// // BridgeBus.swift // ylgamehall // // BridgeProtocol 的具体实现 + WKScriptMessageHandler 适配。 // 详见 docs/H5-Native-Implementation-Design.md §3.3。 // import Foundation import WebKit import os.log /// 桥诊断日志。文件级 `let`(Logger 是 Sendable)以便在 evaluateJavaScript /// 完成回调等非 MainActor 上下文里也能直接用。 private let bridgeLog = Logger(subsystem: "ylgamehall", category: "Bridge") /// ── 临时诊断输出(排查「子游戏收不到 getlocationinfo」,结束后整体移除)── /// /// 真机上 `Logger.debug` 会被系统日志级别过滤掉:devicectl console / Xcode /// console 都只能看到 `.error` 及以上。`print` 直写 stderr 必达,所以诊断期 /// 统一走这里;带毫秒时间戳以便与 CoreLocation 的 os_log 行对齐时序。 /// 同时落盘到 `Documents/diag.log`:用 Xcode 跑真机时 print 只进 Xcode console, /// 外部拿不到;落盘后可用 /// xcrun devicectl device copy from --domain-type appDataContainer \ /// --domain-identifier com.skyapp.ylgamehall --source Documents/diag.log ... /// 把整轮日志取出来分析。 nonisolated func diagLog(_ message: String) { let line = "[DIAG \(diagTimestamp())] \(message)" print(line) diagFileSink.append(line) } private nonisolated let diagFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "HH:mm:ss.SSS" return f }() private nonisolated func diagTimestamp() -> String { diagFormatter.string(from: Date()) } /// 串行写入 `Documents/diag.log`。每个进程首次写入前插一行 session 分隔, /// 便于区分多次启动。纯诊断设施,排查结束随 diagLog 一并移除。 // 工程默认 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor,需显式 nonisolated 才能在 // @Sendable 桥回调 / evaluateJavaScript 完成块里同步调用。 private nonisolated final class DiagFileSink: @unchecked Sendable { private let queue = DispatchQueue(label: "ylgamehall.diag.log") private let url: URL private var wroteHeader = false init() { let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] url = dir.appendingPathComponent("diag.log") } func append(_ line: String) { queue.async { [self] in var text = line + "\n" if !wroteHeader { wroteHeader = true let stamp = ISO8601DateFormatter().string(from: Date()) text = "\n===== session \(stamp) pid=\(ProcessInfo.processInfo.processIdentifier) =====\n" + text } guard let data = text.data(using: .utf8) else { return } let fm = FileManager.default if !fm.fileExists(atPath: url.path) { fm.createFile(atPath: url.path, contents: nil) } guard let handle = try? FileHandle(forWritingTo: url) else { return } handle.seekToEndOfFile() handle.write(data) try? handle.close() } } } private nonisolated let diagFileSink = DiagFileSink() /// H5 ↔ Native 消息总线。 /// /// **运行时约束**:必须挂在 WKWebView 上、@MainActor 单线程访问。 /// **生命周期**:构造时自动向 `WKUserContentController` 注册 `WVJBHandler` 通道, /// 销毁时由 WebView 链路自然释放(无需手动 remove)。 @MainActor public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler { /// JS → Native 的 WKScriptMessageHandler 通道名。 /// JS 端通过 `window.webkit.messageHandlers.WVJBHandler.postMessage({...})` 发送。 public static let scriptMessageHandlerName = "WVJBHandler" /// JS 端注入的全局桥对象名(约定 `window.WebViewJavascriptBridge`)。 public static let jsBridgeName = "WebViewJavascriptBridge" /// 容器标识(`lobby` / `subGame` / …),仅用于诊断日志区分是哪个 WebView 的桥。 /// 不参与任何契约行为。 public let label: String private weak var webView: WKWebView? private var handlers: [String: BridgeHandler] = [:] private var pendingCallbacks: [String: BridgeCallback] = [:] private var nativeCallbackCounter: UInt64 = 0 /// 桥就绪前的消息暂存队列。等价 msext `WebViewJavascriptBridgeBase.startupMessageQueue`: /// `_queueMessage` 在 bridge 尚未 loaded 时入队,`injectJavascriptFile` 时按序 flush。 /// /// 早前的实现在 `window.WebViewJavascriptBridge` 不存在时**直接丢弃**消息 —— /// 真机日志里每次启动都有一条 `sendToJS 'getnetwork' 未送达 H5:no-bridge`, /// msext 里这条是会补发到 H5 的。子游戏 WebView 是全新建的、H5 很早就调 /// `startlocation`,反向 `getlocationinfo` 落在这个窗口里就会被静默吞掉。 private var startupMessageQueue: [[String: Any]] = [] /// H5 侧 WVJB.js 初始化完成后会 postMessage `{bridgeReady:true}`,收到即置 true 并 flush。 private var bridgeReady = false /// 构造时绑定 WebView + 用户内容控制器,自动注册 `WVJBHandler` 消息处理器。 public init(webView: WKWebView, controller: WKUserContentController, label: String = "webview") { self.webView = webView self.label = label super.init() controller.add(self, name: Self.scriptMessageHandlerName) } // MARK: - BridgeProtocol public func register(_ name: String, handler: @escaping BridgeHandler) { handlers[name] = handler diagLog("[\(self.label)] register handler '\(name)'") } public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) { var payload: [String: Any] = ["handlerName": name] if let data { payload["data"] = data.jsonObject } if let callback { nativeCallbackCounter += 1 let cbId = "objc_cb_\(nativeCallbackCounter)" pendingCallbacks[cbId] = callback payload["callbackId"] = cbId } // 诊断:打出实际序列化后的字节,确认字段集合 / 类型 / 引号与 msext 一致 let wire = (try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])) .flatMap { String(data: $0, encoding: .utf8) } ?? "" diagLog("[\(self.label)] → H5 callHandler '\(name)' wire=\(wire)") sendToJS(payload: payload) } // MARK: - WKScriptMessageHandler public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { // body 可能是单条 dict 或一批 dict array(WVJB 支持批处理) if let batch = message.body as? [[String: Any]] { for msg in batch { handleIncoming(msg) } } else if let single = message.body as? [String: Any] { handleIncoming(single) } else { print("[BridgeBus] unrecognized message body type: \(type(of: message.body))") } } // MARK: - 私有 private func handleIncoming(_ msg: [String: Any]) { // H5 侧 WVJB.js 初始化完成信号(等价 msext 的 `__bridge_loaded__`)。 // 每次导航都会重新注入 user script,因此会重复收到;每次都要重新 flush。 if msg["bridgeReady"] as? Bool == true { bridgeReady = true let queued = startupMessageQueue startupMessageQueue.removeAll() if !queued.isEmpty { diagLog("[\(self.label)] 桥就绪,flush \(queued.count) 条暂存消息") } for payload in queued { dispatchToJS(payload: payload) } return } // 诊断上报:H5 侧派发结果(有没有对应 handler、handler 有没有抛错) if let dispatched = msg["diagDispatched"] as? String { let had = msg["hadHandler"] as? Bool ?? false let threw = msg["threw"] as? String diagLog("[\(self.label)] H5 派发 '\(dispatched)':hadHandler=\(had) threw=\(threw ?? "nil")") return } // 优先判 responseId(JS 响应 Native 早前的 callHandler) if let responseId = msg["responseId"] as? String { let respData = msg["responseData"].flatMap { BridgeData(jsonObject: $0) } if let cb = pendingCallbacks.removeValue(forKey: responseId) { cb(respData) } else { print("[BridgeBus] stale responseId: \(responseId)") } return } // 否则是 JS → Native handler 调用 guard let name = msg["handlerName"] as? String else { print("[BridgeBus] message missing handlerName: \(msg)") return } let data = msg["data"].flatMap { BridgeData(jsonObject: $0) } let callbackId = msg["callbackId"] as? String // 构造一个回调闭包,handler 调用即把 responseData 异步派回 JS let responseCallback: BridgeCallback? = makeResponseCallback(for: callbackId) diagLog("[\(self.label)] ← H5 call '\(name)' data=\(String(describing: msg["data"]))") guard let handler = handlers[name] else { // msext `WebViewJavascriptBridgeBase.flushMessageQueue`: // if (!handler) { NSLog(@"WVJBNoHandlerException, ..."); continue; } // `continue` 意味着 **responseCallback 一次都不会被调用**。 // // 早前这里调了 `responseCallback?(nil)`,会给 H5 回一条 {responseId:...}。 // 现网 H5 的 `Func.getlocation()` 就是带 callback 调 `getlocationinfo` 的 // (`bridge.callHandler('getlocationinfo',"",function(resp){})`,而 // `getlocationinfo` 两边都没有对应的原生 handler),于是这条在原工程里 // 永不触发的 JS 回调在我们这儿会被触发一次 —— 链条不一致。改为不回。 diagLog("[\(self.label)] no NATIVE handler registered for '\(name)'(msext 同款:不回 responseCallback)") return } // handler 是 async,独立 Task 内 await;本方法保持 sync 返回 Task { await handler(data, responseCallback) } } private func makeResponseCallback(for callbackId: String?) -> BridgeCallback? { guard let id = callbackId else { return nil } return { [weak self] respData in Task { @MainActor in self?.sendResponseToJS(responseId: id, data: respData) } } } private func sendResponseToJS(responseId: String, data: BridgeData?) { var payload: [String: Any] = ["responseId": responseId] if let data { payload["responseData"] = data.jsonObject } sendToJS(payload: payload) } /// 等价 msext `WebViewJavascriptBridgeBase._queueMessage:`: /// 桥未就绪则入 `startupMessageQueue`,就绪则直接派发。 private func sendToJS(payload: [String: Any]) { guard bridgeReady else { let what = (payload["handlerName"] as? String) ?? (payload["responseId"] as? String) ?? "?" diagLog("[\(self.label)] '\(what)' 桥未就绪 → 入队(msext startupMessageQueue 等价,队列长 \(startupMessageQueue.count + 1))") startupMessageQueue.append(payload) return } dispatchToJS(payload: payload) } /// 等价 msext `_dispatchMessage:`:序列化 → 注入 `_handleMessageFromObjC`。 /// H5 侧派发是 `setTimeout` 异步的(msext dispatchMessagesWithTimeoutSafety=true), /// 因此 evaluateJavaScript 的返回值**不代表** H5 handler 是否执行,不要据此判断送达。 private func dispatchToJS(payload: [String: Any]) { guard let webView else { diagLog("[\(self.label)] dispatchToJS 丢弃:webView 已释放") return } guard let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) else { diagLog("[\(self.label)] JSON encode failed for payload: \(String(describing: payload))") return } // base64 包装避免 JSON 内单引号 / 反斜杠扰乱 JS 字符串字面量 // (msext 是逐个 escape 反斜杠/引号/换行/U+2028/U+2029,等价) let base64 = Data(json.utf8).base64EncodedString() let js = "window.\(Self.jsBridgeName)._handleMessageFromObjC('\(base64)');" let tag = label let what = (payload["handlerName"] as? String) ?? (payload["responseId"] as? String) ?? "?" webView.evaluateJavaScript(js) { _, error in if let error { diagLog("[\(tag)] dispatchToJS '\(what)' evaluateJavaScript 失败: \(error.localizedDescription)") } } } }