4.B QQ 链接分享补齐 URL Scheme 全量参数:可弹好友选择面板
【契约影响】§3.1[2]仅 native 实现细节,H5 无感 先前 QQShare.shareLink 只拼 version/cflag/req_type/url/title/description 6 个字段,QQ 客户端识别失败 → 只打开主页,不弹好友选择。对齐 msext QQShareManager.shareToQQFriend:QQShareTypeNews URL Scheme fallback 路径 补齐: - 公共参数:src_type=app / app_id / share_id / file_type=news / callback_type=scheme / callback_name=QQ<hex(appID,8)> / thirdAppDisplayName / app_name(后两者 base64+urlenc) - 内容参数:News 类型 title/desc/url 必须 base64 后再 URL-encode(与 msext line 513-515 一致) 新增常量(沿用 daoqi 注册值): appID = 102793577 (daoqi kQQAppID) appIDHex = 06208169 (callback_name 后缀,QQ 协议要求) appName = 进贤聚友棋牌 (msext kQQAppName 同源) Info.plist 增加 CFBundleURLSchemes tencent102793577 —— QQ 客户端识别 "调用者已在 Info.plist 注册 AppID"的前置条件,没这一项 QQ 直接拒绝弹 分享面板。 ⚠️ AppID 102793577 在 QQ 互联开放平台绑给 com.chao.msext。新外壳 bundle id com.skyapp.ylgamehall —— QQ 客户端唤起 UI 时实测大概率不严格校验, 但若后续遇到拒绝/回调失败,需到 https://connect.qq.com/ 重新注册 AppID 绑给新 bundle id,只改 QQShare 顶部 3 个常量 + Info.plist 即可。 截图分享(type==2)保持剪贴板 + 引导框模式 —— URL Scheme 内联 base64 1080p 截图编码后 URL ≈ 5MB,iOS openURL 不可靠。
This commit is contained in:
@@ -71,6 +71,11 @@
|
||||
CFBundleURLTypes — 第三方 SDK 回调跳回本 App 用的 URL Scheme。
|
||||
- 微信(Phase 4.E SDK 接入):wx586a9b321e56efb7(沿用 msext AppID,
|
||||
docs/SDK-Integration-Guide.md §0)
|
||||
- QQ(URL Scheme 分享路径):tencent102793577(沿用 daoqi AppID。
|
||||
callback 用于 QQ 客户端把分享结果跳回本 App。当前 sharesuccess
|
||||
走 fire-and-forget 不依赖回调,但**声明 URL Scheme 是 QQ 客户端
|
||||
识别"调用者已注册 AppID"的前置条件**,没有这一项 QQ 会拒绝弹分
|
||||
享面板,只打开主页。详见 Source/Share/QQShare.swift。)
|
||||
-->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
@@ -82,6 +87,14 @@
|
||||
<string>wx586a9b321e56efb7</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>tencent</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>tencent102793577</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!--
|
||||
NSPhotoLibraryAddUsageDescription — Phase 4.D 抖音分享需要保存截图 / 远端图
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
// QQShare.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// QQ 分享 SharePlatform 实现,对齐 daoqi msext QQShareManager
|
||||
// `shareWithContent:completion:`(按 type 分两个分支):
|
||||
// - type == "2" → 截图分享:复制截图到 UIPasteboard + 弹引导框("立即打开 QQ")
|
||||
// - else → 链接分享:mqqapi://share/to_fri?req_type=1&url=...
|
||||
// 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):原项目走 SDK 弹原生会话列表带
|
||||
// 图片预览;无 SDK 路径下退化为"剪贴板 + 引导框",与 Douyin 同款 UX 模式。
|
||||
// **不依赖 QQ SDK**(CLAUDE.md ADR-006):URL Scheme + 公开 AppID 即可调起
|
||||
// QQ 原生分享 UI。
|
||||
//
|
||||
// encodeString 与 msext line 1262-1268 等价:unreserved 字符集(RFC 3986
|
||||
// alphanumeric + "-._~"),其它全部 percent encode。
|
||||
// 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 立即触发。
|
||||
//
|
||||
@@ -28,6 +35,16 @@ public final class QQShare: SharePlatform {
|
||||
|
||||
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)
|
||||
@@ -45,12 +62,75 @@ public final class QQShare: SharePlatform {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 截图分享
|
||||
// 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)))")
|
||||
}
|
||||
|
||||
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 shareToQQFriend QQShareTypeImage 直接传 UIImage 给 SDK;
|
||||
/// 无 SDK 路径下退化为:截图 → UIPasteboard.image → 弹引导框("立即打开 QQ")。
|
||||
/// UX 模式与 DouyinShare 截图分享对齐(都是非 SDK 平台的剪贴板兜底)。
|
||||
/// 原项目用 QQ SDK QQShareTypeImage 直接传 UIImage;URL Scheme 路径
|
||||
/// 理论可走 `image_base64` 内联,但 1080p 截图编码后 URL 约 5MB,iOS
|
||||
/// `openURL` 不可靠。保持剪贴板 + 引导框模式(与 Douyin 截图同款)。
|
||||
private func shareScreenshot() {
|
||||
do {
|
||||
let shot = try ImageProvider.captureScreenshot()
|
||||
@@ -104,37 +184,18 @@ public final class QQShare: SharePlatform {
|
||||
return top
|
||||
}
|
||||
|
||||
// MARK: - 链接分享
|
||||
|
||||
/// 对齐 msext QQShareManager.m simpleShareToQQFriend:
|
||||
/// mqqapi://share/to_fri?version=1&cflag=0&req_type=1
|
||||
/// &url=<encoded webpageUrl>&title=<encoded title>&description=<encoded desc>
|
||||
/// scene == .timeline 走 QQ 空间 (to_qzone);.friend 走 QQ 好友 (to_fri)
|
||||
private func shareLink(_ content: ShareContent, scene: ShareScene) {
|
||||
let host = scene == .timeline ? "share/to_qzone" : "share/to_fri"
|
||||
var parts: [String] = ["version=1", "cflag=0"]
|
||||
if !content.webpageUrl.isEmpty {
|
||||
parts.append("req_type=1")
|
||||
parts.append("url=\(Self.encode(content.webpageUrl))")
|
||||
} else {
|
||||
parts.append("req_type=0")
|
||||
}
|
||||
if !content.title.isEmpty {
|
||||
parts.append("title=\(Self.encode(content.title))")
|
||||
}
|
||||
if !content.desc.isEmpty {
|
||||
parts.append("description=\(Self.encode(content.desc))")
|
||||
}
|
||||
let urlString = "mqqapi://\(host)?\(parts.joined(separator: "&"))"
|
||||
|
||||
guard let qqURL = URL(string: urlString),
|
||||
UIApplication.shared.canOpenURL(qqURL) else { return }
|
||||
UIApplication.shared.open(qqURL)
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user