Files
youle_app_ios_v2/ylgamehall/Source/Share/QQShare.swift
T
joywayer 2f3a35892b 4.B QQ 链接分享补 previewimageUrl:用 app icon 当缩略图
QQ 会话里链接分享的缩略图位空白,因为 News 类型缺 previewimageUrl 字段。
对齐 msext QQShareManager.m:517-522 + 1271-1278 的写法:

1. app icon JPEG 0.7 写到 NSTemporaryDirectory()/qq_share_<UUID>.jpg
2. 拼 file://<path> 字符串
3. base64 后再 URL-encode
4. append &previewimageUrl=<那串>

writeAppIconToTemp() 与 appIconImage() 直接抄 WechatShare 同源策略(读
Info.plist CFBundleIcons → CFBundlePrimaryIcon → CFBundleIconFiles 最后
一个)。两处复制可接受 —— Share 模块统一缩略图工具可后续抽到
ImageProvider,本次保持改动范围最小。
2026-06-24 18:03:58 +08:00

241 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. 缩略图 —— app icon 写到 tmp 目录 → previewimageUrl = urlenc(base64("file://<path>"))
// 对齐 msext QQShareManager.m:517-522 saveImageToTempDirectory + base64
if let thumbPath = Self.writeAppIconToTemp() {
let fileURL = "file://\(thumbPath)"
parts.append("previewimageUrl=\(Self.encode(Self.base64(fileURL)))")
}
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 → tmp file
/// 把 app icon JPEG 写到 NSTemporaryDirectory,返回绝对路径。
/// 对齐 msext QQShareManager.m:1271-1278 saveImageToTempDirectory:
/// 文件名 qq_share_<UUID>.jpgJPEG 0.7 质量。失败返回 nil。
private static func writeAppIconToTemp() -> String? {
guard let data = appIconImage().jpegData(compressionQuality: 0.7) else { return nil }
let path = NSTemporaryDirectory() + "qq_share_\(UUID().uuidString).jpg"
do {
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
return path
} catch {
log.error("QQShare 写缩略图临时文件失败 \(error.localizedDescription, privacy: .public)")
return nil
}
}
/// 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()
}
}