// // WebContainerViewController.swift // ylgamehall // // H5 大厅承载容器:BridgedWebView 全屏铺满 + Splash 覆盖层 // + 启动流水线(ensureReady → fetch → resolve → upgrade → loadFileURL)。 // 详见 docs/H5-Native-Implementation-Design.md §6.3.5。 // import UIKit import WebKit import Network public final class WebContainerViewController: UIViewController { // MARK: - UI private let bridgedWebView = BridgedWebView() private let splash = SplashOverlay() // MARK: - Handlers (有状态的 handler 注册器持有,无状态的走 enum 静态 register) private let shakeHandler = ShakeHandler() // MARK: - .subGameDidReturn 观察者(子游戏 backgameData pop 后回大厅,反向 callback getWebdata) // // 与 msext NewRootVC.m:593 等价:永久挂钩,viewDidLoad 期间一次性 install, // 不随 viewWillDisappear teardown。详见 setupExternalSubscriptions 注释。 private var subGameReturnObserver: NSObjectProtocol? private var subGameReturnObserverInstalled = false // MARK: - Boot pipeline 内部错误分类 // // 用 enum 区分"终态阻塞"与"系统级错误": // - shortText / ipaUpgrade 是运营杀手锏,弹窗后永停(无重试按钮) // - 其它(网络 / 解析 / 解压 / 下载失败)→ showFatalAlert,提供重试 private enum BootError: Error { /// 短文本响应 或 showmessage 非空:契约 §3.2 运营杀手锏 #1/#2 case operationalMessage(String) /// IPA 需要升级,弹窗 + Safari 外链 case ipaUpgradeRequired(downloadURL: String) } // MARK: - Lifecycle public override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black setupBridgedWebView() setupSplash() bridgedWebView.webView.navigationDelegate = self registerBridgeHandlers() Task { @MainActor in await runBootPipeline() } } /// 注册所有 H5 → Native handler 的单一聚合点。 /// Phase 1.15 仅 vibrator;Phase 2.A 扩展到 8 个 handler 模块。 private func registerBridgeHandlers() { let bridge = bridgedWebView.bridge // Phase 1.15 VibratorHandler.register(on: bridge) // §3.1 [10][11][12]扩展 // Phase 2.A 大厅简单 handler(无外部依赖) ClipboardHandler.register(on: bridge) // §3.1 [13][14] shakeHandler.register(on: bridge) // §3.1 [7][8][9] + §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 [20]Phase 5 完整实现,Phase 2 stub // Phase 3.A 本地音频 + 3.C/3.D stub // assetsRoot 闭包 = 大厅 H5 根目录(lobbyIndex 父目录),lobby 不做升级 // 路径不变,但 closure 形式保持与子游戏对称 LocalAudioHandler.register( on: bridge, assetsRoot: { SandboxPaths.lobbyIndex.deletingLastPathComponent() } ) // §3.1 [3]srcIsloop 完整实现 // §3.1 [4]prepareaudio 完整实现 / [5]mediaTypeAudio Phase 3.F stub // 大厅 isSubGame=false:录音上传成功后仅触发 getaudiourl,不触发 recordSuccess // (对齐 daoqi NewRootVC 无 recordSuccess callHandler 的行为) RemoteAudioHandler.register( on: bridge, hostVC: { [weak self] in self }, isSubGame: false ) // Phase 4.A 登录 + 分享 stub(Phase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级) AccreditLoginHandler.register(on: bridge) // §3.1 [1]Phase 4.E 完整微信 SDK FriendsShareHandler.register(on: bridge) // §3.1 [2]Phase 4.B 完整框架(QQ/抖音 已 4.C/4.D 落地) // Phase 6.4 / 7 子游戏 + 弹层入口 stub(让 H5 业务期调时不报 "no handler") SwitchOverGameHandler.register(on: bridge) // §3.1 [17]Phase 6 完整实现 OpenurlTitleDataHandler.register(on: bridge) // §3.1 [15]Phase 7 完整实现 } // MARK: - 摇一摇支持 // motionEnded 必须 first responder 才触发;canBecomeFirstResponder = true + // viewDidAppear becomeFirstResponder。详见 Design §8.7.1。 public override var canBecomeFirstResponder: Bool { true } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // 外部订阅生命周期:栈顶时挂钩,避免子游戏 push 上来后双发(§2.4.2) 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 同款) // // 原项目 NewRootVC.m:256 用 `CGRectMake(0, 0, DEVW, DEVH)` 直接全屏铺满 WKWebView, // 没有任何 letterbox。H5 viewport 自身处理设计稿(1280×720)→ 设备实际宽高的伸缩。 // // 早期 ylgamehall 设计文档(§6.3.5)曾用 16:9 letterbox(左右/上下补黑边),但实测 // 现代 iPhone 横屏比例约 19.5:9(比 16:9 更宽)→ 左右黑边过大体验差,且偏离 msext // 上线多年的视觉习惯。回到与原项目一致的全屏铺满路径。 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 { // 重新进入 pipeline 时(重试路径):清除所有错误态监听 + 重置 splash stopBootRetryWaiting() stopWaitingForUserReturnFromSettings() splash.hideError() splash.isHidden = false splash.update(text: "拼命启动中...", progress: nil) do { try await runBootPipelineSteps() } catch BootError.operationalMessage(let msg) { // 运营杀手锏:modal alert(业务流程信号,不是技术错误) showBlockingAlert(message: msg) } catch BootError.ipaUpgradeRequired(let dl) { // IPA 升级 modal alert(业务流程信号) showIPAUpgradeAlert(downloadURL: dl) } catch { // 网络 / 权限拒 / 本地资源等可重试错误 → 在 splash 上展示错误态。 // 异步 snapshot NWPath 区分"真断网"和"权限被拒",文案和按钮语义按 kind 精确分流。 let path = await Self.currentPathSnapshot() let kind = Self.classifyBootError(error, path: path) presentBootError(error, kind: kind) } } /// 在 splash 上展示错误态(不弹 modal alert)。raw error 仍 print 到 Xcode console /// 便于开发期排查。文案 + 按钮语义按 `kind` 精确切换: /// - 真断网 / 超时 / 服务器不可达 → "重试"(重跑 runBootPipeline) /// + NWPathMonitor 静默监听网络恢复自动重试一次 /// - 权限被拒(wifiDenied / cellularDenied)→ "前往设置"(跳 iOS 设置打开权限) /// + 监听 didBecomeActive,用户从设置回 app 时自动重试 /// - 本地资源失败 → "重试"(同上但通常重试无用,需重启 app) private func presentBootError(_ error: any Error, kind: BootErrorKind) { print("[Boot] presentBootError kind=\(kind) underlying:", error) let title: String let message: String let actionTitle: String switch kind { case .noNetworkAccess: title = "当前未联网" message = "请检查 Wi-Fi 或蜂窝数据后重试。" actionTitle = "重试" case .wifiDenied: title = "未授权使用无线局域网" message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,授权后将自动重试。" actionTitle = "前往设置" case .cellularDenied: title = "未授权使用蜂窝数据" message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,或连接 Wi-Fi 后重试。" actionTitle = "前往设置" case .networkTimeout: title = "网络较慢" message = "连接超时,请稍后重试。" actionTitle = "重试" case .networkCannotReach: title = "暂时无法连接服务器" message = "网络似乎不太通畅,请稍后重试。" actionTitle = "重试" case .localResourceMissing: title = "资源加载失败" message = "请尝试重启 app;若仍未恢复,请重新安装。" actionTitle = "重试" case .unknown: title = "启动遇到问题" message = "请稍后重试。" actionTitle = "重试" } if kind.isPermissionDenied { // 权限拒:按钮 → 跳系统设置;同时挂 didBecomeActive 监听,用户改完权限 // 切回 app 时自动重试(无需用户再点按钮) splash.onRetry = { [weak self] in guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) self?.startWaitingForUserReturnFromSettings() } } else { // 网络 / 本地资源类:按钮 → 直接重跑 pipeline splash.onRetry = { [weak self] in Task { @MainActor in await self?.runBootPipeline() } } // 同时静默后台监听网络恢复(仅网络类有意义;本地资源类失败时 path 通常 satisfied // 不会触发自动重试,无副作用) startBootRetryWaiting() } splash.showError(title: title, message: message, actionTitle: actionTitle) } /// 用户跳 iOS 设置后,监听 app didBecomeActive 自动重试 runBootPipeline。 /// 仅一次性:触发后自动取消监听。错误态期间用户多次进出设置也只触发一次重试。 private var settingsReturnObserver: NSObjectProtocol? private func startWaitingForUserReturnFromSettings() { stopWaitingForUserReturnFromSettings() settingsReturnObserver = NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in guard let self else { return } MainActor.assumeIsolated { self.stopWaitingForUserReturnFromSettings() print("[Boot] 用户从系统设置返回 app,自动重试启动流水线") Task { @MainActor in await self.runBootPipeline() } } } } private func stopWaitingForUserReturnFromSettings() { if let obs = settingsReturnObserver { NotificationCenter.default.removeObserver(obs) settingsReturnObserver = nil } } /// 启动期 NWPathMonitor。仅在 splash 错误态期间存活;进入大厅或用户主动重试时销毁。 /// 仅在"从无网变有网"时触发自动重试,避免初始 satisfied 状态导致死循环。 private var bootRetryPathMonitor: NWPathMonitor? private func startBootRetryWaiting() { stopBootRetryWaiting() let m = NWPathMonitor() let queue = DispatchQueue(label: "ylgamehall.boot-retry", qos: .utility) nonisolated(unsafe) var previousSatisfied: Bool? = nil m.pathUpdateHandler = { [weak self] path in let isSatisfied = (path.status == .satisfied) let prev = previousSatisfied previousSatisfied = isSatisfied // 仅从 false 转 true 触发自动重试;初始 nil(首次回调)不触发 guard prev == false, isSatisfied else { return } Task { @MainActor in guard let self else { return } print("[Boot] 网络恢复,自动重试启动流水线") await self.runBootPipeline() } } m.start(queue: queue) bootRetryPathMonitor = m } private func stopBootRetryWaiting() { bootRetryPathMonitor?.cancel() bootRetryPathMonitor = nil } private func runBootPipelineSteps() async throws { // 1. 首装解压(仅首次安装走真正的 unzip;之后 fileExists 短路 ≤ 1ms) try await ResourceUnzipper.shared.ensureReady() // 2. 拉远端渠道配置(**强约束**:必须拿到正确远端 config 才进大厅) // // 关键决策:**不能**在远端 config 失败时走 fallback 直接进大厅。 // 原因:远端 config 包含「本地 appVersion 是否落后于远端」的升级判定, // 跳过 = 用户用旧版本不被强制升级 = 业务风险。 // // 失败处理 UX(行业主流:停在 splash 显示错误态 + 自动重试): // - 抛出网络错误 → 外层 runBootPipeline catch → splash.showError // - 同时启动 NWPathMonitor,网络从无变有时自动重试一次 // - 用户也可手动点 splash 上的「重试」/「前往系统设置」按钮 // - 永不弹 modal alert / 永不暴露原始 NSError splash.update(text: "拉取配置中...", progress: nil) let resolved: ResolvedVersion let outcome = try await RemoteConfigClient.shared.fetch() switch outcome { case .shortText(let msg): // 运营杀手锏 #1:服务端响应被替换为短文本,前端弹窗永停 throw BootError.operationalMessage(msg) case .parsed(let cfg): let bc = BundleConfig.shared let r = VersionResolver.resolve( config: cfg, agentId: bc.agent, channelId: bc.channel, marketId: bc.market, gameId: bc.gameId ) // 运营杀手锏 #2:showmessage 非空 → 弹窗永停 if let msg = r.showmessage, !msg.isEmpty { throw BootError.operationalMessage(msg) } // IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停 if r.appVersion > LocalVersionReader.localAppVersion, let dl = r.appDownload { throw BootError.ipaUpgradeRequired(downloadURL: dl) } // H5 zip 升级失败仍致命(用户体验 ≠ 静默用旧 zip)—— 抛出由外层 splash.showError // LobbyZipUpgrader.onProgress 由 URLSession delegate 后台队列调度, // 必须显式 hop 到 MainActor 才能动 splash UI。 _ = try await LobbyZipUpgrader.shared.upgradeIfNeeded( resolved: r, onProgress: { [weak self] p in Task { @MainActor in self?.splash.update( text: String(format: "下载更新中 %d%%", Int(p * 100)), progress: p ) } } ) resolved = r } // 6. 在 loadFileURL 之前写 4 个 app_*.js(与原 msext NewRootVC.initJSdata 等价时序) // 详见 docs/H5-Native-Implementation-Design.md §7.5 try writeAppDataFiles(resolved: resolved) // 7. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源) splash.update(text: "加载大厅...", progress: nil) bridgedWebView.webView.loadFileURL( SandboxPaths.lobbyIndex, allowingReadAccessTo: SandboxPaths.lobbyRoot ) } /// 写 4 个 app_*.js 文件首次值(loadFileURL 之前调一次,monitor 已启动)。 /// 业务期变化的桥事件挂钩由 `setupExternalSubscriptions` / `teardownExternalSubscriptions` /// 配对管理(详见 Design §2.4.2 ExternalSubscriptions 生命周期),避免大厅 push /// 子游戏后两个 VC 都在监听导致 H5 收到双倍桥事件。 private func writeAppDataFiles(resolved: ResolvedVersion) throws { let writer = AppDataWriter( bundleConfig: .shared, resolvedVersion: resolved, containerRole: .lobby ) // 启动 2 个 currentXxx 类 monitor(幂等)以便读首次值; // AppLifecycleObserver 不需要 currentXxx,挪到 setup 启动 NetworkMonitor.shared.start() BatteryMonitor.shared.start() // 首次写入:4 个文件全部落盘 try writer.writeInitial() try writer.writeBattery(BatteryMonitor.shared.currentLevel) try writer.writeNetwork(NetworkMonitor.shared.currentCode) } // MARK: - 外部订阅生命周期(Phase 2.C / Design §2.4.2) // // 把 battery / network / appservice 三个外部事件源的订阅挂钩与 VC 生命周期 // 配对:viewWillAppear → setup,viewWillDisappear → teardown。 // 配套约束:未来 Phase 6 子游戏 push 上来时,大厅的 viewWillDisappear 会自动 // 触发 teardown,避免桥事件双发(参 §2.4.3 栈深 ≤ 2)。 // // 业务期变化时**不重写文件**,改为直接 evaluateJavaScript 重新赋值 window.app_* // 全局变量(H5 已加载完,