4.B 截图分享补齐 msext 字段:WeChat description / QQ 引导框

【契约影响】docs/H5-Native-Contract.md §3.1[2]type==2 分支

WeChat 截图分享之前丢了 H5 description(msext 通过
sendImageData:MessageExt: 写到 WXMediaMessage.description,接收方在
微信聊天预览里能看到)。链接分享之前完全没传 thumb(msext 用 app
icon)。统一补齐:

- WeChatManager.ShareImage 加 messageExt / mediaTagName 字段;
  shareImage 内 message.description = messageExt + message.messageExt
  = messageExt + setThumbImage(thumb) + mediaTagName
- WeChatManager.ShareLink 把 thumbnailURL: String? 替换为 thumb:
  UIImage?;shareLink 内 setThumbImage(thumb) + mediaTagName。drop
  Sendable 因 UIImage 不可 Sendable(调用都在 MainActor 上无风险)
- WechatShare 双分支统一传 appIconImage() 做 thumb;截图分支 messageExt
  = content.desc + mediaTagName "进贤聚友棋牌游戏截图分享";链接分支
  mediaTagName "进贤聚友棋牌游戏分享"(字面对齐 msext WechatShareManager.m)

QQ 截图分享原本 4-fallback URL 链不靠谱(file_type=news 给截图场景实际
无意义)。简化为 Douyin 同款模式:截图 → UIPasteboard.image → 弹"立即
打开 QQ / 稍后分享"二选一 Alert。承认 ADR-006 无 QQ SDK 退化的现实,
但 UX 不再让用户摸不着北。
This commit is contained in:
joywayer
2026-06-24 07:53:14 +08:00
parent 9bd5a81e6c
commit 68c668a06c
3 changed files with 109 additions and 34 deletions
@@ -91,27 +91,45 @@ public final class WeChatManager: NSObject {
case timeline // 朋友圈 case timeline // 朋友圈
} }
public struct ShareLink: Sendable { /// 链接分享载荷。`mediaTagName` 对齐 msext WXApiRequestHandler `TagName:`
/// 参数(写入 WXMediaMessage.mediaTagName,微信内部分类用)。
/// 含 UIImage 故无法 Sendable —— 实际使用都在 MainActor 上发起,无跨 actor 风险。
public struct ShareLink {
public let url: String public let url: String
public let title: String public let title: String
public let desc: String public let desc: String
public let thumbnailURL: String? public let thumb: UIImage?
public let mediaTagName: String?
public init(url: String, title: String, desc: String, thumbnailURL: String?) { public init(
self.url = url; self.title = title; self.desc = desc; self.thumbnailURL = thumbnailURL url: String, title: String, desc: String,
thumb: UIImage?, mediaTagName: String? = nil
) {
self.url = url; self.title = title; self.desc = desc
self.thumb = thumb; self.mediaTagName = mediaTagName
} }
} }
/// 图片分享载荷(type=2 截图 / type=3 远端图)。 /// 图片分享载荷(type=2 截图)。
/// imageData 是大图原始字节(JPEG 等),thumb 是缩略图源 — SDK /// - `imageData`:大图原始字节(JPEG)
/// `setThumbImage:` 内部把 thumb 压到 32KB。 /// - `thumb`:缩略图源 — SDK `setThumbImage:` 内部压到 32KB
/// - `messageExt`:H5 的 description;写入 `WXMediaMessage.description`
/// 与 `.messageExt`,对齐 msext `WXApiRequestHandler sendImageData:MessageExt:`
/// - `mediaTagName`:对齐 msext `TagName:` 参数
public struct ShareImage { public struct ShareImage {
public let imageData: Data public let imageData: Data
public let thumb: UIImage public let thumb: UIImage
public let messageExt: String
public let mediaTagName: String?
public init(imageData: Data, thumb: UIImage) { public init(
imageData: Data, thumb: UIImage,
messageExt: String = "", mediaTagName: String? = nil
) {
self.imageData = imageData self.imageData = imageData
self.thumb = thumb self.thumb = thumb
self.messageExt = messageExt
self.mediaTagName = mediaTagName
} }
} }
@@ -127,6 +145,12 @@ public final class WeChatManager: NSObject {
message.title = link.title message.title = link.title
message.description = link.desc message.description = link.desc
message.mediaObject = media message.mediaObject = media
if let mediaTagName = link.mediaTagName {
message.mediaTagName = mediaTagName
}
if let thumb = link.thumb {
message.setThumbImage(thumb)
}
let req = SendMessageToWXReq() let req = SendMessageToWXReq()
req.bText = false req.bText = false
@@ -143,8 +167,11 @@ public final class WeChatManager: NSObject {
} }
} }
/// 图片分享(FIFO 串行,复用 sharePending)。msext gameController.m:637-678 /// 图片分享(FIFO 串行,复用 sharePending)。对齐 msext WechatShareManager
/// type=2 截图 / type=其它 远端图 共用同一条 WXImageObject + setThumbImage 路径。 /// `sendImageData:TagName:MessageExt:Action:ThumbImage:InScene:` 的字段映射:
/// TagName → mediaTagName
/// MessageExt → description(接收方预览可见)+ messageExt(SDK 内部回包)
/// ThumbImage → setThumbImage
public func shareImage(_ img: ShareImage, scene: ShareScene) async throws { public func shareImage(_ img: ShareImage, scene: ShareScene) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
sharePending.append(cont) sharePending.append(cont)
@@ -153,9 +180,15 @@ public final class WeChatManager: NSObject {
media.imageData = img.imageData media.imageData = img.imageData
let message = WXMediaMessage() let message = WXMediaMessage()
// 图片分享不需要 title / description(msext 字面:传 nil;这里传空串等价) message.description = img.messageExt
message.mediaObject = media message.mediaObject = media
message.setThumbImage(img.thumb) message.setThumbImage(img.thumb)
if !img.messageExt.isEmpty {
message.messageExt = img.messageExt
}
if let mediaTagName = img.mediaTagName {
message.mediaTagName = mediaTagName
}
let req = SendMessageToWXReq() let req = SendMessageToWXReq()
req.bText = false req.bText = false
+48 -16
View File
@@ -4,11 +4,11 @@
// //
// QQ 分享 SharePlatform 实现,对齐 daoqi msext QQShareManager // QQ 分享 SharePlatform 实现,对齐 daoqi msext QQShareManager
// `shareWithContent:completion:`(按 type 分两个分支): // `shareWithContent:completion:`(按 type 分两个分支):
// - type == "2" → 截图分享:复制截图到 UIPasteboard + 拉起 mqqapi:// // - type == "2" → 截图分享:复制截图到 UIPasteboard + 弹引导框("立即打开 QQ")
// - else → 链接分享:mqqapi://share/to_fri?req_type=1&url=... // - else → 链接分享:mqqapi://share/to_fri?req_type=1&url=...
// //
// **不依赖 QQ SDK**(CLAUDE.md ADR-006):原项目走 SDK 弹原生会话列表, // **不依赖 QQ SDK**(CLAUDE.md ADR-006):原项目走 SDK 弹原生会话列表带
// 新外壳走 URL Scheme + pasteboard 兜底实现等价 UX。 // 图片预览;无 SDK 路径下退化为"剪贴板 + 引导框",与 Douyin 同款 UX 模式。
// //
// encodeString 与 msext line 1262-1268 等价:unreserved 字符集(RFC 3986 // encodeString 与 msext line 1262-1268 等价:unreserved 字符集(RFC 3986
// alphanumeric + "-._~"),其它全部 percent encode。 // alphanumeric + "-._~"),其它全部 percent encode。
@@ -49,27 +49,59 @@ public final class QQShare: SharePlatform {
/// 对齐 msext QQShareManager.m:1876-1906 isScreenshotShare 分支: /// 对齐 msext QQShareManager.m:1876-1906 isScreenshotShare 分支:
/// 原项目用 QQ SDK shareToQQFriend QQShareTypeImage 直接传 UIImage 给 SDK; /// 原项目用 QQ SDK shareToQQFriend QQShareTypeImage 直接传 UIImage 给 SDK;
/// 无 SDK 路径下退化为:截图 → pasteboard → 拉起 QQ(用户在聊天里粘贴)。 /// 无 SDK 路径下退化为:截图 → UIPasteboard.image → 弹引导框("立即打开 QQ")。
/// UX 模式与 DouyinShare 截图分享对齐(都是非 SDK 平台的剪贴板兜底)。
private func shareScreenshot() { private func shareScreenshot() {
do { do {
let shot = try ImageProvider.captureScreenshot() let shot = try ImageProvider.captureScreenshot()
UIPasteboard.general.image = shot UIPasteboard.general.image = shot
Self.log.debug("QQShare 截图已复制到剪贴板")
} catch { } catch {
Self.log.error("QQShare 截图失败 \(error.localizedDescription, privacy: .public)") Self.log.error("QQShare 截图失败 \(error.localizedDescription, privacy: .public)")
}
let candidates = [
"mqqapi://share/to_fri?file_type=news&version=1&cflag=0",
"mqq://im/chat?chat_type=select",
"mqqapi://",
"mqq://"
]
for s in candidates {
guard let url = URL(string: s),
UIApplication.shared.canOpenURL(url) else { continue }
UIApplication.shared.open(url)
return return
} }
let message = """
✅ 图片已复制到剪贴板
🎯 分享到好友或群聊步骤:
1️⃣ 打开 QQ,选择好友或群聊进入聊天
2️⃣ 在输入框长按粘贴图片
3️⃣ 点击发送即可分享
"""
showGuidanceAlert(title: "QQ 分享准备完成", message: message)
}
/// 弹"立即打开 QQ / 稍后分享"二选一 Alert,与 DouyinShare 同款 UX。
private func showGuidanceAlert(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "立即打开 QQ", style: .default) { _ in
if let url = URL(string: "mqqapi://"),
UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
} else if let url = URL(string: "mqq://"),
UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
})
alert.addAction(UIAlertAction(title: "稍后分享", style: .cancel))
guard let topVC = Self.topViewController() else { return }
topVC.present(alert, animated: true)
}
private static func topViewController() -> UIViewController? {
let scene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first(where: { $0.activationState == .foregroundActive })
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
guard var top = scene?.windows.first(where: \.isKeyWindow)?.rootViewController
?? scene?.windows.first?.rootViewController else { return nil }
while let presented = top.presentedViewController { top = presented }
if let nav = top as? UINavigationController, let visible = nav.visibleViewController {
return visible
}
return top
} }
// MARK: - 链接分享 // MARK: - 链接分享
+12 -2
View File
@@ -42,7 +42,12 @@ public final class WechatShare: SharePlatform {
let shot = try ImageProvider.captureScreenshot() let shot = try ImageProvider.captureScreenshot()
guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return } guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return }
try await WeChatManager.shared.shareImage( try await WeChatManager.shared.shareImage(
.init(imageData: imageData, thumb: thumb), .init(
imageData: imageData,
thumb: thumb,
messageExt: content.desc,
mediaTagName: "\(Self.appTagPrefix)游戏截图分享"
),
scene: wxScene scene: wxScene
) )
} else { } else {
@@ -52,7 +57,8 @@ public final class WechatShare: SharePlatform {
url: content.webpageUrl, url: content.webpageUrl,
title: content.title, title: content.title,
desc: content.desc, desc: content.desc,
thumbnailURL: nil thumb: thumb,
mediaTagName: "\(Self.appTagPrefix)游戏分享"
), ),
scene: wxScene scene: wxScene
) )
@@ -63,6 +69,10 @@ public final class WechatShare: SharePlatform {
} }
} }
/// msext WechatShareManager 字面常量 "进贤聚友棋牌"(与 daoqi
/// SGDefineInfo.h gamehallname 同源),用于 WXMediaMessage.mediaTagName 拼接。
private static let appTagPrefix = "进贤聚友棋牌"
/// app icon 缩略图。读 Info.plist `CFBundleIcons` → `CFBundlePrimaryIcon` /// app icon 缩略图。读 Info.plist `CFBundleIcons` → `CFBundlePrimaryIcon`
/// → `CFBundleIconFiles` 的最后一个(最高分辨率),与原 msext /// → `CFBundleIconFiles` 的最后一个(最高分辨率),与原 msext
/// WechatShareManager `getAppIconImage` 等价。失败兜底返回 1x1 透明图。 /// WechatShareManager `getAppIconImage` 等价。失败兜底返回 1x1 透明图。