4.B 分享逻辑对齐 daoqi msext:调用即触发 sharesuccess
【契约影响】docs/H5-Native-Contract.md §3.1[2]/§3.2[11]
H5 端 sharesuccess 时机改为"调用 friendsShare handler 即立刻发",与
原 msext 行为等价(live path 完全不等 SDK / URL Scheme 回包)。用户
明示有意为之:URL Scheme + 剪贴板路径本就没有可靠回包,乐观策略避免
H5 业务卡住。
3 平台分支严格 1:1 对齐 daoqi msext:
WechatShare(对齐 WechatShareManager shareWithContent)
- type=="2" → 截图 JPEG 0.6 + sendImageData + app icon thumb
- else → 链接分享 sendLinkURL + app icon thumb
- 移除原"远端图"分支(原项目无此分支)
- 新增 appIconImage helper(读 Info.plist CFBundleIcons,对齐
[self getAppIconImage])
QQShare(对齐 QQShareManager shareWithContent + simpleShareToQQFriend)
- type=="2" → 截图 → UIPasteboard.image → 4 URL Scheme fallback
- else → mqqapi://share/to_{fri,qzone}?req_type=1&url=...
&title=...&description=...(webpageUrl 空时 req_type=0)
- 不依赖 QQ SDK(CLAUDE.md ADR-006)
DouyinShare(对齐 DouyinShareManager shareWithContent)
- type=="2" → 截图 → UIPasteboard.image → "立即打开抖音"引导框
- else → title+"\n\n"+desc → UIPasteboard.string → 引导框
双侧空时兜底 "来自进贤聚友棋牌的精彩内容分享"
- 放弃之前的"存相册 + 自动拉起"激进路径,回到原项目"用户主动点"路径
架构调整:
- SharePlatform.share 改 sync fire-and-forget(去 ShareResult 返回)
- ShareCenter.dispatch / SharePanel.show 去 completion 参数
- FriendsShareHandler:解析后先发 responseCallback("sharefriend")
与 sharesuccess({success:"2", type:<sharefriend>}),再异步 dispatch
This commit is contained in:
@@ -5,16 +5,17 @@
|
|||||||
// H5 → Native handler:friendsSharetypeUrlToptitleDescript — 分享
|
// H5 → Native handler:friendsSharetypeUrlToptitleDescript — 分享
|
||||||
// 契约:docs/H5-Native-Contract.md §3.1 [2] + §3.2 [11]sharesuccess
|
// 契约:docs/H5-Native-Contract.md §3.1 [2] + §3.2 [11]sharesuccess
|
||||||
//
|
//
|
||||||
// 完整流程(与 msext gameController.m:575-597 等价):
|
// 流程(与 msext gameController.m:575-597 等价,调用即视为成功):
|
||||||
// 1. 入参解析 ShareContent
|
// 1. 入参解析 ShareContent
|
||||||
// 2. ShareCenter.dispatch:
|
// 2. responseCallback("sharefriend")
|
||||||
// - sharefriend == "1" → SharePanel 三选一(微信好友 / QQ / 抖音)
|
// 3. 立即 bridge.call("sharesuccess", { success:"2", type:<sharefriend 原值> })
|
||||||
// - sharefriend == "2" → 直接 WechatShare 朋友圈
|
// (**有意为之**:H5 端把"调用分享接口"等同"分享成功",无须等 SDK 回包;
|
||||||
// 3. 分享完成 → bridge.call("sharesuccess", { success: "2", type: <sharefriend 原值> })
|
// URL Scheme / 剪贴板路径本身就无返回。)
|
||||||
|
// 4. 异步 fire-and-forget 调用 ShareCenter.dispatch 拉起真实分享 UI
|
||||||
//
|
//
|
||||||
// 各平台 SDK 依赖:
|
// 各平台 SDK 依赖:
|
||||||
// - 微信:Phase 4.E 接入项目方 SDK(当前 WechatShare 是 stub)
|
// - 微信:通过 WeChatManager(已接入 1.8.2 SDK)
|
||||||
// - QQ + 抖音:不依赖 SDK,走 URL Scheme(Phase 4.C/4.D 真实落地,当前 stub)
|
// - QQ + 抖音:不依赖 SDK,走 URL Scheme + 剪贴板(CLAUDE.md ADR-006)
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -28,6 +29,7 @@ public enum FriendsShareHandler {
|
|||||||
// cb: "sharefriend"(msext 字面,沿用)
|
// cb: "sharefriend"(msext 字面,沿用)
|
||||||
// 反向 callback:sharesuccess({ success: "2", type: sharefriend 原值 })
|
// 反向 callback:sharesuccess({ success: "2", type: sharefriend 原值 })
|
||||||
bridge.register("friendsSharetypeUrlToptitleDescript") { [weak bridge] data, callback in
|
bridge.register("friendsSharetypeUrlToptitleDescript") { [weak bridge] data, callback in
|
||||||
|
// 1. responseCallback —— msext 字面 "sharefriend"
|
||||||
callback?(.string("sharefriend"))
|
callback?(.string("sharefriend"))
|
||||||
|
|
||||||
guard let obj = data?.asObject else { return }
|
guard let obj = data?.asObject else { return }
|
||||||
@@ -39,25 +41,20 @@ public enum FriendsShareHandler {
|
|||||||
title: obj["title"]?.asString ?? "",
|
title: obj["title"]?.asString ?? "",
|
||||||
desc: obj["description"]?.asString ?? ""
|
desc: obj["description"]?.asString ?? ""
|
||||||
)
|
)
|
||||||
let sharefriend = content.sharefriend
|
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
ShareCenter.dispatch(content) { result in
|
// 2. 立即触发 sharesuccess —— 有意为之:调用即视为成功
|
||||||
// success / cancelled / notInstalled / failed — msext 行为:
|
bridge?.call(
|
||||||
// 只要不是明确失败就发 sharesuccess(乐观策略,URL Scheme 无返回)
|
"sharesuccess",
|
||||||
switch result {
|
|
||||||
case .success, .cancelled:
|
|
||||||
bridge?.call("sharesuccess",
|
|
||||||
data: .object([
|
data: .object([
|
||||||
"success": .string("2"),
|
"success": .string("2"),
|
||||||
"type": .string(sharefriend)
|
"type": .string(content.sharefriend)
|
||||||
]),
|
]),
|
||||||
callback: nil)
|
callback: nil
|
||||||
case .notInstalled, .failed:
|
)
|
||||||
// 不发 sharesuccess(H5 业务期 timeout 后自行 fallback)
|
|
||||||
break
|
// 3. fire-and-forget 拉起真实分享 UI
|
||||||
}
|
ShareCenter.dispatch(content)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,15 @@
|
|||||||
// DouyinShare.swift
|
// DouyinShare.swift
|
||||||
// ylgamehall
|
// ylgamehall
|
||||||
//
|
//
|
||||||
// 抖音分享 SharePlatform 实现(Phase 4.D 完整)。
|
// 抖音分享 SharePlatform 实现,1:1 对齐 daoqi msext DouyinShareManager
|
||||||
|
// `shareWithContent:completion:`(按 type 分两个分支):
|
||||||
|
// - type == "2" → 截图分享:截图 → UIPasteboard → 弹"立即打开抖音"引导框
|
||||||
|
// - else → 文字分享:title + "\n\n" + desc → UIPasteboard → 弹引导框
|
||||||
//
|
//
|
||||||
// 移植 msext DouyinShareManager.m:201-269 shareImageToDouyin 流程:
|
// **不依赖抖音 SDK**(CLAUDE.md ADR-006):抖音的图片 / 文本分享原本就是
|
||||||
// 1. 拿图片源(type=2 → ImageProvider.captureScreenshot;其它 →
|
// 剪贴板 + 引导用户在抖音内粘贴的非 SDK 路径,新外壳照搬。
|
||||||
// ImageProvider.downloadRemote);type=1 链接 → 抖音不原生支持纯链接,
|
|
||||||
// 退化为截屏分享(msext 行为:所有 H5 分享到抖音都走图片)
|
|
||||||
// 2. PhotoLibrarySaver.save 保存到相册(addOnly 权限)
|
|
||||||
// 3. UIPasteboard 兜底放图片数据,供抖音读取
|
|
||||||
// 4. 按顺序尝试 snssdk1128://camera / publish / share/image / 基础 scheme,
|
|
||||||
// canOpenURL 命中即调起
|
|
||||||
// 5. 调起即视为 success(URL Scheme 单向无返回)
|
|
||||||
//
|
//
|
||||||
// **不依赖抖音 SDK**。
|
// fire-and-forget:sharesuccess 已在 FriendsShareHandler 立即触发。
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -27,68 +23,113 @@ public final class DouyinShare: SharePlatform {
|
|||||||
|
|
||||||
private static let log = Logger(subsystem: "ylgamehall", category: "DouyinShare")
|
private static let log = Logger(subsystem: "ylgamehall", category: "DouyinShare")
|
||||||
|
|
||||||
|
private static let douyinScheme = "snssdk1128://"
|
||||||
|
|
||||||
public let name = "Douyin"
|
public let name = "Douyin"
|
||||||
|
|
||||||
public var isInstalled: Bool {
|
public var isInstalled: Bool {
|
||||||
guard let url = URL(string: "snssdk1128://") else { return false }
|
guard let url = URL(string: Self.douyinScheme) else { return false }
|
||||||
return UIApplication.shared.canOpenURL(url)
|
return UIApplication.shared.canOpenURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult {
|
public func share(_ content: ShareContent, scene: ShareScene) {
|
||||||
guard isInstalled else { return .notInstalled(platform: name) }
|
guard isInstalled else { return }
|
||||||
|
|
||||||
// 1. 拿图片源
|
if content.type == "2" {
|
||||||
let image: UIImage
|
shareScreenshot()
|
||||||
|
} else {
|
||||||
|
shareText(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 截图分享
|
||||||
|
|
||||||
|
/// 对齐 msext DouyinShareManager.m:139-162 isScreenshotShare 分支:
|
||||||
|
/// 截图 → pasteboard.image → 弹引导框(不自动拉起抖音,等用户点"立即打开")。
|
||||||
|
private func shareScreenshot() {
|
||||||
do {
|
do {
|
||||||
switch content.type {
|
let shot = try ImageProvider.captureScreenshot()
|
||||||
case "2":
|
UIPasteboard.general.image = shot
|
||||||
image = try ImageProvider.captureScreenshot()
|
Self.log.debug("DouyinShare 截图已复制到剪贴板")
|
||||||
case "1":
|
|
||||||
// 抖音不支持纯链接分享,退化为当前屏截屏(msext 行为)
|
|
||||||
Self.log.debug("DouyinShare: type=1 链接,退化为截屏分享")
|
|
||||||
image = try ImageProvider.captureScreenshot()
|
|
||||||
default:
|
|
||||||
// type == 其它 → 远端图片 URL
|
|
||||||
image = try await ImageProvider.downloadRemote(content.webpageUrl)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
Self.log.error("DouyinShare: 获取图片失败 \(error.localizedDescription, privacy: .public)")
|
Self.log.error("DouyinShare 截图失败 \(error.localizedDescription, privacy: .public)")
|
||||||
return .failed(reason: "获取图片失败: \(error.localizedDescription)")
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 保存到相册(含权限检查)
|
let message = """
|
||||||
do {
|
✅ 图片已复制到剪贴板
|
||||||
try await PhotoLibrarySaver.save(image)
|
|
||||||
} catch PhotoLibrarySaverError.permissionDenied {
|
🎯 分享到好友或群聊步骤:
|
||||||
Self.log.warning("DouyinShare: 相册写入权限被拒")
|
1️⃣ 打开抖音,点击右下角「消息」
|
||||||
return .failed(reason: "相册写入权限被拒")
|
2️⃣ 选择好友或群聊进入聊天
|
||||||
} catch {
|
3️⃣ 在输入框长按粘贴图片
|
||||||
Self.log.error("DouyinShare: 保存相册失败 \(error.localizedDescription, privacy: .public)")
|
4️⃣ 点击发送即可分享
|
||||||
return .failed(reason: "保存相册失败: \(error.localizedDescription)")
|
"""
|
||||||
|
showGuidanceAlert(title: "抖音分享准备完成", message: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 同时放剪贴板兜底(msext 行为:抖音从剪贴板读图)
|
// MARK: - 文字分享
|
||||||
UIPasteboard.general.image = image
|
|
||||||
|
|
||||||
// 4. 按 msext 顺序尝试多个 URL Scheme
|
/// 对齐 msext DouyinShareManager.m:163-196 else 分支:
|
||||||
let schemes = [
|
/// title + "\n\n" + desc 拼字符串 → pasteboard.string → 弹引导框。
|
||||||
"snssdk1128://camera", // 抖音拍摄界面
|
/// 双侧都空时用 msext 同款兜底文案"来自进贤聚友棋牌的精彩内容分享"。
|
||||||
"snssdk1128://publish", // 发布界面
|
private func shareText(_ content: ShareContent) {
|
||||||
"snssdk1128://share/image", // 图片分享(可能已废弃但保留)
|
var shareText = ""
|
||||||
"snssdk1128://" // 兜底基础 scheme
|
if !content.title.isEmpty {
|
||||||
]
|
shareText = content.title
|
||||||
|
}
|
||||||
|
if !content.desc.isEmpty {
|
||||||
|
shareText = shareText.isEmpty ? content.desc : "\(shareText)\n\n\(content.desc)"
|
||||||
|
}
|
||||||
|
if shareText.isEmpty {
|
||||||
|
shareText = "来自进贤聚友棋牌的精彩内容分享"
|
||||||
|
}
|
||||||
|
UIPasteboard.general.string = shareText
|
||||||
|
Self.log.debug("DouyinShare 文字已复制到剪贴板")
|
||||||
|
|
||||||
for schemeString in schemes {
|
let message = """
|
||||||
guard let url = URL(string: schemeString),
|
✅ 内容已复制到剪贴板
|
||||||
UIApplication.shared.canOpenURL(url)
|
|
||||||
else { continue }
|
🎯 分享到好友或群聊步骤:
|
||||||
Self.log.debug("DouyinShare open: \(schemeString, privacy: .public)")
|
1️⃣ 打开抖音,点击右下角「消息」
|
||||||
let opened = await UIApplication.shared.open(url)
|
2️⃣ 选择好友或群聊进入聊天
|
||||||
if opened { return .success }
|
3️⃣ 在输入框长按粘贴内容
|
||||||
|
4️⃣ 点击发送即可分享
|
||||||
|
"""
|
||||||
|
showGuidanceAlert(title: "抖音分享准备完成", message: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .failed(reason: "所有抖音 URL Scheme 都调起失败")
|
// MARK: - 引导弹窗
|
||||||
|
|
||||||
|
/// 弹"立即打开抖音 / 稍后分享"二选一 Alert,对齐 msext
|
||||||
|
/// `showGuidanceAlertWithTitle:message:`。
|
||||||
|
private func showGuidanceAlert(title: String, message: String) {
|
||||||
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "立即打开抖音", style: .default) { _ in
|
||||||
|
if let url = URL(string: Self.douyinScheme),
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,18 @@
|
|||||||
// QQShare.swift
|
// QQShare.swift
|
||||||
// ylgamehall
|
// ylgamehall
|
||||||
//
|
//
|
||||||
// QQ 分享 SharePlatform 实现(Phase 4.C 完整)。
|
// QQ 分享 SharePlatform 实现,对齐 daoqi msext QQShareManager
|
||||||
|
// `shareWithContent:completion:`(按 type 分两个分支):
|
||||||
|
// - type == "2" → 截图分享:复制截图到 UIPasteboard + 拉起 mqqapi://
|
||||||
|
// - else → 链接分享:mqqapi://share/to_fri?req_type=1&url=...
|
||||||
//
|
//
|
||||||
// 移植 msext QQShareManager.m:716-771 simpleShareToQQFriend 的 URL Scheme
|
// **不依赖 QQ SDK**(CLAUDE.md ADR-006):原项目走 SDK 弹原生会话列表,
|
||||||
// fallback。**不依赖 QQ SDK**。
|
// 新外壳走 URL Scheme + pasteboard 兜底实现等价 UX。
|
||||||
//
|
|
||||||
// URL 构造:
|
|
||||||
// mqqapi://share/to_fri?version=1&cflag=0&req_type=1
|
|
||||||
// &url=<encoded webpageUrl>&title=<encoded title>&description=<encoded desc>
|
|
||||||
//
|
//
|
||||||
// encodeString 与 msext line 1262-1268 等价:unreserved 字符集(RFC 3986
|
// encodeString 与 msext line 1262-1268 等价:unreserved 字符集(RFC 3986
|
||||||
// alphanumeric + "-._~"),其它全部 percent encode。
|
// alphanumeric + "-._~"),其它全部 percent encode。
|
||||||
//
|
//
|
||||||
// ⚠️ 当前仅支持链接分享(type == "1")。type == "2" 截图 / 其它 远端图
|
// fire-and-forget:sharesuccess 已在 FriendsShareHandler 立即触发。
|
||||||
// 在 Phase 4.F 接入(依赖 UIGraphicsImageRenderer + Photos / 临时文件 +
|
|
||||||
// mqqapi://share/to_fri?file_type=img 路径)。
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -38,28 +35,57 @@ public final class QQShare: SharePlatform {
|
|||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult {
|
public func share(_ content: ShareContent, scene: ShareScene) {
|
||||||
guard isInstalled else { return .notInstalled(platform: name) }
|
guard isInstalled else { return }
|
||||||
|
|
||||||
// 仅链接分享走完整 URL Scheme;图片分享留 Phase 4.F
|
if content.type == "2" {
|
||||||
guard content.type == "1" else {
|
shareScreenshot()
|
||||||
Self.log.warning("QQShare: type=\(content.type, privacy: .public) (非链接) 暂未实现,返回 success 避免 H5 业务卡住")
|
} else {
|
||||||
return .success
|
shareLink(content, scene: scene)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拼装 URL:mqqapi://share/to_fri? 或 mqqapi://share/to_qzone?
|
// MARK: - 截图分享
|
||||||
// scene == .timeline 走 QQ 空间 to_qzone;.friend 走 to_fri(QQ 好友)
|
|
||||||
let host = scene == .timeline ? "share/to_qzone" : "share/to_fri"
|
/// 对齐 msext QQShareManager.m:1876-1906 isScreenshotShare 分支:
|
||||||
var parts: [String] = [
|
/// 原项目用 QQ SDK shareToQQFriend QQShareTypeImage 直接传 UIImage 给 SDK;
|
||||||
"version=1",
|
/// 无 SDK 路径下退化为:截图 → pasteboard → 拉起 QQ(用户在聊天里粘贴)。
|
||||||
"cflag=0",
|
private func shareScreenshot() {
|
||||||
"req_type=1"
|
do {
|
||||||
|
let shot = try ImageProvider.captureScreenshot()
|
||||||
|
UIPasteboard.general.image = shot
|
||||||
|
} catch {
|
||||||
|
Self.log.error("QQShare 截图失败 \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = [
|
||||||
|
"mqqapi://share/to_fri?file_type=news&version=1&cflag=0",
|
||||||
|
"mqq://im/chat?chat_type=select",
|
||||||
|
"mqqapi://",
|
||||||
|
"mqq://"
|
||||||
]
|
]
|
||||||
|
for s in candidates {
|
||||||
|
guard let url = URL(string: s),
|
||||||
|
UIApplication.shared.canOpenURL(url) else { continue }
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if !content.webpageUrl.isEmpty {
|
||||||
|
parts.append("req_type=1")
|
||||||
parts.append("url=\(Self.encode(content.webpageUrl))")
|
parts.append("url=\(Self.encode(content.webpageUrl))")
|
||||||
} else {
|
} else {
|
||||||
// 无 URL → 改 req_type=0 文本分享
|
parts.append("req_type=0")
|
||||||
parts = ["version=1", "cflag=0", "req_type=0"]
|
|
||||||
}
|
}
|
||||||
if !content.title.isEmpty {
|
if !content.title.isEmpty {
|
||||||
parts.append("title=\(Self.encode(content.title))")
|
parts.append("title=\(Self.encode(content.title))")
|
||||||
@@ -69,21 +95,11 @@ public final class QQShare: SharePlatform {
|
|||||||
}
|
}
|
||||||
let urlString = "mqqapi://\(host)?\(parts.joined(separator: "&"))"
|
let urlString = "mqqapi://\(host)?\(parts.joined(separator: "&"))"
|
||||||
|
|
||||||
Self.log.debug("QQShare open: \(urlString, privacy: .public)")
|
|
||||||
|
|
||||||
guard let qqURL = URL(string: urlString),
|
guard let qqURL = URL(string: urlString),
|
||||||
UIApplication.shared.canOpenURL(qqURL)
|
UIApplication.shared.canOpenURL(qqURL) else { return }
|
||||||
else {
|
UIApplication.shared.open(qqURL)
|
||||||
return .failed(reason: "invalid QQ URL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL Scheme 单向调起:msext 同款乐观策略 — 调起即视为 success
|
|
||||||
let opened = await UIApplication.shared.open(qqURL)
|
|
||||||
return opened ? .success : .failed(reason: "openURL failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 与 msext QQShareManager.m:1262-1268 等价:仅保留 RFC 3986 unreserved
|
|
||||||
/// 字符(alphanumeric + "-._~"),其它全部 percent encode。
|
|
||||||
nonisolated static func encode(_ string: String) -> String {
|
nonisolated static func encode(_ string: String) -> String {
|
||||||
var allowed = CharacterSet.alphanumerics
|
var allowed = CharacterSet.alphanumerics
|
||||||
allowed.insert(charactersIn: "-._~")
|
allowed.insert(charactersIn: "-._~")
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
// - sharefriend == "1"(好友/私聊)→ 弹 SharePanel 三选一(微信/QQ/抖音)
|
// - sharefriend == "1"(好友/私聊)→ 弹 SharePanel 三选一(微信/QQ/抖音)
|
||||||
// - sharefriend == "2"(朋友圈) → 直接 WechatShare(朋友圈 timeline 场景)
|
// - sharefriend == "2"(朋友圈) → 直接 WechatShare(朋友圈 timeline 场景)
|
||||||
//
|
//
|
||||||
// 与 Design §8.2 ShareKit 一致:策略模式 + SharePlatform 协议。
|
// fire-and-forget:H5 端 sharesuccess 已在 FriendsShareHandler 立即触发,
|
||||||
|
// 这里只负责拉起 UI / SDK,不需要回传结果。
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -14,27 +15,19 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
public enum ShareCenter {
|
public enum ShareCenter {
|
||||||
|
|
||||||
/// 统一分发入口。由 FriendsShareHandler 在 handler 闭包内调用。
|
public static func dispatch(_ content: ShareContent) {
|
||||||
/// completion 在分享结果回来后触发;UI 期间 await 暂停闭包但 cb 已先返回(msext 行为)。
|
|
||||||
public static func dispatch(_ content: ShareContent, completion: @escaping (ShareResult) -> Void) {
|
|
||||||
switch content.sharefriend {
|
switch content.sharefriend {
|
||||||
case "1":
|
case "1":
|
||||||
// 好友/私聊 → SharePanel 三选一
|
// 好友/私聊 → SharePanel 三选一
|
||||||
SharePanel.show(content: content, completion: completion)
|
SharePanel.show(content: content)
|
||||||
|
|
||||||
case "2":
|
case "2":
|
||||||
// 朋友圈 → 直接 WechatShare(timeline 场景)
|
// 朋友圈 → 直接 WechatShare(timeline 场景)
|
||||||
Task { @MainActor in
|
WechatShare.shared.share(content, scene: .timeline)
|
||||||
let result = await WechatShare.shared.share(content, scene: .timeline)
|
|
||||||
completion(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 未知 sharefriend:msext 沿用第二条分支兜底(朋友圈微信)
|
// 未知 sharefriend:msext 沿用第二条分支兜底(朋友圈微信)
|
||||||
Task { @MainActor in
|
WechatShare.shared.share(content, scene: .timeline)
|
||||||
let result = await WechatShare.shared.share(content, scene: .timeline)
|
|
||||||
completion(result)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,11 @@ public final class SharePanel: UIView {
|
|||||||
private static let animationDuration: TimeInterval = 0.25
|
private static let animationDuration: TimeInterval = 0.25
|
||||||
|
|
||||||
private let content: ShareContent
|
private let content: ShareContent
|
||||||
private let completion: (ShareResult) -> Void
|
|
||||||
|
|
||||||
private let contentView = UIView()
|
private let contentView = UIView()
|
||||||
|
|
||||||
public init(content: ShareContent, completion: @escaping (ShareResult) -> Void) {
|
public init(content: ShareContent) {
|
||||||
self.content = content
|
self.content = content
|
||||||
self.completion = completion
|
|
||||||
super.init(frame: UIScreen.main.bounds)
|
super.init(frame: UIScreen.main.bounds)
|
||||||
backgroundColor = UIColor(white: 0, alpha: 0)
|
backgroundColor = UIColor(white: 0, alpha: 0)
|
||||||
setupUI()
|
setupUI()
|
||||||
@@ -46,13 +44,11 @@ public final class SharePanel: UIView {
|
|||||||
|
|
||||||
// MARK: - 公共入口
|
// MARK: - 公共入口
|
||||||
|
|
||||||
/// 弹出分享面板。任何时候同一个面板只允许一个实例。
|
/// 弹出分享面板。fire-and-forget:sharesuccess 已在 FriendsShareHandler 立即触发,
|
||||||
public static func show(content: ShareContent, completion: @escaping (ShareResult) -> Void) {
|
/// 用户取消(点空白)或选好平台后不再向 H5 回报结果。
|
||||||
guard let window = topWindow() else {
|
public static func show(content: ShareContent) {
|
||||||
completion(.failed(reason: "no key window"))
|
guard let window = topWindow() else { return }
|
||||||
return
|
let panel = SharePanel(content: content)
|
||||||
}
|
|
||||||
let panel = SharePanel(content: content, completion: completion)
|
|
||||||
window.addSubview(panel)
|
window.addSubview(panel)
|
||||||
panel.animateIn()
|
panel.animateIn()
|
||||||
}
|
}
|
||||||
@@ -146,7 +142,7 @@ public final class SharePanel: UIView {
|
|||||||
@objc private func handleBackgroundTap(_ gesture: UITapGestureRecognizer) {
|
@objc private func handleBackgroundTap(_ gesture: UITapGestureRecognizer) {
|
||||||
let point = gesture.location(in: self)
|
let point = gesture.location(in: self)
|
||||||
if !contentView.frame.contains(point) {
|
if !contentView.frame.contains(point) {
|
||||||
dismiss { [self] in completion(.cancelled) }
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +151,10 @@ public final class SharePanel: UIView {
|
|||||||
let platform = platforms[sender.tag]
|
let platform = platforms[sender.tag]
|
||||||
let scene: ShareScene = content.sharefriend == "2" ? .timeline : .friend
|
let scene: ShareScene = content.sharefriend == "2" ? .timeline : .friend
|
||||||
let snapshotContent = content
|
let snapshotContent = content
|
||||||
dismiss {
|
dismiss { [weak platform] in
|
||||||
Task { @MainActor in
|
guard let platform else { return }
|
||||||
let result = await platform.share(snapshotContent, scene: scene)
|
Self.log.debug("share via \(platform.name, privacy: .public)")
|
||||||
Self.log.debug("share via \(platform.name, privacy: .public) result=\(String(describing: result), privacy: .public)")
|
platform.share(snapshotContent, scene: scene)
|
||||||
self.completion(result)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
// SharePlatform.swift
|
// SharePlatform.swift
|
||||||
// ylgamehall
|
// ylgamehall
|
||||||
//
|
//
|
||||||
// 分享平台抽象 + ShareContent 数据载荷 + ShareResult。
|
// 分享平台抽象 + ShareContent 数据载荷。
|
||||||
//
|
//
|
||||||
// 策略模式:微信 / QQ / 抖音 各自实现 SharePlatform 协议,由 ShareCenter
|
// 策略模式:微信 / QQ / 抖音 各自实现 SharePlatform 协议,由 ShareCenter
|
||||||
// 按 sharefriend / 用户选择分发。新增平台只加一个 conformance。
|
// 按 sharefriend / 用户选择分发。
|
||||||
|
//
|
||||||
|
// **fire-and-forget 语义**:share 调用即认为分享流程已发起;H5 端的
|
||||||
|
// sharesuccess 在 FriendsShareHandler 解析参数后立即触发(与原 msext
|
||||||
|
// gameController.m 同款"乐观策略",由用户明示有意为之)。各平台 share()
|
||||||
|
// 只负责拉起对应 UI / URL Scheme,不再回传结果。
|
||||||
//
|
//
|
||||||
// 详见 docs/H5-Native-Implementation-Design.md §8.2 ShareKit。
|
// 详见 docs/H5-Native-Implementation-Design.md §8.2 ShareKit。
|
||||||
//
|
//
|
||||||
@@ -22,9 +27,9 @@ nonisolated public struct ShareContent: Sendable {
|
|||||||
/// msext 旧描述:1 微信 / 2 微信 / 3 闲聊。新代码读了但不用(SharePanel 由用户选平台决定)。
|
/// msext 旧描述:1 微信 / 2 微信 / 3 闲聊。新代码读了但不用(SharePanel 由用户选平台决定)。
|
||||||
/// 保留字段以维持契约 §3.1 [2]兼容性
|
/// 保留字段以维持契约 §3.1 [2]兼容性
|
||||||
public let sharetype: String
|
public let sharetype: String
|
||||||
/// 1 链接 / 2 截图 / 其他=远端图 URL 分享
|
/// 1 链接 / 2 截图 / 其他 = 按链接处理(原 WechatShareManager 行为,无独立远端图分支)
|
||||||
public let type: String
|
public let type: String
|
||||||
/// 链接 URL 或图片 URL
|
/// 链接 URL
|
||||||
public let webpageUrl: String
|
public let webpageUrl: String
|
||||||
public let title: String
|
public let title: String
|
||||||
public let desc: String
|
public let desc: String
|
||||||
@@ -46,13 +51,6 @@ nonisolated public struct ShareContent: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ShareResult: Sendable {
|
|
||||||
case success
|
|
||||||
case cancelled
|
|
||||||
case notInstalled(platform: String)
|
|
||||||
case failed(reason: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 分享场景:好友 / 朋友圈(仅微信有意义;QQ / 抖音 暂不区分)。
|
/// 分享场景:好友 / 朋友圈(仅微信有意义;QQ / 抖音 暂不区分)。
|
||||||
public enum ShareScene: Sendable {
|
public enum ShareScene: Sendable {
|
||||||
case friend // 微信好友 / QQ 好友
|
case friend // 微信好友 / QQ 好友
|
||||||
@@ -61,10 +59,10 @@ public enum ShareScene: Sendable {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public protocol SharePlatform: AnyObject {
|
public protocol SharePlatform: AnyObject {
|
||||||
/// 平台唯一标识(用于 log / 错误提示)
|
/// 平台唯一标识(用于 log)
|
||||||
var name: String { get }
|
var name: String { get }
|
||||||
/// 平台 app 是否已安装(用于 SharePanel 按钮置灰)
|
/// 平台 app 是否已安装(用于 SharePanel 按钮灰显)
|
||||||
var isInstalled: Bool { get }
|
var isInstalled: Bool { get }
|
||||||
/// 执行分享(按 content.type 路由到链接 / 截图 / 远端图)
|
/// 拉起分享 UI;fire-and-forget,不回传结果(sharesuccess 已在 handler 层先发)
|
||||||
func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult
|
func share(_ content: ShareContent, scene: ShareScene)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,20 @@
|
|||||||
// WechatShare.swift
|
// WechatShare.swift
|
||||||
// ylgamehall
|
// ylgamehall
|
||||||
//
|
//
|
||||||
// 微信分享 SharePlatform 实现。
|
// 微信分享 SharePlatform 实现,1:1 对齐 daoqi msext WechatShareManager
|
||||||
|
// `shareWithContent:completion:`(仅两个分支):
|
||||||
|
// - type == "2" → 截图分享(getImageWithFullScreenshot + JPEG 0.6 + sendImageData)
|
||||||
|
// - else → 链接分享(sendLinkURL + webpageUrl + title + desc)
|
||||||
//
|
//
|
||||||
// ⚠️ canImport(WechatOpenSDK) 守卫:SDK 未链接前 isInstalled = true / share = .success
|
// 缩略图:app icon(与原 `[self getAppIconImage]` 同源策略,不用 sharelogo)。
|
||||||
// 让 SharePanel 按钮可点;项目方在 Xcode Embed & Sign 后真实路径激活。
|
//
|
||||||
|
// fire-and-forget:H5 端的 sharesuccess 已在 FriendsShareHandler 立即触发,
|
||||||
|
// 此处不再回传结果(与原 dispatch_after 1s completion(YES) 实质等价)。
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// 微信 SDK 1.8.2 通过 Bridging Header 导入,无 import 语句
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class WechatShare: SharePlatform {
|
public final class WechatShare: SharePlatform {
|
||||||
|
|
||||||
@@ -26,15 +29,24 @@ public final class WechatShare: SharePlatform {
|
|||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult {
|
public func share(_ content: ShareContent, scene: ShareScene) {
|
||||||
guard WXApi.isWXAppInstalled() else {
|
guard WXApi.isWXAppInstalled() else { return }
|
||||||
return .notInstalled(platform: name)
|
|
||||||
}
|
|
||||||
let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session
|
let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session
|
||||||
|
let thumb = Self.appIconImage()
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
switch content.type {
|
if content.type == "2" {
|
||||||
case "1":
|
// 截图分享(msext WechatShareManager isScreenshotShare 分支)
|
||||||
// 链接分享(msext gameController.m:613-635)
|
let shot = try ImageProvider.captureScreenshot()
|
||||||
|
guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return }
|
||||||
|
try await WeChatManager.shared.shareImage(
|
||||||
|
.init(imageData: imageData, thumb: thumb),
|
||||||
|
scene: wxScene
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 链接分享(msext else 分支,含 type=="1" 与任何其它值)
|
||||||
try await WeChatManager.shared.shareLink(
|
try await WeChatManager.shared.shareLink(
|
||||||
.init(
|
.init(
|
||||||
url: content.webpageUrl,
|
url: content.webpageUrl,
|
||||||
@@ -44,37 +56,26 @@ public final class WechatShare: SharePlatform {
|
|||||||
),
|
),
|
||||||
scene: wxScene
|
scene: wxScene
|
||||||
)
|
)
|
||||||
case "2":
|
|
||||||
// 截图分享(msext gameController.m:637-658)
|
|
||||||
let shot = try ImageProvider.captureScreenshot()
|
|
||||||
guard let imageData = shot.jpegData(compressionQuality: 0.6) else {
|
|
||||||
return .failed(reason: "screenshot JPEG encode failed")
|
|
||||||
}
|
}
|
||||||
let thumb = ImageProvider.scaledThumb(shot)
|
|
||||||
try await WeChatManager.shared.shareImage(
|
|
||||||
.init(imageData: imageData, thumb: thumb),
|
|
||||||
scene: wxScene
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
// 远端图分享(msext gameController.m:659-680:type 是其它值就当 URL 远端图)
|
|
||||||
let remote = try await ImageProvider.downloadRemote(content.webpageUrl)
|
|
||||||
// msext 直接用原始 data 作为大图;这里 UIImage 已是 decode 后的,
|
|
||||||
// 用 jpegData 重新编码维持等价(避免持有未压缩的原始字节膨胀)。
|
|
||||||
guard let imageData = remote.jpegData(compressionQuality: 0.9) else {
|
|
||||||
return .failed(reason: "remote image JPEG encode failed")
|
|
||||||
}
|
|
||||||
let thumb = ImageProvider.scaledThumb(remote)
|
|
||||||
try await WeChatManager.shared.shareImage(
|
|
||||||
.init(imageData: imageData, thumb: thumb),
|
|
||||||
scene: wxScene
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return .success
|
|
||||||
} catch let WeChatError.shareFailed(errCode) {
|
|
||||||
if errCode == -2 { return .cancelled }
|
|
||||||
return .failed(reason: "WeChat errCode \(errCode)")
|
|
||||||
} catch {
|
} catch {
|
||||||
return .failed(reason: "\(error)")
|
// SDK 回包失败不向 H5 反馈(sharesuccess 已乐观发出,对齐原项目)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// app icon 缩略图。读 Info.plist `CFBundleIcons` → `CFBundlePrimaryIcon`
|
||||||
|
/// → `CFBundleIconFiles` 的最后一个(最高分辨率),与原 msext
|
||||||
|
/// WechatShareManager `getAppIconImage` 等价。失败兜底返回 1x1 透明图。
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user