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:
joywayer
2026-06-24 17:49:23 +08:00
parent 0636aadbec
commit 34a336d859
2 changed files with 113 additions and 39 deletions
+13
View File
@@ -71,6 +71,11 @@
CFBundleURLTypes — 第三方 SDK 回调跳回本 App 用的 URL Scheme。
- 微信(Phase 4.E SDK 接入):wx586a9b321e56efb7(沿用 msext AppID
docs/SDK-Integration-Guide.md §0
- QQURL 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 抖音分享需要保存截图 / 远端图
+100 -39
View File
@@ -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 URLiOS openURL
// msext +
//
// ** QQ SDK**CLAUDE.md ADR-006 SDK
// SDK 退" + " Douyin UX
// ** QQ SDK**CLAUDE.md ADR-006URL 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-forgetsharesuccess 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 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)
@@ -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_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)))")
}
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 UIImageURL Scheme
/// `image_base64` 1080p URL 5MBiOS
/// `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()
}
}