为未来 Cocos 子游戏 H5 build 适配铺路(file:// 下 Cocos XHR/fetch 受 null-origin 限制几乎必踩坑),同时保留大厅 + 子游戏跨页 localStorage 共享语义(用单虚拟 host `h5` 让所有 H5 same-origin)。 变更: - 新增 AppSchemeHandler(WKURLSchemeHandler 单例 + Range/MIME/异步 IO/ 取消语义;闭包只携 Sendable ObjectIdentifier,不捕获 task) - SandboxPaths 加 lobbyIndexAppURL / subGameIndexAppURL builders - BridgedWebView 注册 scheme handler(WKWebView init 前) - WebContainerViewController / SubGameViewController 的 loadFileURL → webView.load(URLRequest),OverlayViewController 不变 文档: - Plan 新增 Phase 1.F (1.18-1.21) + ADR-010 决策记录 + 进度勾选 - Design 新增 §7.6 包含 URL 结构 / 实现要点 / 等价性表 / Cocos 预检脚本 - Contract §0.2 / §4.1 / §10 验收清单同步切换说明(H5 可观察差异: location.protocol "file:" → "ylgame:",项目方已 grep 确认现网 H5 不依赖此字面) 存量影响:file:// → ylgame:// origin 切换时老用户 localStorage 一次性 清零,已与项目方确认业务可接受、不做迁移补偿。 BuildProject 通过。Plan 进度已勾选 1.18/1.19/1.20。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
413 lines
18 KiB
Swift
413 lines
18 KiB
Swift
//
|
||
// SubGameViewController.swift
|
||
// ylgamehall
|
||
//
|
||
// 子游戏 H5 承载容器:BridgedWebView 全屏铺满 + Splash + boot pipeline。
|
||
// 与 WebContainerViewController(大厅)共享同款 BridgedWebView + SplashOverlay 样式,
|
||
// 但 boot 流程更短(不拉远端配置、不做 IPA 升级、不做 lobby zip 升级),仅:
|
||
// 1) 确保子游戏 H5 已就绪(commit B: SubGameDownloader.ensureReady)
|
||
// 2) 写 app_*.js(containerRole = .subGame)
|
||
// 3) loadFileURL 子游戏 index.html
|
||
//
|
||
// 详见 docs/H5-Native-Implementation-Design.md §2.4.3 / §6.4。
|
||
//
|
||
|
||
import UIKit
|
||
import WebKit
|
||
|
||
public final class SubGameViewController: UIViewController {
|
||
|
||
// MARK: - 入参(从 SwitchOverGameData 解析)
|
||
|
||
private let request: SubGameRequest
|
||
|
||
// MARK: - 实际生效的子游戏目录名
|
||
//
|
||
// init 时从 SubGameDirectoryStore 同步读:首次 = request.gameDir,已升级 = 末尾追加
|
||
// 若干 "1" 的持久化值(msext gameController.m:1414 等价的 advance 链)。
|
||
// boot pipeline 内 SubGameDownloader.ensureReady 若触发升级(远端 game_version >
|
||
// 本地 version.xml),返回的 actualDirectory 会更新本属性。所有资源路径(loadFileURL /
|
||
// AppDataWriter / srcIsloop assets/wav)必须走它,而不是 request.gameDir。
|
||
|
||
private var effectiveGameDir: String
|
||
|
||
// MARK: - UI
|
||
|
||
private let bridgedWebView = BridgedWebView()
|
||
private let splash = SplashOverlay()
|
||
|
||
// MARK: - Handlers
|
||
|
||
private let shakeHandler = ShakeHandler()
|
||
|
||
// MARK: - .subGameDidReturn 观察者(overlay pop 回子游戏时反向 callback getWebdata)
|
||
|
||
private var subGameReturnObserver: NSObjectProtocol?
|
||
|
||
// MARK: - Init
|
||
|
||
public init(request: SubGameRequest) {
|
||
self.request = request
|
||
// 同步读持久化目录名,registerBridgeHandlers 的 closure 捕获 self 即可读到当前值
|
||
self.effectiveGameDir = SubGameDirectoryStore.current(forKey: request.gameDir)
|
||
super.init(nibName: nil, bundle: nil)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("SubGameViewController requires SubGameRequest")
|
||
}
|
||
|
||
// MARK: - Lifecycle
|
||
|
||
public override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.backgroundColor = .black
|
||
|
||
setupBridgedWebView()
|
||
setupSplash()
|
||
|
||
bridgedWebView.webView.navigationDelegate = self
|
||
registerBridgeHandlers()
|
||
|
||
Task { @MainActor in
|
||
await runBootPipeline()
|
||
}
|
||
}
|
||
|
||
/// 注册子游戏 handler。大部分与大厅共用同一注册器(无状态 enum register),
|
||
/// 但 SwitchOverGameData 不再注册(子游戏不能 push 更深一层,符合 §2.4.3 栈深 ≤ 2)。
|
||
/// backgameData 在 commit C 接入。
|
||
private func registerBridgeHandlers() {
|
||
let bridge = bridgedWebView.bridge
|
||
|
||
VibratorHandler.register(on: bridge)
|
||
ClipboardHandler.register(on: bridge)
|
||
shakeHandler.register(on: bridge)
|
||
VoicePlayingHandler.register(on: bridge)
|
||
DeviceInfoHandler.register(on: bridge)
|
||
BrowserHandler.register(on: bridge)
|
||
OpenSaomaHandler.register(on: bridge)
|
||
StartLocationHandler.register(on: bridge)
|
||
|
||
// assetsRoot 闭包 = 子游戏 H5 根目录(msext gameController.m:364 等价)。
|
||
// 用 closure 是因为 effectiveGameDir 在 boot pipeline 升级路径下会变化,
|
||
// 必须运行时读最新值,否则升级后 srcIsloop 仍指旧目录。
|
||
LocalAudioHandler.register(
|
||
on: bridge,
|
||
assetsRoot: { [weak self] in
|
||
guard let self else {
|
||
return SandboxPaths.caches
|
||
}
|
||
return SandboxPaths.subGameIndex(self.effectiveGameDir, self.request.gameStart)
|
||
.deletingLastPathComponent()
|
||
}
|
||
)
|
||
// 子游戏 isSubGame=true:录音上传成功后额外触发 recordSuccess 反向 callback
|
||
// (对齐 daoqi gameController.m:2310;大厅 NewRootVC 无此 callHandler)
|
||
RemoteAudioHandler.register(
|
||
on: bridge,
|
||
hostVC: { [weak self] in self },
|
||
isSubGame: true
|
||
)
|
||
|
||
AccreditLoginHandler.register(on: bridge)
|
||
FriendsShareHandler.register(on: bridge, mode: .subGame) // 子游戏路径:SharePanel 三选一 + 朋友圈兜底(对齐 msext gameController.m 活路径)
|
||
|
||
// OpenurlTitleData 在子游戏中也可能调(threeView 弹层 push 自子游戏页),保留 stub
|
||
OpenurlTitleDataHandler.register(on: bridge)
|
||
|
||
// 子游戏专属:backgameData(退出回大厅 + 反向 callback getWebdata)
|
||
BackGameDataHandler.register(on: bridge)
|
||
|
||
// 视频房间 3 件套 stub(业务暂未启用,仅维持桥契约不让 H5 报 "no handler")
|
||
// 未来接 Agora 时把 VideoRoomHandlers 内 3 个 stub 展开实现,注册点不变。
|
||
VideoRoomHandlers.register(on: bridge)
|
||
}
|
||
|
||
// MARK: - 摇一摇支持
|
||
|
||
public override var canBecomeFirstResponder: Bool { true }
|
||
|
||
public override func viewWillAppear(_ animated: Bool) {
|
||
super.viewWillAppear(animated)
|
||
setupExternalSubscriptions()
|
||
}
|
||
|
||
public override func viewDidAppear(_ animated: Bool) {
|
||
super.viewDidAppear(animated)
|
||
becomeFirstResponder()
|
||
}
|
||
|
||
public override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
teardownExternalSubscriptions()
|
||
}
|
||
|
||
public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||
shakeHandler.handleMotionEnded(motion)
|
||
}
|
||
|
||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape }
|
||
public override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .landscapeRight }
|
||
public override var prefersStatusBarHidden: Bool { false }
|
||
|
||
// MARK: - WebView 全屏布局(与大厅同款,等价 daoqi msext gameController.m
|
||
// 全屏 WKWebView 路径)
|
||
|
||
private func setupBridgedWebView() {
|
||
bridgedWebView.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(bridgedWebView)
|
||
|
||
NSLayoutConstraint.activate([
|
||
bridgedWebView.topAnchor.constraint(equalTo: view.topAnchor),
|
||
bridgedWebView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||
bridgedWebView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
bridgedWebView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||
])
|
||
}
|
||
|
||
private func setupSplash() {
|
||
splash.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(splash)
|
||
NSLayoutConstraint.activate([
|
||
splash.topAnchor.constraint(equalTo: view.topAnchor),
|
||
splash.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||
splash.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
splash.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||
])
|
||
splash.update(text: "加载子游戏...", progress: nil)
|
||
}
|
||
|
||
// MARK: - Boot pipeline
|
||
|
||
private func runBootPipeline() async {
|
||
splash.isHidden = false
|
||
splash.update(text: "加载子游戏...", progress: nil)
|
||
|
||
do {
|
||
try await runBootPipelineSteps()
|
||
} catch {
|
||
showFatalAlert(error: error)
|
||
}
|
||
}
|
||
|
||
private func runBootPipelineSteps() async throws {
|
||
// 0. H5 传过来的 SwitchOverGameData.gamedownloadurl **字段名是 url 但语义是 gameid**
|
||
// (msext gameController.m:544 注释明确写"游戏ID")。真实 zip 下载地址要按
|
||
// gameid 去启动期缓存的 RemoteConfig 里查 `game_zip` 字段;走 VersionResolver
|
||
// 单链 4 层 fallback(agent → game → channel → market)的同一套实现。
|
||
//
|
||
// 需要做两次 resolve:
|
||
// - lobbyResolved(gameId = bc.gameId)→ 给 AppDataWriter 算 app_appversion 审核标志
|
||
// 与 msext NewRootVC 算 result 后 initinfo 传给 gameController 等价
|
||
// - subGameResolved(gameId = H5 传的 gameid)→ 拿 game_version 和 game_zip URL
|
||
// 与 msext gameController.uplevel: 等价
|
||
let (lobbyResolved, subGameResolved) = try await resolveBoot()
|
||
let subGameZipURL = subGameResolved.gameZip ?? ""
|
||
|
||
// 1. 准备子游戏 zip(msext gameController.m:1239-1255 等价):
|
||
// - 目录不存在(首装):下载到 currentDir
|
||
// - 目录存在 + 远端 > 本地:advance + 下载到新目录
|
||
// - 远端 ≤ 本地:复用 currentDir
|
||
splash.update(text: "准备子游戏...", progress: nil)
|
||
let outcome = try await SubGameDownloader.shared.ensureReady(
|
||
directoryKey: request.gameDir,
|
||
gameStart: request.gameStart,
|
||
remoteVersion: subGameResolved.gameVersion,
|
||
remoteZipURL: subGameZipURL,
|
||
onProgress: { [weak self] p in
|
||
Task { @MainActor in
|
||
self?.splash.update(
|
||
text: String(format: "下载子游戏 %d%%", Int(p * 100)),
|
||
progress: p
|
||
)
|
||
}
|
||
}
|
||
)
|
||
|
||
// ensureReady 内可能 advance 了目录名,同步到本地状态。
|
||
// 此后所有资源路径(loadFileURL / AppDataWriter / srcIsloop closure)都走它。
|
||
effectiveGameDir = outcome.actualDirectory
|
||
|
||
// 2. 写 4 个 app_*.js(containerRole = .subGame)。BatteryMonitor / NetworkMonitor
|
||
// 单例已由大厅启动,此处仅复用其 currentXxx。
|
||
// resolvedVersion 必须传大厅视角的版本判定结果,让 app_appversion 审核标志
|
||
// 与大厅一致(msext result_state 传递路径等价)。
|
||
// containerRole.dir 传 effectiveGameDir,让 app_gamedir = msext gamefilepath
|
||
// (升级后的"xxx1"形式),与 msext app_data.js:2160 等价。
|
||
let writer = AppDataWriter(
|
||
bundleConfig: .shared,
|
||
resolvedVersion: lobbyResolved,
|
||
containerRole: .subGame(name: request.gameStart, dir: effectiveGameDir)
|
||
)
|
||
try writer.writeInitial()
|
||
try writer.writeBattery(BatteryMonitor.shared.currentLevel)
|
||
try writer.writeNetwork(NetworkMonitor.shared.currentCode)
|
||
|
||
// 3. 加载子游戏 H5(Phase 1.F:从 loadFileURL 迁到 AppSchemeHandler)
|
||
// URL:ylgame://h5/subgame/<dir>/<start>/index.html
|
||
// 与大厅同 host("h5")→ same-origin → 共享 localStorage。
|
||
// 跨目录引用自动落回 subGameRoot(<dir>),等价原 allowingReadAccessTo 语义。
|
||
splash.update(text: "进入子游戏...", progress: nil)
|
||
let indexURL = SandboxPaths.subGameIndexAppURL(effectiveGameDir, request.gameStart)
|
||
bridgedWebView.webView.load(URLRequest(url: indexURL))
|
||
}
|
||
|
||
/// 子游戏启动期两次 resolve:
|
||
/// - lobby 视角(gameId = bc.gameId)给 app_appversion 审核标志(msext result_state 等价)
|
||
/// - sub-game 视角(gameId = H5 传的 gameid)给 game_version + game_zip URL(msext gameController.uplevel: 等价)
|
||
private func resolveBoot() async throws -> (lobbyResolved: ResolvedVersion, subGameResolved: ResolvedVersion) {
|
||
guard let cfg = await RemoteConfigClient.shared.current() else {
|
||
throw SubGameBootError.remoteConfigUnavailable
|
||
}
|
||
let bc = BundleConfig.shared
|
||
let lobbyResolved = VersionResolver.resolve(
|
||
config: cfg,
|
||
agentId: bc.agent,
|
||
channelId: bc.channel,
|
||
marketId: bc.market,
|
||
gameId: bc.gameId
|
||
)
|
||
let subGameResolved = VersionResolver.resolve(
|
||
config: cfg,
|
||
agentId: bc.agent,
|
||
channelId: bc.channel,
|
||
marketId: bc.market,
|
||
gameId: request.downloadURL // ← H5 字段名 url,实为 gameid
|
||
)
|
||
// 远端必须给出 game_zip URL,否则 SubGameDownloader 首装路径无法落地。
|
||
// 已有本地缓存且远端不需要升级时(≤本地版本),URL 空也允许 → 让 ensureReady 走复用分支。
|
||
let zip = subGameResolved.gameZip ?? ""
|
||
let localVer = LocalVersionReader.subGameVersion(
|
||
dir: SubGameDirectoryStore.current(forKey: request.gameDir),
|
||
start: request.gameStart
|
||
)
|
||
if zip.isEmpty && subGameResolved.gameVersion > localVer {
|
||
throw SubGameBootError.zipURLNotFound(gameId: request.downloadURL)
|
||
}
|
||
return (lobbyResolved, subGameResolved)
|
||
}
|
||
|
||
enum SubGameBootError: Error, CustomStringConvertible {
|
||
case remoteConfigUnavailable
|
||
case zipURLNotFound(gameId: String)
|
||
|
||
var description: String {
|
||
switch self {
|
||
case .remoteConfigUnavailable:
|
||
return "远端配置未就绪,请返回大厅重试"
|
||
case .zipURLNotFound(let id):
|
||
return "未找到子游戏 \(id) 的下载地址"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 外部订阅生命周期(参 §2.4.2)
|
||
//
|
||
// 子游戏栈顶时挂钩 battery / network / appservice。大厅 push 子游戏后大厅
|
||
// viewWillDisappear 已 teardown,避免双发。子游戏 pop 时 teardown,大厅
|
||
// viewWillAppear 重新 setup。
|
||
//
|
||
// 与大厅一致:业务期变化不重写文件,evaluateJavaScript 重新赋值 window.app_*。
|
||
|
||
private func setupExternalSubscriptions() {
|
||
NetworkMonitor.shared.start()
|
||
BatteryMonitor.shared.start()
|
||
AppLifecycleObserver.shared.start()
|
||
// 仅子游戏挂电话状态监听(大厅不订阅,与 msext gameController 同款边界)
|
||
PhoneStateMonitor.shared.start()
|
||
|
||
let bridge = bridgedWebView.bridge
|
||
let webView = bridgedWebView.webView
|
||
|
||
BatteryMonitor.shared.onChange = { level in
|
||
let value = String(format: "%.2f", level)
|
||
Task { @MainActor in
|
||
_ = try? await webView.evaluateJavaScript("window.app_getbattery=\(value);")
|
||
}
|
||
bridge.call("getBattery", data: .string(value), callback: nil)
|
||
}
|
||
|
||
NetworkMonitor.shared.onChange = { code in
|
||
Task { @MainActor in
|
||
_ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);")
|
||
}
|
||
bridge.call("getnetwork", data: .string("\(code)"), callback: nil)
|
||
}
|
||
|
||
// §3.2 [4] appservice — 值错位沿用历史 msext WKWebView 路径:
|
||
// 真·进入后台 → "2",真·回到前台 → "1"。参 WebContainerViewController 同位置注释。
|
||
AppLifecycleObserver.shared.onBackground = {
|
||
bridge.call("appservice", data: .string("2"), callback: nil)
|
||
}
|
||
AppLifecycleObserver.shared.onForeground = {
|
||
bridge.call("appservice", data: .string("1"), callback: nil)
|
||
}
|
||
|
||
// 电话状态 — 契约 §3.1 [14]:来电 "2" / 挂断 "0"
|
||
PhoneStateMonitor.shared.onChange = { state in
|
||
bridge.call("phonestate", data: .string(state), callback: nil)
|
||
}
|
||
|
||
// overlay(Phase 7)pop 回子游戏时反向 callback getWebdata。**lifecycle-tied**:
|
||
// 子游戏栈顶时挂钩,overlay push / 子游戏自身 pop 时由 viewWillDisappear 解绑。
|
||
// 这一行为与大厅 observer 的"永久挂钩"配合,复现 msext 双观察者行为:
|
||
// - 子游戏自身 backgameData pop 回大厅:sub-game observer 在 viewWillDisappear
|
||
// 已解绑 → 仅大厅 observer 触发 → 大厅 H5 收到 getWebdata(msext 单发等价)
|
||
// - 子游戏内 overlay backgameData pop:sub-game.viewWillAppear 时 observer 重挂
|
||
// → 大厅 + sub-game 双触发 → 大厅 H5 + sub-game H5 双发收到(msext 双观察者等价)
|
||
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() {
|
||
BatteryMonitor.shared.onChange = nil
|
||
NetworkMonitor.shared.onChange = nil
|
||
AppLifecycleObserver.shared.onBackground = nil
|
||
AppLifecycleObserver.shared.onForeground = nil
|
||
PhoneStateMonitor.shared.onChange = nil
|
||
PhoneStateMonitor.shared.stop()
|
||
|
||
if let token = subGameReturnObserver {
|
||
NotificationCenter.default.removeObserver(token)
|
||
subGameReturnObserver = nil
|
||
}
|
||
}
|
||
|
||
// MARK: - Error alert
|
||
|
||
private func showFatalAlert(error: Error) {
|
||
let alert = UIAlertController(
|
||
title: "加载失败",
|
||
message: "\(error)",
|
||
preferredStyle: .alert
|
||
)
|
||
alert.addAction(UIAlertAction(title: "返回大厅", style: .default) { [weak self] _ in
|
||
self?.navigationController?.popViewController(animated: true)
|
||
})
|
||
present(alert, animated: true)
|
||
}
|
||
}
|
||
|
||
// MARK: - WKNavigationDelegate
|
||
|
||
extension SubGameViewController: WKNavigationDelegate {
|
||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||
UIView.animate(withDuration: 0.3, animations: { [weak self] in
|
||
self?.splash.alpha = 0
|
||
}, completion: { [weak self] _ in
|
||
self?.splash.removeFromSuperview()
|
||
})
|
||
}
|
||
}
|
||
|