Phase 7 完整实现:OpenurlTitleData 弹层(OverlayViewController + 3 个 settings)

- AppCoordinator 扩 OverlayRequest + lastOverlayAt(3s 节流 / 栈深 < 3 守门) +
  showOverlay / popOverlay(data==nil 仅 pop 即 finishweb 路径;
  data!=nil 同时发 .subGameDidReturn 即 backgameData 路径)
- 新增 Source/WebView/OverlayViewController.swift:独立 WKWebView,
  WKWebViewConfiguration.websiteDataStore = .nonPersistent() 隔离 Cookie / 缓存;
  .atDocumentEnd 注入 polyfill:window.settings = {browser/finishweb/backgameData}
  → webkit.messageHandlers.<name>(与 WVJB 不同 channel 可同名共存);
  ScriptMessageProxy weak target 切断 retain cycle
- OpenurlTitleDataHandler 升级为真实 push:解 url / "title "(末尾空格契约)/
  data / orientation → AppCoordinator.showOverlay,cb 字面始终回 "OpenurlTitleData"
- SubGameViewController.setupExternalSubscriptions 也订阅 .subGameDidReturn
  (overlay 可从子游戏 push,pop 回子游戏时需要 callback 子游戏 H5 的 getWebdata;
  与 viewWillAppear/Disappear 生命周期对齐,栈顶才挂钩避免双发)
- orientation 字段保留契约但 landscape-only app 暂不实施旋转(msext 旋转 transform
  在我们项目里无意义,记注释,如有 H5 反馈再补)
- Plan §5.7.1-7.7 / §8 进度已勾选

至此 SwitchOverGameData → push 子游戏 / OpenurlTitleData → push 弹层 / backgameData ↔
finishweb → pop + getWebdata 反向闭环全部接通。栈深永远 ≤ 3。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 21:30:03 +08:00
co-authored by Claude Opus 4.7
parent 5b67e414b3
commit 0427bb9542
5 changed files with 332 additions and 23 deletions
+23 -12
View File
@@ -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 可从子游戏 pushpop 回子游戏时需要 callback 子游戏 H5 的 getWebdata
与大厅 / 子游戏的 viewWillAppear/Disappear 生命周期对齐,栈顶才挂钩避免双发)
#### 验收
@@ -942,11 +952,12 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- [x] 6.7 SubGameDownloaderactorURLSession + 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 个 WKScriptMessageHandlerScriptMessageProxy 切断 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
@@ -2,14 +2,17 @@
// OpenurlTitleDataHandler.swift
// ylgamehall
//
// H5 Native handler stubOpenurlTitleData WebView threeView
// H5 Native handlerOpenurlTitleData 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 {
// 15OpenurlTitleData
// 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"))
}
}
@@ -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]
)
}
/// / OpenurlTitleData3
/// 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]
)
}
}
}
@@ -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-lockedorientation 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.<name>`
/// 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.<name>
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:
// popsettings.finishweb()
AppCoordinator.shared.popOverlay(returningData: nil)
case .backgameData:
// pop + .subGameDidReturn / H5 getWebdata
let payload = serialize(body)
AppCoordinator.shared.popOverlay(returningData: payload)
}
}
/// string / dict / arraydict/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)
}
}
@@ -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)
}
// overlayPhase 7pop 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