【契约影响】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 不再让用户摸不着北。
265 lines
10 KiB
Swift
265 lines
10 KiB
Swift
//
|
||
// WeChatManager.swift
|
||
// ylgamehall
|
||
//
|
||
// 微信 SDK 持久 delegate + authorize + share 异步包装。
|
||
// 把 WXApiDelegate 的回调 → async/await,state UUID 配对多发起方。
|
||
//
|
||
// Design §8.5;msext WXApiManager.m 等价。
|
||
//
|
||
|
||
import Foundation
|
||
import UIKit
|
||
|
||
// 微信 SDK 1.8.2(与 daoqi msext 同款)通过 Bridging Header 导入,无需 import 语句
|
||
|
||
@MainActor
|
||
public final class WeChatManager: NSObject {
|
||
|
||
public static let shared = WeChatManager()
|
||
|
||
/// 授权 scope(沿用 msext SGDefineInfo.h:104)
|
||
public static let authScope = "snsapi_message,snsapi_userinfo,snsapi_friend,snsapi_contact"
|
||
|
||
/// 待 resolve 的 authorize continuation(state UUID 配对)
|
||
private var authPending: [String: CheckedContinuation<WXAuthCodePayload, Error>] = [:]
|
||
/// 待 resolve 的 share continuation(FIFO 串行 — 微信回包不带配对 ID)
|
||
private var sharePending: [CheckedContinuation<Void, Error>] = []
|
||
|
||
public override init() {
|
||
super.init()
|
||
}
|
||
|
||
// MARK: - Authorize
|
||
|
||
/// 拉起微信授权(msext NewRootVC.m → WXApiRequestHandler.m 同款路径)。
|
||
/// 必须用新版 SDK 2.x 的 `sendAuthReq:viewController:delegate:completion:`:
|
||
/// - viewController = SDK 拉起微信回来时的 present 上下文(缺失则 SDK 无法弹起)
|
||
/// - delegate = SDK 弱引用,授权完成由 WXApiDelegate.onResp 回调(与 SceneDelegate
|
||
/// openURL 路径解耦,授权回包独立由 SDK 直接派给 delegate)
|
||
/// 旧版 1.x 的 `WXApi.send(req)` 已 deprecated,不传 vc/delegate 会导致 H5 调了无反应。
|
||
public func authorize() async throws -> WXAuthCodePayload {
|
||
let state = UUID().uuidString
|
||
return try await withCheckedThrowingContinuation { cont in
|
||
authPending[state] = cont
|
||
let req = SendAuthReq()
|
||
req.scope = Self.authScope
|
||
req.state = state
|
||
|
||
guard let vc = Self.topmostViewController() else {
|
||
authPending.removeValue(forKey: state)
|
||
cont.resume(throwing: WeChatError.authFailed(-3))
|
||
return
|
||
}
|
||
// 1.x 的 sendAuthReq 返回 BOOL(同步知道是否成功拉起),无 completion
|
||
let success = WXApi.sendAuthReq(req, viewController: vc, delegate: self)
|
||
if !success {
|
||
if let pending = authPending.removeValue(forKey: state) {
|
||
pending.resume(throwing: WeChatError.authFailed(-3))
|
||
}
|
||
}
|
||
// success=true 时等 WXApiDelegate.onResp 触发(SceneDelegate openURL 链路)
|
||
}
|
||
}
|
||
|
||
/// 取当前最顶层 ViewController(含 presented / nav.visible),供微信 SDK 作为
|
||
/// modal present 的上下文。msext 直接 self(gameController/NewRootVC),新外壳
|
||
/// AccreditLoginHandler 是 stateless enum,由本方法 resolve。
|
||
private static func topmostViewController() -> UIViewController? {
|
||
let windowScene = UIApplication.shared.connectedScenes
|
||
.compactMap { $0 as? UIWindowScene }
|
||
.first(where: { $0.activationState == .foregroundActive })
|
||
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||
guard let root = windowScene?.windows.first(where: \.isKeyWindow)?.rootViewController
|
||
?? windowScene?.windows.first?.rootViewController else {
|
||
return nil
|
||
}
|
||
var top: UIViewController = root
|
||
while let presented = top.presentedViewController {
|
||
top = presented
|
||
}
|
||
if let nav = top as? UINavigationController, let visible = nav.visibleViewController {
|
||
return visible
|
||
}
|
||
return top
|
||
}
|
||
|
||
// MARK: - Share
|
||
|
||
public enum ShareScene {
|
||
case session // 微信好友
|
||
case timeline // 朋友圈
|
||
}
|
||
|
||
/// 链接分享载荷。`mediaTagName` 对齐 msext WXApiRequestHandler `TagName:`
|
||
/// 参数(写入 WXMediaMessage.mediaTagName,微信内部分类用)。
|
||
/// 含 UIImage 故无法 Sendable —— 实际使用都在 MainActor 上发起,无跨 actor 风险。
|
||
public struct ShareLink {
|
||
public let url: String
|
||
public let title: String
|
||
public let desc: String
|
||
public let thumb: UIImage?
|
||
public let mediaTagName: String?
|
||
|
||
public init(
|
||
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 截图)。
|
||
/// - `imageData`:大图原始字节(JPEG)
|
||
/// - `thumb`:缩略图源 — SDK `setThumbImage:` 内部压到 32KB
|
||
/// - `messageExt`:H5 的 description;写入 `WXMediaMessage.description`
|
||
/// 与 `.messageExt`,对齐 msext `WXApiRequestHandler sendImageData:MessageExt:`
|
||
/// - `mediaTagName`:对齐 msext `TagName:` 参数
|
||
public struct ShareImage {
|
||
public let imageData: Data
|
||
public let thumb: UIImage
|
||
public let messageExt: String
|
||
public let mediaTagName: String?
|
||
|
||
public init(
|
||
imageData: Data, thumb: UIImage,
|
||
messageExt: String = "", mediaTagName: String? = nil
|
||
) {
|
||
self.imageData = imageData
|
||
self.thumb = thumb
|
||
self.messageExt = messageExt
|
||
self.mediaTagName = mediaTagName
|
||
}
|
||
}
|
||
|
||
/// 链接分享(FIFO 串行)。微信回包不携带配对 ID,连续调时按入队顺序消费。
|
||
public func shareLink(_ link: ShareLink, scene: ShareScene) async throws {
|
||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||
sharePending.append(cont)
|
||
|
||
let media = WXWebpageObject()
|
||
media.webpageUrl = link.url
|
||
|
||
let message = WXMediaMessage()
|
||
message.title = link.title
|
||
message.description = link.desc
|
||
message.mediaObject = media
|
||
if let mediaTagName = link.mediaTagName {
|
||
message.mediaTagName = mediaTagName
|
||
}
|
||
if let thumb = link.thumb {
|
||
message.setThumbImage(thumb)
|
||
}
|
||
|
||
let req = SendMessageToWXReq()
|
||
req.bText = false
|
||
req.message = message
|
||
switch scene {
|
||
case .session: req.scene = Int32(WXSceneSession.rawValue)
|
||
case .timeline: req.scene = Int32(WXSceneTimeline.rawValue)
|
||
}
|
||
// 1.x: sendReq BOOL 返回;分享回包通过 SceneDelegate.openURLContexts →
|
||
// WXApi.handleOpen(url:delegate:WeChatManager.shared) → onResp 路径到达。
|
||
// ObjC selector +sendReq:,Swift 简化为 send(_:)(去 Req 后缀)。
|
||
// 运行时仍调 +[WXApi sendReq:]
|
||
WXApi.send(req)
|
||
}
|
||
}
|
||
|
||
/// 图片分享(FIFO 串行,复用 sharePending)。对齐 msext WechatShareManager
|
||
/// `sendImageData:TagName:MessageExt:Action:ThumbImage:InScene:` 的字段映射:
|
||
/// TagName → mediaTagName
|
||
/// MessageExt → description(接收方预览可见)+ messageExt(SDK 内部回包)
|
||
/// ThumbImage → setThumbImage
|
||
public func shareImage(_ img: ShareImage, scene: ShareScene) async throws {
|
||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||
sharePending.append(cont)
|
||
|
||
let media = WXImageObject()
|
||
media.imageData = img.imageData
|
||
|
||
let message = WXMediaMessage()
|
||
message.description = img.messageExt
|
||
message.mediaObject = media
|
||
message.setThumbImage(img.thumb)
|
||
if !img.messageExt.isEmpty {
|
||
message.messageExt = img.messageExt
|
||
}
|
||
if let mediaTagName = img.mediaTagName {
|
||
message.mediaTagName = mediaTagName
|
||
}
|
||
|
||
let req = SendMessageToWXReq()
|
||
req.bText = false
|
||
req.message = message
|
||
switch scene {
|
||
case .session: req.scene = Int32(WXSceneSession.rawValue)
|
||
case .timeline: req.scene = Int32(WXSceneTimeline.rawValue)
|
||
}
|
||
// ObjC selector +sendReq:,Swift 简化为 send(_:)(去 Req 后缀)。
|
||
// 运行时仍调 +[WXApi sendReq:]
|
||
WXApi.send(req)
|
||
}
|
||
}
|
||
|
||
// MARK: - Internal: resolve callbacks
|
||
|
||
fileprivate func resolveAuth(state: String, code: String?, errCode: Int) {
|
||
guard let cont = authPending.removeValue(forKey: state) else { return }
|
||
if errCode == 0, let code {
|
||
cont.resume(returning: WXAuthCodePayload(code: code, state: state))
|
||
} else {
|
||
cont.resume(throwing: WeChatError.authFailed(errCode))
|
||
}
|
||
}
|
||
|
||
fileprivate func resolveShare(errCode: Int) {
|
||
guard !sharePending.isEmpty else { return }
|
||
let cont = sharePending.removeFirst()
|
||
if errCode == 0 {
|
||
cont.resume(returning: ())
|
||
} else {
|
||
cont.resume(throwing: WeChatError.shareFailed(errCode))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - WXApiDelegate
|
||
|
||
extension WeChatManager: WXApiDelegate {
|
||
|
||
public nonisolated func onReq(_ req: BaseReq) {
|
||
// 我们当前只发起请求,不接受外部 req(微信 → 本 App 的 req 用于公众号回复等场景)
|
||
}
|
||
|
||
public nonisolated func onResp(_ resp: BaseResp) {
|
||
// 主线程化:BaseResp 由 SDK 在主线程派发,但保留 MainActor.run 兜底
|
||
let errCode = Int(resp.errCode)
|
||
if let auth = resp as? SendAuthResp {
|
||
let code = auth.code
|
||
let state = auth.state ?? ""
|
||
Task { @MainActor in
|
||
Self.shared.resolveAuth(state: state, code: code, errCode: errCode)
|
||
}
|
||
} else if resp is SendMessageToWXResp {
|
||
Task { @MainActor in
|
||
Self.shared.resolveShare(errCode: errCode)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Public payload / error types
|
||
|
||
public struct WXAuthCodePayload: Sendable {
|
||
public let code: String
|
||
public let state: String
|
||
}
|
||
|
||
public enum WeChatError: Error, Sendable {
|
||
case sdkNotLinked
|
||
case authFailed(Int)
|
||
case shareFailed(Int)
|
||
}
|