Phase 2.A:大厅 8 个简单 handler 落地 + BridgeData 加 nonisolated

闭合 Phase 1.17 联调最后一公里(解决 Xcode 日志 4 个 "no handler registered"
+ 提前落地 4 个无外部依赖的简单 handler)。

新增 7 个 handler 模块(Source/Bridge/Handlers/):
  - ClipboardHandler.swift           §3.1 [13][14]剪贴板 UIPasteboard.general
  - ShakeHandler.swift               §3.1 [7][8][9]摇一摇 + §3.2 [5]shakeEnd 反向
                                       canShake / canVoice 状态 + motionEnded 转发钩子
  - VoicePlayingHandler.swift        §3.1 [6]actor VoiceCenter.shared 开关位
  - DeviceInfoHandler.swift          §3.1 [21]+ §3.2 [1]getphoneInfo 大写 I 入、
                                       getphoneinfo 小写 i 出反向 callback
                                       DeviceInfoSnapshot 6 字段(IDFA 用 idfv 兜底,
                                       CLAUDE.md 不接 ATT/IDFA)
  - BrowserHandler.swift             §3.1 [16]UIApplication.open async(Task @MainActor
                                       异步打开,闭包先 cb 不等 open 完成)
  - OpenSaomaHandler.swift           §3.1 [22]空 stub(契约要求注册)
  - StartLocationHandler.swift       §3.1 [20]Phase 5 完整实现,Phase 2 stub

扩展 VibratorHandler.swift:
  - §3.1 [10]vibrator(原有)+ [11]repeatvibrator(msext RootVC.m:1820 沿用:
    实际单次 vibrate 不真循环)+ [12]canclevibrator

BridgeProtocol.swift BridgeData 加 nonisolated:
  - 项目默认 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor 让 BridgeData 跨 actor
    访问被拦;asString / asDouble / asInt / asBool / asArray / asObject /
    subscript 全部标 nonisolated(纯值类型本就该是 isolation-free)
  - 影响所有 @Sendable async handler 闭包内的 data?.asXxx 访问

WebContainerViewController 接入:
  - 加 private let shakeHandler = ShakeHandler() 持有
  - registerBridgeHandlers() 注册 8 个 handler 模块
  - 加 canBecomeFirstResponder = true + viewDidAppear becomeFirstResponder
  - 加 motionEnded(_:with:) 转发到 shakeHandler.handleMotionEnded
    (motion 检测必需 first responder,否则不触发)

BuildProject 通过。重装 app 后 H5 启动应不再报 "no handler registered" 错误。

