diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 118a1f0..9634ee0 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -347,13 +347,15 @@ Contract Design Plan(本文档) ##### 2.A H5→Native handler(无外部依赖) -- [ ] **2.1** `gameCopytext` / `gamepastetext`(剪贴板) -- [ ] **2.2** `vibrator` / `repeatvibrator` / `canclevibrator`(已有,扩展) -- [ ] **2.3** `startshake` / `stopshake` / `SwitchShake`(摇一摇开关 + 音效开关) -- [ ] **2.4** `voicePlaying`(语音播放总开关) -- [ ] **2.5** `getphoneInfo` → 触发反向 callback `getphoneinfo`(注意小写 i) -- [ ] **2.6** `browser` → 系统 Safari 打开 URL -- [ ] **2.7** `opensaoma` 空实现注册(契约 §3.1【22】要求) +- [x] **2.1** `gameCopytext` / `gamepastetext`(剪贴板)— `Source/Bridge/Handlers/ClipboardHandler.swift`,UIPasteboard.general +- [x] **2.2** `vibrator` / `repeatvibrator` / `canclevibrator`(VibratorHandler.swift 扩展 2 项) +- [x] **2.3** `startshake` / `stopshake` / `SwitchShake`(ShakeHandler.swift,canShake / canVoice 状态 + WebContainer motionEnded 转发 + shakeEnd 反向) +- [x] **2.4** `voicePlaying`(VoicePlayingHandler.swift,actor VoiceCenter.shared.isEnabled 开关位,Phase 3 mediaTypeAudio 读此) +- [x] **2.5** `getphoneInfo` → 触发反向 callback `getphoneinfo`(DeviceInfoHandler.swift,注意大小写:大写 I 入、小写 i 出;DeviceInfoSnapshot 6 字段,IDFA 用 idfv 兜底因为 CLAUDE.md 不接 ATT) +- [x] **2.6** `browser` → 系统 Safari 打开 URL(BrowserHandler.swift,UIApplication.open async) +- [x] **2.7** `opensaoma` 空实现注册(OpenSaomaHandler.swift,契约 §3.1【22】要求) +- [x] **Bonus** `startlocation` stub(StartLocationHandler.swift,让 H5 启动不报 "no handler";Phase 5 接 LocationKit 完整实现) +- [x] **Bridge 重构** `BridgeData.asXxx` 全部加 `nonisolated`,让 handler 闭包(@Sendable async)可以自由访问数据值;契约不变 ##### 2.B Native→H5 反向 callback diff --git a/ylgamehall/Source/Bridge/BridgeProtocol.swift b/ylgamehall/Source/Bridge/BridgeProtocol.swift index 0269955..54f589b 100644 --- a/ylgamehall/Source/Bridge/BridgeProtocol.swift +++ b/ylgamehall/Source/Bridge/BridgeProtocol.swift @@ -98,47 +98,50 @@ extension BridgeData { } // MARK: - 访问语法糖 +// 注:项目默认 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 让所有类型默认 main 隔离; +// BridgeData 是纯值类型、需要在 handler 闭包(@Sendable)内自由访问, +// 所有访问语法糖标 nonisolated 避免跨 actor 限制。 extension BridgeData { - public var asString: String? { + nonisolated public var asString: String? { if case .string(let s) = self { return s } return nil } - public var asDouble: Double? { + nonisolated public var asDouble: Double? { if case .number(let d) = self { return d } return nil } - public var asInt: Int? { + nonisolated public var asInt: Int? { if case .number(let d) = self { return Int(d) } return nil } - public var asBool: Bool? { + nonisolated public var asBool: Bool? { if case .bool(let b) = self { return b } return nil } - public var asArray: [BridgeData]? { + nonisolated public var asArray: [BridgeData]? { if case .array(let a) = self { return a } return nil } - public var asObject: [String: BridgeData]? { + nonisolated public var asObject: [String: BridgeData]? { if case .object(let o) = self { return o } return nil } /// 对象字段访问:`data["title"]?.asString` - public subscript(key: String) -> BridgeData? { + nonisolated public subscript(key: String) -> BridgeData? { if case .object(let o) = self { return o[key] } return nil } /// 数组索引访问:`data[0]?.asString` - public subscript(index: Int) -> BridgeData? { + nonisolated public subscript(index: Int) -> BridgeData? { if case .array(let a) = self, a.indices.contains(index) { return a[index] } return nil } diff --git a/ylgamehall/Source/Bridge/Handlers/BrowserHandler.swift b/ylgamehall/Source/Bridge/Handlers/BrowserHandler.swift new file mode 100644 index 0000000..a047ce1 --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/BrowserHandler.swift @@ -0,0 +1,32 @@ +// +// BrowserHandler.swift +// ylgamehall +// +// H5 → Native handler:browser — 用系统 Safari 打开 URL +// 契约:docs/H5-Native-Contract.md §3.1 [16] +// + +import UIKit + +@MainActor +public enum BrowserHandler { + + public static func register(on bridge: any BridgeProtocol) { + // 【16】browser + // 入参 data: string(URL,需 URL 编码后传给 UIApplication.openURL) + // cb:"browser" + bridge.register("browser") { data, callback in + defer { callback?(.string("browser")) } + guard let urlStr = data?.asString, + let url = URL(string: urlStr) ?? URL(string: urlStr.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") + else { + return + } + // UIApplication.open 在 iOS 15+ 是 async;用 Task 异步打开但不 + // await 返回值(H5 已经 cb 完成,不需要等 open 完成) + Task { @MainActor in + _ = await UIApplication.shared.open(url) + } + } + } +} diff --git a/ylgamehall/Source/Bridge/Handlers/ClipboardHandler.swift b/ylgamehall/Source/Bridge/Handlers/ClipboardHandler.swift new file mode 100644 index 0000000..b94405d --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/ClipboardHandler.swift @@ -0,0 +1,33 @@ +// +// ClipboardHandler.swift +// ylgamehall +// +// H5 → Native handler:gameCopytext / gamepastetext +// 契约:docs/H5-Native-Contract.md §3.1 [13][14] +// 原项目:daoqi/msext NewRootVC.m(散落实现,UIPasteboard 同款) +// + +import UIKit + +@MainActor +public enum ClipboardHandler { + + public static func register(on bridge: any BridgeProtocol) { + // 【13】gamepastetext — 读剪贴板 + // 入参忽略;cb:当前剪贴板文本(不是 "gamepastetext",nil 时回空串) + // 注:iOS 14+ 读取会触发系统"已粘贴自 XX"提示,是平台行为,不算契约违反 + bridge.register("gamepastetext") { _, callback in + let text = UIPasteboard.general.string ?? "" + callback?(.string(text)) + } + + // 【14】gameCopytext — 写剪贴板 + // 入参 data: string;cb:"gameCopytext" + bridge.register("gameCopytext") { data, callback in + if let text = data?.asString { + UIPasteboard.general.string = text + } + callback?(.string("gameCopytext")) + } + } +} diff --git a/ylgamehall/Source/Bridge/Handlers/DeviceInfoHandler.swift b/ylgamehall/Source/Bridge/Handlers/DeviceInfoHandler.swift new file mode 100644 index 0000000..7e720ee --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/DeviceInfoHandler.swift @@ -0,0 +1,69 @@ +// +// DeviceInfoHandler.swift +// ylgamehall +// +// H5 → Native handler:getphoneInfo + 反向 callback getphoneinfo(小写 i) +// 契约:docs/H5-Native-Contract.md §3.1 [21] + §3.2 [1] + 表 A +// +// ⚠️ 大小写硬约束: +// H5 调用:bridge.callHandler("getphoneInfo", ...) 大写 I +// 原生回调:bridge.callHandler("getphoneinfo", ...) 小写 i +// + +import UIKit + +@MainActor +public enum DeviceInfoHandler { + + public static func register(on bridge: any BridgeProtocol) { + // 【21】getphoneInfo(大写 I) + // 入参忽略;cb:"getphoneInfo" + // 反向调用:bridge.call("getphoneinfo"(小写 i!),data: 表 A 6 字段) + bridge.register("getphoneInfo") { [weak bridge] _, callback in + callback?(.string("getphoneInfo")) + + Task { @MainActor in + let snapshot = DeviceInfoSnapshot.current() + bridge?.call("getphoneinfo", // ⚠️ 小写 i + data: .object(snapshot.bridgeFields), + callback: nil) + } + } + } +} + +/// 设备信息快照(契约 §3.2 表 A 6 字段,全部 string)。 +@MainActor +public struct DeviceInfoSnapshot { + public let phoneAdresseMAC: String // UIDevice.identifierForVendor.uuidString + public let phoneDeviceBrand: String // "iPhone" / "iPad" + public let phoneIMEI: String // 历史用 IDFA;新外壳暂用 idfv(IDFA 需 ATT,本项目不接) + public let phoneModel: String // UIDevice.localizedModel + public let phoneProvidersName: String // 运营商名(iOS 16+ 私有 / 已弃用,暂空) + public let phoneVersion: String // UIDevice.systemVersion + + public static func current() -> DeviceInfoSnapshot { + let device = UIDevice.current + let idfv = device.identifierForVendor?.uuidString ?? "" + return DeviceInfoSnapshot( + phoneAdresseMAC: idfv, + phoneDeviceBrand: device.model, + phoneIMEI: idfv, // CLAUDE.md 不接 ATT/IDFA,沿用 idfv + phoneModel: device.localizedModel, + phoneProvidersName: "", // iOS 16+ CTCarrier 已弃用,业务暂用不到 + phoneVersion: device.systemVersion + ) + } + + /// 契约 §3.2 表 A 字段名严格保持 + public var bridgeFields: [String: BridgeData] { + [ + "PhoneAdresseMAC": .string(phoneAdresseMAC), + "PhoneDeviceBrand": .string(phoneDeviceBrand), + "PhoneIMEI": .string(phoneIMEI), + "PhoneModel": .string(phoneModel), + "PhoneProvidersName": .string(phoneProvidersName), + "PhoneVersion": .string(phoneVersion) + ] + } +} diff --git a/ylgamehall/Source/Bridge/Handlers/OpenSaomaHandler.swift b/ylgamehall/Source/Bridge/Handlers/OpenSaomaHandler.swift new file mode 100644 index 0000000..fe0624b --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/OpenSaomaHandler.swift @@ -0,0 +1,23 @@ +// +// OpenSaomaHandler.swift +// ylgamehall +// +// H5 → Native handler:opensaoma — 二维码扫描(**契约要求注册的空实现**) +// 契约:docs/H5-Native-Contract.md §3.1 [22] +// +// 原 msext 也是空实现,H5 调了不会出错也不会有动作。新外壳保留空实现 +// 以保持契约(不注册的话 H5 会触发 "no handler registered" 错误分支)。 +// + +import Foundation + +public enum OpenSaomaHandler { + + public static func register(on bridge: any BridgeProtocol) { + // 【22】opensaoma — 空实现 + // 入参忽略;副作用:无;cb:"opensaoma" + bridge.register("opensaoma") { _, callback in + callback?(.string("opensaoma")) + } + } +} diff --git a/ylgamehall/Source/Bridge/Handlers/ShakeHandler.swift b/ylgamehall/Source/Bridge/Handlers/ShakeHandler.swift new file mode 100644 index 0000000..8203392 --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/ShakeHandler.swift @@ -0,0 +1,70 @@ +// +// ShakeHandler.swift +// ylgamehall +// +// H5 → Native handler 三件套 + 反向 callback: +// startshake / stopshake / SwitchShake → shakeEnd +// +// 契约:docs/H5-Native-Contract.md §3.1 [7][8][9] + §3.2 [5] +// Design:§8.7 ShakeKit +// + +import UIKit + +/// 摇一摇状态控制 + 反向 callback 桥接器(@MainActor 实例化,VC 注入)。 +/// +/// 由 WebContainerViewController 在 viewDidLoad 时创建,并: +/// - 调用 `register(on:)` 注册 3 个 handler +/// - 重写 `motionEnded(_:with:)` 转发到 `handleMotionEnded(_:)` +/// - `viewDidAppear` 时调 `becomeFirstResponder()`(否则 motionEnded 不触发) +@MainActor +public final class ShakeHandler { + + /// 摇一摇总开关(startshake / stopshake 控制) + public private(set) var canShake: Bool = false + + /// 摇一摇音效开关(SwitchShake 控制) + public private(set) var canVoice: Bool = true + + private weak var bridge: (any BridgeProtocol)? + + public init() {} + + public func register(on bridge: any BridgeProtocol) { + self.bridge = bridge + + // 【7】startshake — 开摇 + // 入参忽略 → canshake = YES + // cb:"startshake from accreditlogin"(字面字符串带后缀,沿用历史; + // H5 不依赖此值,但严格拷贝保留契约等价) + bridge.register("startshake") { [weak self] _, callback in + Task { @MainActor in self?.canShake = true } + callback?(.string("startshake from accreditlogin")) + } + + // 【8】stopshake — 关摇 + // 入参忽略 → canshake = NO;cb 同 startshake + bridge.register("stopshake") { [weak self] _, callback in + Task { @MainActor in self?.canShake = false } + callback?(.string("startshake from accreditlogin")) + } + + // 【9】SwitchShake — 摇一摇音效开关 + // 入参 int 1 → canvoice = YES,其它 → canvoice = NO + // cb:"SwitchShake" + bridge.register("SwitchShake") { [weak self] data, callback in + let on = (data?.asInt ?? 0) == 1 + Task { @MainActor in self?.canVoice = on } + callback?(.string("SwitchShake")) + } + } + + /// 由 WebContainerViewController.motionEnded(_:with:) 转发调用。 + /// 触发反向 callback `shakeEnd`(仅 `canshake=YES` 时)。 + public func handleMotionEnded(_ motion: UIEvent.EventSubtype) { + guard motion == .motionShake, canShake else { return } + // Phase 2.3 暂不播放摇一摇音效(依赖 AudioKit / shake_sound_male.mp3, + // 留待 Phase 3 AudioKit 完整时补;canVoice 状态保留供后续使用) + bridge?.call("shakeEnd", data: nil, callback: nil) + } +} diff --git a/ylgamehall/Source/Bridge/Handlers/StartLocationHandler.swift b/ylgamehall/Source/Bridge/Handlers/StartLocationHandler.swift new file mode 100644 index 0000000..128c314 --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/StartLocationHandler.swift @@ -0,0 +1,27 @@ +// +// StartLocationHandler.swift +// ylgamehall +// +// H5 → Native handler:startlocation — 定位(Phase 5 完整实现,本期为 stub) +// 契约:docs/H5-Native-Contract.md §3.1 [20] + §3.2 [6] +// +// Stub 行为: +// - 接收 startlocation 调用,cb "startlocation"(响应不为 undefined,H5 不卡) +// - **不触发** getlocationinfo 反向 callback(暂无高德 SDK,做不到真实定位) +// - H5 业务侧若强依赖 getlocationinfo,需等 Phase 5 完整接入 AMapLocationKit +// +// 按 CLAUDE.md 原则 A 第一准则:H5 调啥就得有人接,即使没真实业务也要注册。 +// + +import Foundation + +public enum StartLocationHandler { + + public static func register(on bridge: any BridgeProtocol) { + bridge.register("startlocation") { _, callback in + // Phase 2 stub:仅响应 callback,不真实定位 + // Phase 5 接入 LocationKit + 高德 AMapLocationKit.xcframework 时升级为完整实现 + callback?(.string("startlocation")) + } + } +} diff --git a/ylgamehall/Source/Bridge/Handlers/VibratorHandler.swift b/ylgamehall/Source/Bridge/Handlers/VibratorHandler.swift index f995507..7f9bf45 100644 --- a/ylgamehall/Source/Bridge/Handlers/VibratorHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/VibratorHandler.swift @@ -2,31 +2,38 @@ // VibratorHandler.swift // ylgamehall // -// H5 → Native handler「vibrator」(契约 §3.1 [10]:单次振动)。 -// Phase 1.15 仅实现这一项作为最小垂直闭环验证标的; -// `repeatvibrator` / `canclevibrator` 放到 Phase 2.2 一并补齐。 +// H5 → Native handler 三件套:vibrator / repeatvibrator / canclevibrator +// 契约:docs/H5-Native-Contract.md §3.1 [10][11][12] +// 原项目:daoqi/msext RootVC.m:1816-1834 // import AudioToolbox -/// 单次振动 handler 注册器。 -/// -/// 设计为无状态 enum:震动调用是无状态的 C API,不需要持有任何资源; -/// 同时避免误把 handler 注册器实例化保留,造成"注册多份"。 +/// 振动 handler 注册器(无状态 enum)。 public enum VibratorHandler { - /// 在 `bridge` 上注册 `vibrator` handler。 - /// - /// 契约(`docs/H5-Native-Contract.md` §3.1 [10]): - /// - 入参:被忽略(参考原 msext `RootVC.m:1816-1818` 入参 `time` 实际不读取) - /// - 副作用:`AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)` - /// - responseCallback:字符串 `"vibrator"` - /// - /// 模拟器无振动硬件,调用会被静默忽略(不抛异常),真机才能感知。 + /// 注册三个振动 handler。 public static func register(on bridge: any BridgeProtocol) { + // 【10】vibrator — 单次振动 + // 入参忽略;副作用:AudioServicesPlaySystemSound;cb "vibrator" bridge.register("vibrator") { _, callback in AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) callback?(.string("vibrator")) } + + // 【11】repeatvibrator — 重复振动(msext RootVC.m:1820 实际就是单次 vibrate, + // 不真的循环,沿用历史) + bridge.register("repeatvibrator") { _, callback in + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + callback?(.string("repeatvibrator")) + } + + // 【12】canclevibrator — 释放系统振动音 + // AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate) 对系统常量 + // 意义有限,但 H5 仍会调,必须 noop + cb 维持契约 + bridge.register("canclevibrator") { _, callback in + AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate) + callback?(.string("canclevibrator")) + } } } diff --git a/ylgamehall/Source/Bridge/Handlers/VoicePlayingHandler.swift b/ylgamehall/Source/Bridge/Handlers/VoicePlayingHandler.swift new file mode 100644 index 0000000..55e5519 --- /dev/null +++ b/ylgamehall/Source/Bridge/Handlers/VoicePlayingHandler.swift @@ -0,0 +1,34 @@ +// +// VoicePlayingHandler.swift +// ylgamehall +// +// H5 → Native handler:voicePlaying — 远端语音播放总开关 +// 契约:docs/H5-Native-Contract.md §3.1 [6] +// +// Phase 2.4 仅实现"开关位",真正的 mediaTypeAudio 播放在 Phase 3 AudioKit +// 完整时接入。开关位由 actor VoiceCenter 持有,跨 handler 共享。 +// + +import Foundation + +/// 远端语音播放总开关(actor 隔离,跨 bridge handler 安全共享)。 +/// Phase 3 mediaTypeAudio handler 读此 isEnabled 决定是否播放。 +public actor VoiceCenter { + public static let shared = VoiceCenter() + public private(set) var isEnabled: Bool = true + public func setEnabled(_ on: Bool) { isEnabled = on } +} + +public enum VoicePlayingHandler { + + public static func register(on bridge: any BridgeProtocol) { + // 【6】voicePlaying + // 入参 int 1=允许 / 其它=静默 + // cb:"Response from voicePlaying" + bridge.register("voicePlaying") { data, callback in + let on = (data?.asInt ?? 0) == 1 + Task { await VoiceCenter.shared.setEnabled(on) } + callback?(.string("Response from voicePlaying")) + } + } +} diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index 4549ffe..e97b6c0 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -17,6 +17,10 @@ public final class WebContainerViewController: UIViewController { private let bridgedWebView = BridgedWebView() private let splash = SplashOverlay() + // MARK: - Handlers (有状态的 handler 注册器持有,无状态的走 enum 静态 register) + + private let shakeHandler = ShakeHandler() + // MARK: - Boot pipeline 内部错误分类 // // 用 enum 区分"终态阻塞"与"系统级错误": @@ -47,10 +51,37 @@ public final class WebContainerViewController: UIViewController { } } - /// 注册所有 H5 → Native handler。Phase 1.15 仅 `vibrator`; - /// 后续 Phase 2+ 在此追加更多 handler(保持单一聚合点便于发现 / 调试)。 + /// 注册所有 H5 → Native handler 的单一聚合点。 + /// Phase 1.15 仅 vibrator;Phase 2.A 扩展到 8 个 handler 模块。 private func registerBridgeHandlers() { - VibratorHandler.register(on: bridgedWebView.bridge) + let bridge = bridgedWebView.bridge + + // Phase 1.15 + VibratorHandler.register(on: bridge) // §3.1 [10][11][12]扩展 + + // Phase 2.A 大厅简单 handler(无外部依赖) + ClipboardHandler.register(on: bridge) // §3.1 [13][14] + shakeHandler.register(on: bridge) // §3.1 [7][8][9] + §3.2 [5] + VoicePlayingHandler.register(on: bridge) // §3.1 [6] + DeviceInfoHandler.register(on: bridge) // §3.1 [21] + §3.2 [1] + BrowserHandler.register(on: bridge) // §3.1 [16] + OpenSaomaHandler.register(on: bridge) // §3.1 [22]空 stub + StartLocationHandler.register(on: bridge) // §3.1 [20]Phase 5 完整实现,Phase 2 stub + } + + // MARK: - 摇一摇支持 + // motionEnded 必须 first responder 才触发;canBecomeFirstResponder = true + + // viewDidAppear becomeFirstResponder。详见 Design §8.7.1。 + + public override var canBecomeFirstResponder: Bool { true } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + becomeFirstResponder() + } + + public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + shakeHandler.handleMotionEnded(motion) } public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape }