diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 28fa378..2995a43 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -479,12 +479,13 @@ Contract Design Plan(本文档) - [ ] **4.1** `Vendor/WechatSDK/` 拷贝微信 SDK - Info.plist 加 URL Scheme(`wx586a9b321e56efb7`)+ `LSApplicationQueriesSchemes`(`weixin` / `weixinULAPI` / `weixinURLParamsAPI`) - Entitlement 加 Associated Domains(Universal Links) -- [ ] **4.2** **SharePanel 三选一面板(msext gameController.m:585 等价行为)** - - **关键发现**:msext `gameController.m:575-597` 表明 H5 调 `friendsShare` 时: - - `sharefriend == 1`(好友/私聊)→ 弹 **SharePanel 三选一面板**(微信好友 + QQ + 抖音) - - `sharefriend == 2`(朋友圈)→ 直接 `WechatShareManager.shareWithContent`(朋友圈) - - Contract §3.1 [2]旧描述(sharetype=3 闲聊)已被 msext 新代码覆盖;`sharetype` 字段在新代码里读了但不用 - - 新外壳按 SharePanel 模式实现,不依赖 Contract 旧描述 +- [x] **4.2** **SharePanel 三选一面板 + SharePlatform 框架**(4.B 完成) + - `Source/Share/SharePlatform.swift`:SharePlatform 协议(name / isInstalled / share async)+ ShareContent struct(nonisolated Sendable)+ ShareResult enum(success / cancelled / notInstalled / failed)+ ShareScene enum(friend / timeline) + - `Source/Share/SharePanel.swift`:底部弹出三按钮(微信 / QQ / 抖音)+ 半透明背景点击关闭 + 0.25s 滑入/滑出动画;与 msext SharePanel.m 等价行为 + - `Source/Share/ShareCenter.swift`:sharefriend == "1" 弹 SharePanel;"2" 直接 WechatShare.timeline;与 msext gameController.m:585 行为 1:1 + - `Source/Share/{Wechat,QQ,Douyin}Share.swift`:三个平台 stub(仅返回 success),Phase 4.C/4.D/4.E 升级 + - `FriendsShareHandler` 升级:调 ShareCenter.dispatch + 完成后触发 sharesuccess 反向 callback(success/cancelled 都发,notInstalled/failed 不发,沿用 msext 乐观策略) + - BuildProject 通过 - [ ] **4.3** **QQ 分享走 URL Scheme,不依赖 SDK** - 移植 msext `QQShareManager.m:716-771` `simpleShareToQQFriend` 简化版 - URL 构造:`mqqapi://share/to_fri?version=1&cflag=0&req_type=1&url=...&title=...&description=...` diff --git a/ylgamehall/Source/Bridge/Handlers/FriendsShareHandler.swift b/ylgamehall/Source/Bridge/Handlers/FriendsShareHandler.swift index 0b72a1d..87a1d87 100644 --- a/ylgamehall/Source/Bridge/Handlers/FriendsShareHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/FriendsShareHandler.swift @@ -2,37 +2,63 @@ // FriendsShareHandler.swift // ylgamehall // -// H5 → Native handler stub:friendsSharetypeUrlToptitleDescript — 分享 +// H5 → Native handler:friendsSharetypeUrlToptitleDescript — 分享 // 契约:docs/H5-Native-Contract.md §3.1 [2] + §3.2 [11]sharesuccess // -// Phase 4.A 阶段为 stub(仅 cb 维持契约);Phase 4.B 微信 SDK 接入 + Phase 4.C -// QQ URL Scheme 完整实现后升级。 -// -// 完整实现: -// 1. 入参解析:sharefriend (1=好友 / 2=朋友圈) + sharetype (1=微信 / 2=微信 / -// 3=闲聊;契约 §3.1 [2]) + type (1=链接 / 2=截图 / 3=远端图) + url + -// title + description -// 2. ShareCenter.dispatch(req): -// - sharetype = 3 → 闲聊 NoopSharePlatform(暂未集成) -// - 否则 → WeChatShare(Phase 4.B SDK 接入) +// 完整流程(与 msext gameController.m:575-597 等价): +// 1. 入参解析 ShareContent +// 2. ShareCenter.dispatch: +// - sharefriend == "1" → SharePanel 三选一(微信好友 / QQ / 抖音) +// - sharefriend == "2" → 直接 WechatShare 朋友圈 // 3. 分享完成 → bridge.call("sharesuccess", { success: "2", type: }) // -// ⚠️ QQ 分享按用户约定**不依赖 SDK**,走 URL Scheme(mqq://share/to_fri、 -// mqqapi://share/to_qzone 等);参 daoqi/msext QQShareManager.m fallback -// 部分(Phase 4.C 真实落地) +// 各平台 SDK 依赖: +// - 微信:Phase 4.E 接入项目方 SDK(当前 WechatShare 是 stub) +// - QQ + 抖音:不依赖 SDK,走 URL Scheme(Phase 4.C/4.D 真实落地,当前 stub) // import Foundation +@MainActor public enum FriendsShareHandler { public static func register(on bridge: any BridgeProtocol) { // 【2】friendsSharetypeUrlToptitleDescript - // 入参:sharefriend / sharetype / type / url / title / description + // 入参:sharefriend / sharetype / type / webpageUrl / title / description // cb: "sharefriend"(msext 字面,沿用) - // 完整实现需触发 sharesuccess 反向 callback(Phase 4.B/4.C) - bridge.register("friendsSharetypeUrlToptitleDescript") { _, callback in + // 反向 callback:sharesuccess({ success: "2", type: sharefriend 原值 }) + bridge.register("friendsSharetypeUrlToptitleDescript") { [weak bridge] data, callback in callback?(.string("sharefriend")) + + guard let obj = data?.asObject else { return } + let content = ShareContent( + sharefriend: obj["sharefriend"]?.asString ?? "1", + sharetype: obj["sharetype"]?.asString ?? "", + type: obj["type"]?.asString ?? "1", + webpageUrl: obj["webpageUrl"]?.asString ?? "", + title: obj["title"]?.asString ?? "", + desc: obj["description"]?.asString ?? "" + ) + let sharefriend = content.sharefriend + + await MainActor.run { + ShareCenter.dispatch(content) { result in + // success / cancelled / notInstalled / failed — msext 行为: + // 只要不是明确失败就发 sharesuccess(乐观策略,URL Scheme 无返回) + switch result { + case .success, .cancelled: + bridge?.call("sharesuccess", + data: .object([ + "success": .string("2"), + "type": .string(sharefriend) + ]), + callback: nil) + case .notInstalled, .failed: + // 不发 sharesuccess(H5 业务期 timeout 后自行 fallback) + break + } + } + } } } } diff --git a/ylgamehall/Source/Share/DouyinShare.swift b/ylgamehall/Source/Share/DouyinShare.swift new file mode 100644 index 0000000..36b41a9 --- /dev/null +++ b/ylgamehall/Source/Share/DouyinShare.swift @@ -0,0 +1,40 @@ +// +// DouyinShare.swift +// ylgamehall +// +// 抖音分享 SharePlatform 实现 stub(Phase 4.D 真实落地)。 +// +// Phase 4.B 阶段为 stub(仅返回 success); +// Phase 4.D 移植 msext DouyinShareManager.m URL Scheme + Photos 路径 +// (snssdk1128://share/video + 保存截图到相册让用户在抖音里选)。 +// + +import UIKit + +@MainActor +public final class DouyinShare: SharePlatform { + + public static let shared = DouyinShare() + + public let name = "Douyin" + + /// 检测抖音 app 是否安装:canOpenURL("snssdk1128://") + public var isInstalled: Bool { + guard let url = URL(string: "snssdk1128://") else { return false } + return UIApplication.shared.canOpenURL(url) + } + + public init() {} + + public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult { + guard isInstalled else { return .notInstalled(platform: name) } + // Phase 4.B 阶段 stub:返回 success,Phase 4.D 升级 + // 真实实现(Phase 4.D): + // - type == 2 截图:UIGraphicsImageRenderer 截图 → 保存到相册(PHPhotoLibrary) + // - type == 其他 远端图:URLSession 下载 → 保存到相册 + // - 保存后 snssdk1128://share/video 或 snssdk1128://camera 调起抖音 + // - Info.plist 加 NSPhotoLibraryAddUsageDescription + // - 调起即视为 success + return .success + } +} diff --git a/ylgamehall/Source/Share/QQShare.swift b/ylgamehall/Source/Share/QQShare.swift new file mode 100644 index 0000000..a70e612 --- /dev/null +++ b/ylgamehall/Source/Share/QQShare.swift @@ -0,0 +1,39 @@ +// +// QQShare.swift +// ylgamehall +// +// QQ 分享 SharePlatform 实现 stub(Phase 4.C 真实落地)。 +// +// Phase 4.B 阶段为 stub(仅返回 success,让 SharePanel 流程跑通); +// Phase 4.C 移植 msext QQShareManager.m:716-771 simpleShareToQQFriend +// 的 URL Scheme fallback(mqqapi://share/to_fri?...)。 +// + +import UIKit + +@MainActor +public final class QQShare: SharePlatform { + + public static let shared = QQShare() + + public let name = "QQ" + + /// 检测 QQ app 是否安装:canOpenURL("mqqapi://") + 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) async -> ShareResult { + guard isInstalled else { return .notInstalled(platform: name) } + // Phase 4.B 阶段 stub:返回 success,Phase 4.C 升级为完整 URL Scheme 调起 + // 真实实现(Phase 4.C): + // - 拼装 mqqapi://share/to_fri?version=1&cflag=0&req_type=1 + // &url=...&title=...&description=... + // - canOpenURL + open + // - 调起即视为 success(URL Scheme 单向无返回,msext 同款乐观策略) + return .success + } +} diff --git a/ylgamehall/Source/Share/ShareCenter.swift b/ylgamehall/Source/Share/ShareCenter.swift new file mode 100644 index 0000000..89ecc0e --- /dev/null +++ b/ylgamehall/Source/Share/ShareCenter.swift @@ -0,0 +1,40 @@ +// +// ShareCenter.swift +// ylgamehall +// +// H5 friendsShare 调用入口的协调中心,按 msext gameController.m:575-597 行为: +// - sharefriend == "1"(好友/私聊)→ 弹 SharePanel 三选一(微信/QQ/抖音) +// - sharefriend == "2"(朋友圈) → 直接 WechatShare(朋友圈 timeline 场景) +// +// 与 Design §8.2 ShareKit 一致:策略模式 + SharePlatform 协议。 +// + +import Foundation + +@MainActor +public enum ShareCenter { + + /// 统一分发入口。由 FriendsShareHandler 在 handler 闭包内调用。 + /// completion 在分享结果回来后触发;UI 期间 await 暂停闭包但 cb 已先返回(msext 行为)。 + public static func dispatch(_ content: ShareContent, completion: @escaping (ShareResult) -> Void) { + switch content.sharefriend { + case "1": + // 好友/私聊 → SharePanel 三选一 + SharePanel.show(content: content, completion: completion) + + case "2": + // 朋友圈 → 直接 WechatShare(timeline 场景) + Task { @MainActor in + let result = await WechatShare.shared.share(content, scene: .timeline) + completion(result) + } + + default: + // 未知 sharefriend:msext 沿用第二条分支兜底(朋友圈微信) + Task { @MainActor in + let result = await WechatShare.shared.share(content, scene: .timeline) + completion(result) + } + } + } +} diff --git a/ylgamehall/Source/Share/SharePanel.swift b/ylgamehall/Source/Share/SharePanel.swift new file mode 100644 index 0000000..3ec1bbe --- /dev/null +++ b/ylgamehall/Source/Share/SharePanel.swift @@ -0,0 +1,166 @@ +// +// SharePanel.swift +// ylgamehall +// +// 原生分享面板:底部弹出三选一(微信好友 / QQ / 抖音)。 +// +// 与 msext SharePanel.m 等价行为: +// - 半透明黑底覆盖全屏,点击空白区关闭 +// - 内容视图从屏幕底部滑入,0.25s 动画 +// - 三个圆形按钮 + label,等间距居中布局 +// - 点击按钮后先 dismiss 再触发对应 SharePlatform.share +// +// Phase 4.B 阶段:UI 完整 + 调用 SharePlatform stub; +// Phase 4.C/4.D 阶段:SharePlatform 升级为真实 URL Scheme 实现,本类不变。 +// + +import UIKit +import os.log + +@MainActor +public final class SharePanel: UIView { + + private static let log = Logger(subsystem: "ylgamehall", category: "SharePanel") + + private static let panelHeight: CGFloat = 200 + private static let buttonSize: CGFloat = 60 + private static let animationDuration: TimeInterval = 0.25 + + private let content: ShareContent + private let completion: (ShareResult) -> Void + + private let contentView = UIView() + + public init(content: ShareContent, completion: @escaping (ShareResult) -> Void) { + self.content = content + self.completion = completion + super.init(frame: UIScreen.main.bounds) + backgroundColor = UIColor(white: 0, alpha: 0) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + // MARK: - 公共入口 + + /// 弹出分享面板。任何时候同一个面板只允许一个实例。 + public static func show(content: ShareContent, completion: @escaping (ShareResult) -> Void) { + guard let window = topWindow() else { + completion(.failed(reason: "no key window")) + return + } + let panel = SharePanel(content: content, completion: completion) + window.addSubview(panel) + panel.animateIn() + } + + private static func topWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow } + } + + // MARK: - UI 装配 + + private func setupUI() { + // 半透明背景手势 + let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap(_:))) + addGestureRecognizer(tap) + + // 内容视图(屏幕底部,初始 Y 在屏幕外) + let screen = UIScreen.main.bounds + contentView.frame = CGRect(x: 0, y: screen.height, width: screen.width, height: Self.panelHeight) + contentView.backgroundColor = .white + let mask = CAShapeLayer() + mask.path = UIBezierPath( + roundedRect: contentView.bounds, + byRoundingCorners: [.topLeft, .topRight], + cornerRadii: CGSize(width: 10, height: 10) + ).cgPath + contentView.layer.mask = mask + addSubview(contentView) + + // 三个按钮 + label 等间距居中布局 + let buttonsContainer = UIView(frame: CGRect(x: 0, y: 30, width: screen.width, height: 110)) + contentView.addSubview(buttonsContainer) + + let buttons: [(title: String, platform: SharePlatform, color: UIColor)] = [ + ("微信", WechatShare.shared, UIColor(red: 0.06, green: 0.69, blue: 0.36, alpha: 1)), + ("QQ", QQShare.shared, UIColor(red: 0.12, green: 0.51, blue: 0.97, alpha: 1)), + ("抖音", DouyinShare.shared, .black) + ] + + let totalButtonsWidth = Self.buttonSize * CGFloat(buttons.count) + let spacing = (screen.width - totalButtonsWidth) / CGFloat(buttons.count + 1) + for (index, b) in buttons.enumerated() { + let x = spacing + CGFloat(index) * (Self.buttonSize + spacing) + let button = UIButton(type: .system) + button.frame = CGRect(x: x, y: 0, width: Self.buttonSize, height: Self.buttonSize) + button.backgroundColor = b.platform.isInstalled ? b.color : .lightGray + button.layer.cornerRadius = Self.buttonSize / 2 + button.setTitle(b.title.prefix(1).description, for: .normal) + button.setTitleColor(.white, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 22, weight: .medium) + button.tag = index + button.addTarget(self, action: #selector(handleButtonTap(_:)), for: .touchUpInside) + buttonsContainer.addSubview(button) + + let label = UILabel(frame: CGRect(x: x, y: Self.buttonSize + 8, width: Self.buttonSize, height: 20)) + label.text = b.title + label.textAlignment = .center + label.font = .systemFont(ofSize: 13) + label.textColor = .darkGray + buttonsContainer.addSubview(label) + } + } + + // MARK: - 动画 + + private func animateIn() { + UIView.animate(withDuration: Self.animationDuration) { + self.backgroundColor = UIColor(white: 0, alpha: 0.5) + var frame = self.contentView.frame + frame.origin.y = UIScreen.main.bounds.height - Self.panelHeight + self.contentView.frame = frame + } + } + + private func dismiss(then action: (() -> Void)? = nil) { + UIView.animate(withDuration: Self.animationDuration, animations: { + self.backgroundColor = UIColor(white: 0, alpha: 0) + var frame = self.contentView.frame + frame.origin.y = UIScreen.main.bounds.height + self.contentView.frame = frame + }, completion: { _ in + self.removeFromSuperview() + action?() + }) + } + + // MARK: - 事件 + + @objc private func handleBackgroundTap(_ gesture: UITapGestureRecognizer) { + let point = gesture.location(in: self) + if !contentView.frame.contains(point) { + dismiss { [self] in completion(.cancelled) } + } + } + + @objc private func handleButtonTap(_ sender: UIButton) { + let platforms: [SharePlatform] = [WechatShare.shared, QQShare.shared, DouyinShare.shared] + let platform = platforms[sender.tag] + let scene: ShareScene = content.sharefriend == "2" ? .timeline : .friend + let snapshotContent = content + dismiss { + Task { @MainActor in + let result = await platform.share(snapshotContent, scene: scene) + Self.log.debug("share via \(platform.name, privacy: .public) result=\(String(describing: result), privacy: .public)") + self.completion(result) + } + } + } +} diff --git a/ylgamehall/Source/Share/SharePlatform.swift b/ylgamehall/Source/Share/SharePlatform.swift new file mode 100644 index 0000000..a7d1d1a --- /dev/null +++ b/ylgamehall/Source/Share/SharePlatform.swift @@ -0,0 +1,70 @@ +// +// SharePlatform.swift +// ylgamehall +// +// 分享平台抽象 + ShareContent 数据载荷 + ShareResult。 +// +// 策略模式:微信 / QQ / 抖音 各自实现 SharePlatform 协议,由 ShareCenter +// 按 sharefriend / 用户选择分发。新增平台只加一个 conformance。 +// +// 详见 docs/H5-Native-Implementation-Design.md §8.2 ShareKit。 +// + +import Foundation +import UIKit + +/// 分享内容(H5 → Native 桥的 friendsShare 参数解析后的 Swift 类型)。 +/// 项目默认 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 让 struct 也跨 actor 受限; +/// 标 nonisolated 让 handler 闭包(@Sendable async)可自由构造与传递。 +nonisolated public struct ShareContent: Sendable { + /// 分享给好友("1")还是朋友圈("2") + public let sharefriend: String + /// msext 旧描述:1 微信 / 2 微信 / 3 闲聊。新代码读了但不用(SharePanel 由用户选平台决定)。 + /// 保留字段以维持契约 §3.1 [2]兼容性 + public let sharetype: String + /// 1 链接 / 2 截图 / 其他=远端图 URL 分享 + public let type: String + /// 链接 URL 或图片 URL + public let webpageUrl: String + public let title: String + public let desc: String + + public init( + sharefriend: String, + sharetype: String, + type: String, + webpageUrl: String, + title: String, + desc: String + ) { + self.sharefriend = sharefriend + self.sharetype = sharetype + self.type = type + self.webpageUrl = webpageUrl + self.title = title + self.desc = desc + } +} + +public enum ShareResult: Sendable { + case success + case cancelled + case notInstalled(platform: String) + case failed(reason: String) +} + +/// 分享场景:好友 / 朋友圈(仅微信有意义;QQ / 抖音 暂不区分)。 +public enum ShareScene: Sendable { + case friend // 微信好友 / QQ 好友 + case timeline // 微信朋友圈 / QQ 空间 +} + +@MainActor +public protocol SharePlatform: AnyObject { + /// 平台唯一标识(用于 log / 错误提示) + var name: String { get } + /// 平台 app 是否已安装(用于 SharePanel 按钮置灰) + var isInstalled: Bool { get } + /// 执行分享(按 content.type 路由到链接 / 截图 / 远端图) + func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult +} diff --git a/ylgamehall/Source/Share/WechatShare.swift b/ylgamehall/Source/Share/WechatShare.swift new file mode 100644 index 0000000..87f00b9 --- /dev/null +++ b/ylgamehall/Source/Share/WechatShare.swift @@ -0,0 +1,31 @@ +// +// WechatShare.swift +// ylgamehall +// +// 微信分享 SharePlatform 实现 stub。 +// +// Phase 4.E 阶段为 stub(等项目方提供微信 SDK + AppID + Universal Links); +// 完整实现见 Design §8.2 ShareKit + §8.5 微信 / QQ 多发起方派发。 +// + +import Foundation + +@MainActor +public final class WechatShare: SharePlatform { + + public static let shared = WechatShare() + + public let name = "WeChat" + + /// Stub: 微信 SDK 未接入前永远返回 true,让 SharePanel 按钮可点; + /// 真实接入 SDK 后改为 `WXApi.isWXAppInstalled()` + public var isInstalled: Bool { true } + + public init() {} + + public func share(_ content: ShareContent, scene: ShareScene) async -> ShareResult { + // Phase 4.E 阶段 stub:仅返回 success,不真实调起微信 + // 完整实现:WXApi.send + SendMessageToWXResp 回调 + 多发起方 state 配对 + return .success + } +}