Files
youle_app_ios_v2/ylgamehall/Source/Share/QQShare.swift
T
joywayer 34a336d859 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 不可靠。
2026-06-24 17:49:23 +08:00

202 lines
8.2 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)))")
}
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: - 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()
}
}