Plan §5 Phase 2.A 全部勾选(含 startlocation bonus stub + BridgeData
nonisolated 重构)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 18:38:00 +08:00
co-authored by Claude Opus 4.7
parent 0a313afe8e
commit d38ff0f8c4
11 changed files with 364 additions and 33 deletions
+9 -7
View File
@@ -347,13 +347,15 @@ Contract Design Plan(本文档)
##### 2.A H5→Native handler(无外部依赖) ##### 2.A H5→Native handler(无外部依赖)
- [ ] **2.1** `gameCopytext` / `gamepastetext`(剪贴板) - [x] **2.1** `gameCopytext` / `gamepastetext`(剪贴板)`Source/Bridge/Handlers/ClipboardHandler.swift`UIPasteboard.general
- [ ] **2.2** `vibrator` / `repeatvibrator` / `canclevibrator`已有,扩展 - [x] **2.2** `vibrator` / `repeatvibrator` / `canclevibrator`VibratorHandler.swift 扩展 2 项
- [ ] **2.3** `startshake` / `stopshake` / `SwitchShake`摇一摇开关 + 音效开关 - [x] **2.3** `startshake` / `stopshake` / `SwitchShake`ShakeHandler.swiftcanShake / canVoice 状态 + WebContainer motionEnded 转发 + shakeEnd 反向
- [ ] **2.4** `voicePlaying`语音播放总开关 - [x] **2.4** `voicePlaying`VoicePlayingHandler.swiftactor VoiceCenter.shared.isEnabled 开关位,Phase 3 mediaTypeAudio 读此
- [ ] **2.5** `getphoneInfo` → 触发反向 callback `getphoneinfo`注意小写 i - [x] **2.5** `getphoneInfo` → 触发反向 callback `getphoneinfo`DeviceInfoHandler.swift,注意大小写:大写 I 入、小写 i 出;DeviceInfoSnapshot 6 字段,IDFA 用 idfv 兜底因为 CLAUDE.md 不接 ATT
- [ ] **2.6** `browser` → 系统 Safari 打开 URL - [x] **2.6** `browser` → 系统 Safari 打开 URLBrowserHandler.swiftUIApplication.open async
- [ ] **2.7** `opensaoma` 空实现注册(契约 §3.1【22】要求) - [x] **2.7** `opensaoma` 空实现注册(OpenSaomaHandler.swift契约 §3.1【22】要求)
- [x] **Bonus** `startlocation` stubStartLocationHandler.swift,让 H5 启动不报 "no handler"Phase 5 接 LocationKit 完整实现)
- [x] **Bridge 重构** `BridgeData.asXxx` 全部加 `nonisolated`,让 handler 闭包(@Sendable async)可以自由访问数据值;契约不变
##### 2.B Native→H5 反向 callback ##### 2.B Native→H5 反向 callback
+11 -8
View File
@@ -98,47 +98,50 @@ extension BridgeData {
} }
// MARK: - 访 // MARK: - 访
// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor main
// BridgeData handler @Sendable访
// 访 nonisolated actor
extension BridgeData { extension BridgeData {
public var asString: String? { nonisolated public var asString: String? {
if case .string(let s) = self { return s } if case .string(let s) = self { return s }
return nil return nil
} }
public var asDouble: Double? { nonisolated public var asDouble: Double? {
if case .number(let d) = self { return d } if case .number(let d) = self { return d }
return nil return nil
} }
public var asInt: Int? { nonisolated public var asInt: Int? {
if case .number(let d) = self { return Int(d) } if case .number(let d) = self { return Int(d) }
return nil return nil
} }
public var asBool: Bool? { nonisolated public var asBool: Bool? {
if case .bool(let b) = self { return b } if case .bool(let b) = self { return b }
return nil return nil
} }
public var asArray: [BridgeData]? { nonisolated public var asArray: [BridgeData]? {
if case .array(let a) = self { return a } if case .array(let a) = self { return a }
return nil return nil
} }
public var asObject: [String: BridgeData]? { nonisolated public var asObject: [String: BridgeData]? {
if case .object(let o) = self { return o } if case .object(let o) = self { return o }
return nil return nil
} }
/// 访`data["title"]?.asString` /// 访`data["title"]?.asString`
public subscript(key: String) -> BridgeData? { nonisolated public subscript(key: String) -> BridgeData? {
if case .object(let o) = self { return o[key] } if case .object(let o) = self { return o[key] }
return nil return nil
} }
/// 访`data[0]?.asString` /// 访`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] } if case .array(let a) = self, a.indices.contains(index) { return a[index] }
return nil return nil
} }
@@ -0,0 +1,32 @@
//
// BrowserHandler.swift
// ylgamehall
//
// H5 Native handlerbrowser Safari URL
// docs/H5-Native-Contract.md §3.1 16
//
import UIKit
@MainActor
public enum BrowserHandler {
public static func register(on bridge: any BridgeProtocol) {
// 16browser
// data: stringURL 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)
}
}
}
}
@@ -0,0 +1,33 @@
//
// ClipboardHandler.swift
// ylgamehall
//
// H5 Native handlergameCopytext / gamepastetext
// docs/H5-Native-Contract.md §3.1 1314
// daoqi/msext NewRootVC.mUIPasteboard
//
import UIKit
@MainActor
public enum ClipboardHandler {
public static func register(on bridge: any BridgeProtocol) {
// 13gamepastetext
// cb "gamepastetext"nil
// iOS 14+ " XX"
bridge.register("gamepastetext") { _, callback in
let text = UIPasteboard.general.string ?? ""
callback?(.string(text))
}
// 14gameCopytext
// data: stringcb"gameCopytext"
bridge.register("gameCopytext") { data, callback in
if let text = data?.asString {
UIPasteboard.general.string = text
}
callback?(.string("gameCopytext"))
}
}
}
@@ -0,0 +1,69 @@
//
// DeviceInfoHandler.swift
// ylgamehall
//
// H5 Native handlergetphoneInfo + 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) {
// 21getphoneInfo I
// cb"getphoneInfo"
// bridge.call("getphoneinfo" idata: 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 idfvIDFA 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)
]
}
}
@@ -0,0 +1,23 @@
//
// OpenSaomaHandler.swift
// ylgamehall
//
// H5 Native handleropensaoma ****
// 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) {
// 22opensaoma
// cb"opensaoma"
bridge.register("opensaoma") { _, callback in
callback?(.string("opensaoma"))
}
}
}
@@ -0,0 +1,70 @@
//
// ShakeHandler.swift
// ylgamehall
//
// H5 Native handler + callback
// startshake / stopshake / SwitchShake shakeEnd
//
// docs/H5-Native-Contract.md §3.1 789 + §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
// 7startshake
// 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"))
}
// 8stopshake
// canshake = NOcb startshake
bridge.register("stopshake") { [weak self] _, callback in
Task { @MainActor in self?.canShake = false }
callback?(.string("startshake from accreditlogin"))
}
// 9SwitchShake
// 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)
}
}
@@ -0,0 +1,27 @@
//
// StartLocationHandler.swift
// ylgamehall
//
// H5 Native handlerstartlocation Phase 5 stub
// docs/H5-Native-Contract.md §3.1 20 + §3.2 6
//
// Stub
// - startlocation cb "startlocation" undefinedH5
// - **** 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"))
}
}
}
@@ -2,31 +2,38 @@
// VibratorHandler.swift // VibratorHandler.swift
// ylgamehall // ylgamehall
// //
// H5 Native handlervibrator §3.1 10 // H5 Native handler vibrator / repeatvibrator / canclevibrator
// Phase 1.15 // docs/H5-Native-Contract.md §3.1 101112
// `repeatvibrator` / `canclevibrator` Phase 2.2 // daoqi/msext RootVC.m:1816-1834
// //
import AudioToolbox import AudioToolbox
/// handler /// handler enum
///
/// enum C API
/// handler ""
public enum VibratorHandler { public enum VibratorHandler {
/// `bridge` `vibrator` handler /// handler
///
/// `docs/H5-Native-Contract.md` §3.1 10
/// - msext `RootVC.m:1816-1818` `time`
/// - `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`
/// - responseCallback `"vibrator"`
///
///
public static func register(on bridge: any BridgeProtocol) { public static func register(on bridge: any BridgeProtocol) {
// 10vibrator
// AudioServicesPlaySystemSoundcb "vibrator"
bridge.register("vibrator") { _, callback in bridge.register("vibrator") { _, callback in
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
callback?(.string("vibrator")) callback?(.string("vibrator"))
} }
// 11repeatvibrator msext RootVC.m:1820 vibrate
// 沿
bridge.register("repeatvibrator") { _, callback in
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
callback?(.string("repeatvibrator"))
}
// 12canclevibrator
// AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)
// H5 noop + cb
bridge.register("canclevibrator") { _, callback in
AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)
callback?(.string("canclevibrator"))
}
} }
} }
@@ -0,0 +1,34 @@
//
// VoicePlayingHandler.swift
// ylgamehall
//
// H5 Native handlervoicePlaying
// 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) {
// 6voicePlaying
// 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"))
}
}
}
@@ -17,6 +17,10 @@ public final class WebContainerViewController: UIViewController {
private let bridgedWebView = BridgedWebView() private let bridgedWebView = BridgedWebView()
private let splash = SplashOverlay() private let splash = SplashOverlay()
// MARK: - Handlers ( handler enum register)
private let shakeHandler = ShakeHandler()
// MARK: - Boot pipeline // MARK: - Boot pipeline
// //
// enum """" // enum """"
@@ -47,10 +51,37 @@ public final class WebContainerViewController: UIViewController {
} }
} }
/// H5 Native handlerPhase 1.15 `vibrator` /// H5 Native handler
/// Phase 2+ handler便 / /// Phase 1.15 vibratorPhase 2.A 8 handler
private func registerBridgeHandlers() { private func registerBridgeHandlers() {
VibratorHandler.register(on: bridgedWebView.bridge) let bridge = bridgedWebView.bridge
// Phase 1.15
VibratorHandler.register(on: bridge) // §3.1 101112
// Phase 2.A handler
ClipboardHandler.register(on: bridge) // §3.1 1314
shakeHandler.register(on: bridge) // §3.1 789 + §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 20Phase 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 } public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape }