// // QQShare.swift // ylgamehall // // QQ 分享 SharePlatform 实现: // - type != "2" → 链接分享:mqqapi://share/to_fri 全量参数(src_type/app_id // /thirdAppDisplayName/callback_* + file_type=news + 双层 // base64 title/desc/url),对齐 msext QQShareManager // shareToQQFriend:QQShareTypeNews 的 URL Scheme fallback 路径 // - type == "2" → 截图分享:UIPasteboard + 引导框(URL Scheme 内联 base64 // 截图字节 1080p ≈ 5MB URL,iOS openURL 长度限制下不可靠, // msext 实战也常被截断;保持剪贴板 + 引导框模式) // // **不依赖 QQ SDK**(CLAUDE.md ADR-006):URL Scheme + 公开 AppID 即可调起 // QQ 原生分享 UI。 // // AppID 102793577:复用 daoqi 注册的 QQ 互联 AppID。原绑 bundle id 是 // com.chao.msext,新外壳 com.skyapp.ylgamehall —— QQ 客户端唤起分享 UI 时 // 实测不严格校验,但若分享后回调 / 反向接口被拒,需 https://connect.qq.com/ // 重新注册 AppID 绑给 com.skyapp.ylgamehall(只改下面 4 个常量 + Info.plist // CFBundleURLSchemes 的 tencent<新ID>)。 // // fire-and-forget:sharesuccess 已在 FriendsShareHandler 立即触发。 // import UIKit import os.log @MainActor public final class QQShare: SharePlatform { public static let shared = QQShare() private static let log = Logger(subsystem: "ylgamehall", category: "QQShare") public let name = "QQ" // MARK: - QQ 互联 AppID 配置(沿用 daoqi) /// QQ 互联开放平台 AppID(沿用 daoqi `kQQAppID`) private static let appID = "102793577" /// app_id 对应的 8 位 hex(102793577 → 0x6208169 → "06208169",前补 0 到 8 位)。 /// QQ 客户端要求 callback_name == "QQ" + hex(appID, padding=8) private static let appIDHex = "06208169" /// 与 Info.plist CFBundleDisplayName 字面对齐,msext kQQAppName 同源 private static let appName = "进贤聚友棋牌" public var isInstalled: Bool { guard let url = URL(string: "mqqapi://") else { return false } return UIApplication.shared.canOpenURL(url) } public init() {} public func share(_ content: ShareContent, scene: ShareScene) { guard isInstalled else { return } if content.type == "2" { shareScreenshot() } else { shareLink(content, scene: scene) } } // MARK: - 链接分享(URL Scheme 全量参数) /// 对齐 msext QQShareManager.m:200-523 `shareToQQFriend:QQShareTypeNews` /// 的 URL Scheme fallback 路径。URL 大致结构: /// /// mqqapi://share/to_fri? /// version=1 /// &src_type=app /// &thirdAppDisplayName= /// &app_name= /// &app_id=102793577 /// &share_id=102793577 /// &callback_type=scheme /// &callback_name=QQ06208169 /// &file_type=news /// &req_type=1 /// &cflag=0 /// &title= /// &description= /// &url= /// /// scene == .timeline → share/to_qzone(QQ 空间);.friend → share/to_fri(QQ 好友) private func shareLink(_ content: ShareContent, scene: ShareScene) { let host = (scene == .timeline) ? "share/to_qzone" : "share/to_fri" // 1. 公共参数(AppID / 回调)—— 缺任意一项 QQ 都不弹分享面板 var parts: [String] = [ "version=1", "src_type=app", "file_type=news", "req_type=1", "cflag=0", "app_id=\(Self.appID)", "share_id=\(Self.appID)", "callback_type=scheme", "callback_name=QQ\(Self.appIDHex)" ] let encodedAppName = Self.encode(Self.base64(Self.appName)) parts.append("thirdAppDisplayName=\(encodedAppName)") parts.append("app_name=\(encodedAppName)") // 2. 内容参数 —— News 类型 title/desc/url 必须 base64 后再 URL-encode if !content.title.isEmpty { parts.append("title=\(Self.encode(Self.base64(content.title)))") } if !content.desc.isEmpty { parts.append("description=\(Self.encode(Self.base64(content.desc)))") } if !content.webpageUrl.isEmpty { parts.append("url=\(Self.encode(Self.base64(content.webpageUrl)))") } // 3. 缩略图 —— 字段名是 previewimagedata(图像字节 base64),不是 // msext 写的 previewimageUrl=base64("file://..."): 后者跨 app 沙盒读不到 // 本地文件(控制台 `unable to make sandbox extension` 即此原因),实测 // QQ 渲染破图占位。改对齐 QQ Open SDK 的 previewimagedata 字段,把 // app icon JPEG 字节本身 base64 内联到 URL。 // iOS openURL URL 长度限制 ~64KB,app icon 60-180px JPEG 0.6 编码后 // base64 ≈ 5-15KB,余量充足。 if let thumbBase64 = Self.appIconBase64() { parts.append("previewimagedata=\(Self.encode(thumbBase64))") } let urlString = "mqqapi://\(host)?\(parts.joined(separator: "&"))" Self.log.debug("QQShare URL → \(urlString, privacy: .public)") guard let qqURL = URL(string: urlString), UIApplication.shared.canOpenURL(qqURL) else { Self.log.error("QQShare canOpenURL=false url=\(urlString, privacy: .public)") return } UIApplication.shared.open(qqURL) } // MARK: - 截图分享(剪贴板 + 引导框) /// 对齐 msext QQShareManager.m:1876-1906 isScreenshotShare 分支: /// 原项目用 QQ SDK QQShareTypeImage 直接传 UIImage;URL Scheme 路径 /// 理论可走 `image_base64` 内联,但 1080p 截图编码后 URL 约 5MB,iOS /// `openURL` 不可靠。保持剪贴板 + 引导框模式(与 Douyin 截图同款)。 private func shareScreenshot() { do { let shot = try ImageProvider.captureScreenshot() UIPasteboard.general.image = shot Self.log.debug("QQShare 截图已复制到剪贴板") } catch { Self.log.error("QQShare 截图失败 \(error.localizedDescription, privacy: .public)") 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: - 缩略图(app icon JPEG → base64 内联到 URL) /// app icon JPEG 0.6 → base64 字符串。QQ Open SDK previewimagedata 字段的值。 /// 跨 app 沙盒下 file:// 路径不可读(控制台 `sandbox extension`),唯一可 /// 靠的传递方式是把图像字节直接 base64 编码到 URL 参数里。 private static func appIconBase64() -> String? { guard let data = appIconImage().jpegData(compressionQuality: 0.6) else { return nil } return data.base64EncodedString() } /// app icon UIImage,与 WechatShare.appIconImage() 同源策略 /// (读 Info.plist CFBundleIcons → CFBundlePrimaryIcon → CFBundleIconFiles 最后一个) private static func appIconImage() -> UIImage { if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any], let primary = icons["CFBundlePrimaryIcon"] as? [String: Any], let files = primary["CFBundleIconFiles"] as? [String], let last = files.last, let icon = UIImage(named: last) { return icon } if let icon = UIImage(named: "AppIcon") { return icon } let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) return renderer.image { _ in } } // MARK: - URL / Base64 编码 /// RFC 3986 unreserved + URL-encode(与 msext line 1262-1268 等价) nonisolated static func encode(_ string: String) -> String { var allowed = CharacterSet.alphanumerics allowed.insert(charactersIn: "-._~") return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" } /// UTF-8 → base64(与 msext base64Encode: 等价) nonisolated static func base64(_ string: String) -> String { guard let data = string.data(using: .utf8) else { return "" } return data.base64EncodedString() } }