Files
youle_app_ios_v2/ylgamehall/Source/Share/QQShare.swift
T
joywayer 1d231a52ef 4.B QQ 链接分享改 previewimagedata 内联 base64:修缩略图破图
实测控制台 `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 内部自由重构"覆盖范围,按现代标准修正
不破契约。
2026-06-24 18:45:05 +08:00

238 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 URLiOS 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-forgetsharesuccess 已在 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 位 hex102793577 → 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_qzoneQQ 空间);.friend → share/to_friQQ 好友)
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 长度限制 ~64KBapp 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 直接传 UIImageURL Scheme 路径
/// 理论可走 `image_base64` 内联,但 1080p 截图编码后 URL 约 5MBiOS
/// `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()
}
}