diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 9bb6c13..da1c020 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -634,13 +634,23 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin #### 任务清单 -- [ ] **7.1** `Source/Containers/OverlayViewController.swift`:继承 WebContainer,但 WKWebsiteDataStore 用 `.nonPersistent()` 隔离 -- [ ] **7.2** `Source/WebView/OverlayBridge.swift` polyfill JS:`window.settings = {backgameData, browser, finishweb}` → `webkit.messageHandlers.*` -- [ ] **7.3** `WKScriptMessageHandler` 注册三 handler:`overlayBackgameData` / `overlayBrowser` / `overlayFinishweb` -- [ ] **7.4** `OpenurlTitleData` handler(大厅 + 子游戏都注册):解参(注意 `"title "` 末尾空格契约) → AppCoordinator.showOverlay → 3 秒节流 -- [ ] **7.5** Overlay backgameData → 发 `.subGameDidReturn` → 上层 H5 收 `getWebdata` -- [ ] **7.6** Overlay `finishweb` → coordinator.popOverlay -- [ ] **7.7** Overlay `browser` → 系统 Safari 打开 +- [x] **7.1** `Source/WebView/OverlayViewController.swift`:独立 WKWebView,`WKWebViewConfiguration.websiteDataStore = .nonPersistent()` + 隔离弹层 Cookie / 缓存(不污染大厅 / 子游戏会话) +- [x] **7.2** polyfill JS 内联(不抽独立 .js 文件):`.atDocumentEnd` 注入 + `window.settings={browser,finishweb,backgameData}` → `webkit.messageHandlers.*` + (msext threeView.m:223-241 在 webViewDidFinishLoad 注入 `context[@"settings"]=jo` 等价时机) +- [x] **7.3** `WKScriptMessageHandler` 通过 `ScriptMessageProxy`(weak target 切断 retain cycle) + 注册 3 handler:`browser` / `finishweb` / `backgameData`(messageHandler 与 WVJB 不同 channel, + 可与大厅 WVJB 同名共存不冲突) +- [x] **7.4** `OpenurlTitleData` handler(大厅 + 子游戏都注册):解参(`"title "` 末尾空格契约硬约束) → + `AppCoordinator.showOverlay` → 3 秒节流由 Coordinator 守门(msext first_Time 等价) +- [x] **7.5** Overlay backgameData → `AppCoordinator.popOverlay(returningData:)` → 发 `.subGameDidReturn` + → 父层(大厅 / 子游戏)观察后 callback H5 `getWebdata` +- [x] **7.6** Overlay `finishweb` → `popOverlay(returningData: nil)` 仅 pop 不发通知 +- [x] **7.7** Overlay `browser` → `UIApplication.shared.open` Safari 外链 +- [x] **额外**:SubGameViewController.setupExternalSubscriptions 也订阅 `.subGameDidReturn` + (overlay 可从子游戏 push,pop 回子游戏时需要 callback 子游戏 H5 的 getWebdata; + 与大厅 / 子游戏的 viewWillAppear/Disappear 生命周期对齐,栈顶才挂钩避免双发) #### 验收 @@ -942,11 +952,12 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin - [x] 6.7 SubGameDownloader(actor,URLSession + ZIPFoundation + staging + 原子 rename) ### Phase 7 弹层 -- [ ] 7.1 OverlayViewController + 私有 dataStore -- [ ] 7.2 window.settings polyfill -- [ ] 7.3 3 个 WKScriptMessageHandler -- [ ] 7.4 OpenurlTitleData handler("title " 末尾空格) -- [ ] 7.5 Overlay backgameData +- [x] 7.1 OverlayViewController + 私有 dataStore(.nonPersistent) +- [x] 7.2 window.settings polyfill(.atDocumentEnd 注入) +- [x] 7.3 3 个 WKScriptMessageHandler(ScriptMessageProxy 切断 retain cycle) +- [x] 7.4 OpenurlTitleData handler("title " 末尾空格 + 3s 节流由 Coordinator 守门) +- [x] 7.5 Overlay backgameData(→ popOverlay 发 .subGameDidReturn → 父层 getWebdata) +- [x] 额外 SubGameViewController 也订阅 .subGameDidReturn(覆盖 overlay→sub-game 返回) - [ ] 7.6 Overlay finishweb - [ ] 7.7 Overlay browser diff --git a/ylgamehall/Source/Bridge/Handlers/OpenurlTitleDataHandler.swift b/ylgamehall/Source/Bridge/Handlers/OpenurlTitleDataHandler.swift index a5999e5..5586167 100644 --- a/ylgamehall/Source/Bridge/Handlers/OpenurlTitleDataHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/OpenurlTitleDataHandler.swift @@ -2,14 +2,17 @@ // OpenurlTitleDataHandler.swift // ylgamehall // -// H5 → Native handler stub:OpenurlTitleData — 打开内嵌 WebView 弹层(threeView) +// H5 → Native handler:OpenurlTitleData — 打开内嵌 WebView 弹层(msext threeView 等价)。 // 契约:docs/H5-Native-Contract.md §3.1 [15] // -// Phase 7 完整实现需要 OverlayViewController + window.settings polyfill -// + 3 秒节流。当前为 stub(仅 cb 维持契约),让 H5 调时不报 "no handler"。 +// ⚠️ 入参 key "title " 末尾有空格(msext 历史契约硬约束): +// 必须 `data["title "]?.asString` 而不是 `data["title"]?.asString`, +// H5 端 callHandler 时也是 `{"title ": "..."}`(带空格的字符串字面) // -// ⚠️ 注意入参 key "title " 末尾有空格(msext 历史契约硬约束),完整实现时 -// 必须 obj["title "]?.asString 而不是 obj["title"]?.asString。 +// 与 msext NewRootVC.m / gameController.m 等价: +// - 大厅 + 子游戏页都注册(msext 两处都有),用户在哪一页调都能打开弹层 +// - 3 秒节流由 AppCoordinator.showOverlay 守门(msext first_Time 等价) +// - callback 字面始终回 "OpenurlTitleData",无论节流是否命中(msext 行为) // import Foundation @@ -20,8 +23,25 @@ public enum OpenurlTitleDataHandler { // 【15】OpenurlTitleData // 入参 url / "title "(末尾空格!) / data / orientation // cb: "OpenurlTitleData" - // 完整实现需 push OverlayViewController + 3 秒节流(first_Time 标志) - bridge.register("OpenurlTitleData") { _, callback in + bridge.register("OpenurlTitleData") { data, callback in + await MainActor.run { + // ⚠️ "title " 末尾空格不可改;orientation 在 landscape-only app 中保留契约字段 + let url = data?["url"]?.asString ?? "" + let title = data?["title "]?.asString ?? "" + let webData = data?["data"]?.asString ?? "" + let orientation = data?["orientation"]?.asInt + ?? Int(data?["orientation"]?.asString ?? "") + ?? 0 + + let request = OverlayRequest( + url: url, + title: title, + webData: webData, + orientation: orientation + ) + AppCoordinator.shared.showOverlay(request) + } + // msext 行为:无论节流命中 / 入参缺失,都回 cb,避免 H5 等待 callback?(.string("OpenurlTitleData")) } } diff --git a/ylgamehall/Source/Coordinator/AppCoordinator.swift b/ylgamehall/Source/Coordinator/AppCoordinator.swift index 6aa52fb..70e57cd 100644 --- a/ylgamehall/Source/Coordinator/AppCoordinator.swift +++ b/ylgamehall/Source/Coordinator/AppCoordinator.swift @@ -10,7 +10,8 @@ // // 与 msext 一对一行为: // - SwitchOverGameData 节流:2s 内不重复触发 -// - subGameDidReturn 通知:子游戏 pop 时发,大厅监听并 callback getWebdata +// - OpenurlTitleData 节流:3s 内不重复触发(msext first_Time 等价) +// - subGameDidReturn 通知:子游戏 / 弹层 pop 时发,父层(大厅 / 子游戏)监听并 callback getWebdata // import UIKit @@ -30,9 +31,26 @@ public struct SubGameRequest: Sendable { } } -/// 子游戏 pop 回大厅通知(msext .subGameDidReturn 等价) +/// 弹层 push 请求(从 H5 OpenurlTitleData 入参解析) +public struct OverlayRequest: Sendable { + public let url: String // 待加载 HTTP URL + public let title: String // 弹层标题(msext 历史:nav 默认隐藏,title 仅信息性) + public let webData: String // data:业务数据透传给 threeView 内 H5 + public let orientation: Int // 0=竖屏 / 1=横屏(landscape-only app 忽略,预留契约字段) + + public init(url: String, title: String, webData: String, orientation: Int) { + self.url = url + self.title = title + self.webData = webData + self.orientation = orientation + } +} + +/// 子容器(子游戏 / 弹层)pop 回父层通知。 +/// msext 同款 "backgameDatatwo":threeView 和 gameController 共用一个名字, +/// 父层(大厅 / 子游戏)监听后转 `getWebdata` 反向 callback。 public extension Notification.Name { - /// 子游戏 pop 返回时发出,userInfo["data"] = 子游戏 backgameData 入参字符串 + /// 子游戏 / 弹层 pop 返回时发出,userInfo["data"] = 调 backgameData 时传入的字符串 static let subGameDidReturn = Notification.Name("ylgamehall.subGameDidReturn") } @@ -41,8 +59,10 @@ public final class AppCoordinator { public static let shared = AppCoordinator() - /// 节流:2 秒内连续 SwitchOverGameData 视为重复(msext first_Time 等价) + /// SwitchOverGameData 节流:2 秒内连续触发视为重复(msext first_Time 等价) private var lastSwitchAt: Date? + /// OpenurlTitleData 节流:3 秒内连续触发视为重复(契约 §3.1 [15] first_Time 等价) + private var lastOverlayAt: Date? /// 当前可见 WebContainer(大厅)— 用于 push 子游戏 public weak var navigationController: UINavigationController? @@ -80,4 +100,39 @@ public final class AppCoordinator { userInfo: ["data": data] ) } + + /// 大厅 / 子游戏触发 OpenurlTitleData。节流命中或栈深已满(3 层)时不触发。 + /// 返回 true 表示 push 成功,false 表示被忽略。 + @discardableResult + public func showOverlay(_ request: OverlayRequest) -> Bool { + // 节流:3s 内重复调用直接吞(msext first_Time 等价) + if let last = lastOverlayAt, Date().timeIntervalSince(last) < 3 { + return false + } + // 栈深约束:最多 Lobby + SubGame + Overlay = 3 层 + guard let nav = navigationController, + nav.viewControllers.count < 3 + else { + return false + } + lastOverlayAt = Date() + + let overlay = OverlayViewController(request: request) + nav.pushViewController(overlay, animated: true) + return true + } + + /// 弹层 pop 回父层(大厅 / 子游戏)。 + /// - data == nil:`settings.finishweb()` 路径,仅 pop,不发 backgameDatatwo + /// - data != nil:`settings.backgameData(data)` 路径,pop + 发 .subGameDidReturn 通知 + public func popOverlay(returningData data: String?) { + navigationController?.popViewController(animated: true) + if let data { + NotificationCenter.default.post( + name: .subGameDidReturn, + object: nil, + userInfo: ["data": data] + ) + } + } } diff --git a/ylgamehall/Source/WebView/OverlayViewController.swift b/ylgamehall/Source/WebView/OverlayViewController.swift new file mode 100644 index 0000000..9da731f --- /dev/null +++ b/ylgamehall/Source/WebView/OverlayViewController.swift @@ -0,0 +1,201 @@ +// +// OverlayViewController.swift +// ylgamehall +// +// 弹层(msext threeView 等价):WKWebView 加载外部 HTTP 页面, +// H5 通过 `window.settings.{browser,finishweb,backgameData}` 三接口与原生交互。 +// +// 契约:docs/H5-Native-Contract.md §3.4 弹层桥 +// Design:§3.4.2 / §2.4.3 栈深 ≤ 3 +// +// 与 msext threeView.m 的差异: +// - msext 用 UIWebView + JSExport (Bridgetwo);新外壳用 WKWebView + WKScriptMessageHandler, +// 在 .atDocumentEnd 注入 `window.settings = {...}` polyfill 桥到 messageHandlers +// - msext orientation=1 时 CGAffineTransformMakeRotation(±π/2) 旋转 webView; +// 本项目 landscape-locked,orientation 字段保留但不主动旋转(如有 H5 反馈需要再补) +// - msext 用全局 sharedHTTPCookieStorage;本项目用 WKWebsiteDataStore.nonPersistent() +// 隔离弹层 Cookie / 缓存,避免污染主业务态(Design §3.4.2 风险条目) +// + +import UIKit +import WebKit + +public final class OverlayViewController: UIViewController { + + // MARK: - 入参 + + private let request: OverlayRequest + + // MARK: - UI + + private var webView: WKWebView! + + /// WKScriptMessageHandler 名字(与 H5 端 polyfill 内 `webkit.messageHandlers.` 一致)。 + /// 弹层 WKWebView 与大厅 WVJB 在不同 channel,可以与 WVJB 同名 handler 共存不冲突。 + /// fileprivate 让同文件下的 ScriptMessageProxy 可用 `OverlayViewController.MessageName(rawValue:)` + fileprivate enum MessageName: String, CaseIterable { + case browser + case finishweb + case backgameData + } + + // MARK: - Init + + public init(request: OverlayRequest) { + self.request = request + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("OverlayViewController requires OverlayRequest") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + title = request.title + + setupWebView() + loadRequest() + } + + public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape } + public override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .landscapeRight } + public override var prefersStatusBarHidden: Bool { false } + + // MARK: - Setup + + private func setupWebView() { + let config = WKWebViewConfiguration() + // 关键:弹层 Cookie / 缓存隔离,第三方外链不污染大厅 / 子游戏的 H5 会话 + config.websiteDataStore = .nonPersistent() + config.defaultWebpagePreferences.allowsContentJavaScript = true + config.preferences.javaScriptCanOpenWindowsAutomatically = false + config.allowsInlineMediaPlayback = true + + // .atDocumentEnd 注入 polyfill:与 msext threeView.webViewDidFinishLoad 等价时机 + // 用 documentEnd 而不是 documentStart,是为了让外链页自带的 JS 框架先初始化, + // 再覆盖 / 创建 window.settings — msext 在 didFinishLoad 才注入,行为等价。 + let polyfill = MessageName.allCases.map { name in + switch name { + case .browser: + return "browser:function(u){webkit.messageHandlers.browser.postMessage(String(u||''));}" + case .finishweb: + return "finishweb:function(){webkit.messageHandlers.finishweb.postMessage('');}" + case .backgameData: + return "backgameData:function(d){webkit.messageHandlers.backgameData.postMessage(String(d==null?'':d));}" + } + }.joined(separator: ",") + let source = "window.settings=window.settings||{};Object.assign(window.settings,{\(polyfill)});" + let userScript = WKUserScript( + source: source, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + ) + config.userContentController.addUserScript(userScript) + + // 3 个 MessageHandler 名字与 polyfill 内的 messageHandlers. 一致 + for name in MessageName.allCases { + config.userContentController.add( + ScriptMessageProxy(target: self), + name: name.rawValue + ) + } + + webView = WKWebView(frame: .zero, configuration: config) + webView.scrollView.bounces = false + webView.scrollView.contentInsetAdjustmentBehavior = .never + webView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(webView) + + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func loadRequest() { + guard let url = URL(string: request.url) else { + showFatalAlert(message: "URL 无效:\(request.url)") + return + } + webView.load(URLRequest(url: url)) + } + + // MARK: - Message handling + + fileprivate func handleMessage(_ name: MessageName, body: Any) { + switch name { + case .browser: + // 系统 Safari 打开(msext threeView.browser: 等价) + let raw = (body as? String) ?? "" + guard !raw.isEmpty, + let target = URL(string: raw) + ?? raw.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + .flatMap(URL.init(string:)) + else { return } + Task { @MainActor in + _ = await UIApplication.shared.open(target) + } + + case .finishweb: + // 仅 pop,不带数据(settings.finishweb()) + AppCoordinator.shared.popOverlay(returningData: nil) + + case .backgameData: + // pop + 发 .subGameDidReturn,父层(大厅 / 子游戏)观察后回调 H5 getWebdata + let payload = serialize(body) + AppCoordinator.shared.popOverlay(returningData: payload) + } + } + + /// 兼容 string / dict / array:dict/array 走 JSON 序列化透传给父层 H5 + private func serialize(_ body: Any) -> String { + if let s = body as? String { return s } + if JSONSerialization.isValidJSONObject(body), + let bytes = try? JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]), + let s = String(data: bytes, encoding: .utf8) { + return s + } + return "" + } + + private func showFatalAlert(message: String) { + let alert = UIAlertController( + title: "加载失败", + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "返回", style: .default) { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + }) + present(alert, animated: true) + } +} + +// MARK: - WKScriptMessageHandler proxy +// +// WKScriptMessageHandler 协议默认 retain 持有者,会跟 WebView 互相强引用导致 VC 不释放。 +// 用一个 weak 持有 target 的 proxy 切断循环引用 — msext 时代手动 MRC release 等价做法。 + +private final class ScriptMessageProxy: NSObject, WKScriptMessageHandler { + private weak var target: OverlayViewController? + + init(target: OverlayViewController) { + self.target = target + super.init() + } + + func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + guard let target, + let name = OverlayViewController.MessageName(rawValue: message.name) + else { return } + target.handleMessage(name, body: message.body) + } +} diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift index 5b3f79b..be82c43 100644 --- a/ylgamehall/Source/WebView/SubGameViewController.swift +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -30,6 +30,10 @@ public final class SubGameViewController: UIViewController { private let shakeHandler = ShakeHandler() + // MARK: - .subGameDidReturn 观察者(overlay pop 回子游戏时反向 callback getWebdata) + + private var subGameReturnObserver: NSObjectProtocol? + // MARK: - Init public init(request: SubGameRequest) { @@ -239,6 +243,19 @@ public final class SubGameViewController: UIViewController { AppLifecycleObserver.shared.onForeground = { bridge.call("appservice", data: .string("2"), callback: nil) } + + // overlay(Phase 7)pop 回子游戏时反向 callback getWebdata。挂在子游戏栈顶 + // 期间生效;overlay push 上来时 viewWillDisappear → teardown 自动解绑,避免双发。 + subGameReturnObserver = NotificationCenter.default.addObserver( + forName: .subGameDidReturn, + object: nil, + queue: .main + ) { note in + let data = (note.userInfo?["data"] as? String) ?? "" + MainActor.assumeIsolated { + bridge.call("getWebdata", data: .string(data), callback: nil) + } + } } private func teardownExternalSubscriptions() { @@ -246,6 +263,11 @@ public final class SubGameViewController: UIViewController { NetworkMonitor.shared.onChange = nil AppLifecycleObserver.shared.onBackground = nil AppLifecycleObserver.shared.onForeground = nil + + if let token = subGameReturnObserver { + NotificationCenter.default.removeObserver(token) + subGameReturnObserver = nil + } } // MARK: - Error alert