实测控制台 `unable to make sandbox extension: [1: Operation not permitted]`
证实 previewimageUrl=file://... 跨 app 沙盒无效 —— QQ 进程读不到调用方 app
tmp 目录里的文件,渲染破图占位。
msext 这段实现一直是装饰性代码(线上同样破图但没人改)。新外壳按原则 B
现代化修正:
- 字段名改用 QQ Open SDK 实际约定的 `previewimagedata`(值=图像字节
base64,而非 file path base64)
- 内容来源改用 app icon JPEG 0.6 编码后 base64 内联到 URL
- 删 writeAppIconToTemp(不再写临时文件)
App icon 60-180px JPEG 0.6 后 base64 约 5-15KB,iOS openURL ~64KB 限额下
余量充足。这条修正违反"原则 A 等价 msext"吗?不——契约 §3.1[2]
sharesuccess 反向 callback 已经在 commit 9bd5a81 落地,与 H5 唯一可见
的契约面(callback 时机与载荷)保持等价。msext 这处缩略图字段错写属于
原项目内部 bug,新外壳的"原则 B 内部自由重构"覆盖范围,按现代标准修正
不破契约。
238 lines
10 KiB
Swift
238 lines
10 KiB
Swift
//
|
||
// 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=<urlenc(base64(appName))>
|
||
/// &app_name=<urlenc(base64(appName))>
|
||
/// &app_id=102793577
|
||
/// &share_id=102793577
|
||
/// &callback_type=scheme
|
||
/// &callback_name=QQ06208169
|
||
/// &file_type=news
|
||
/// &req_type=1
|
||
/// &cflag=0
|
||
/// &title=<urlenc(base64(title))>
|
||
/// &description=<urlenc(base64(desc))>
|
||
/// &url=<urlenc(base64(webpageUrl))>
|
||
///
|
||
/// 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()
|
||
}
|
||
}
|