Files
youle_app_ios_v2/ylgamehall/Source/WebView/WebContainerViewController.swift
T

760 lines
36 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
/// 远端 RemoteConfig 缺 audio_domain / audio_bucket:后台配置错误,启动期致命
case audioConfigMissing
}
// 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 仅 vibratorPhase 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 20Phase 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 3srcIsloop 完整实现
// §3.1 4prepareaudio 完整实现 / 5mediaTypeAudio 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 登录 + 分享 stubPhase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级)
AccreditLoginHandler.register(on: bridge) // §3.1 1Phase 4.E 完整微信 SDK
FriendsShareHandler.register(on: bridge, mode: .lobby) // §3.1 [2]大厅路径:直发微信,3 type 分支(对齐 msext NewRootVC.m
// Phase 6.4 / 7 子游戏 + 弹层入口 stub(让 H5 业务期调时不报 "no handler"
SwitchOverGameHandler.register(on: bridge) // §3.1 17Phase 6 完整实现
OpenurlTitleDataHandler.register(on: bridge) // §3.1 15Phase 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 BootError.audioConfigMissing {
// 后台配置错误:远端 RemoteConfig 缺 audio_domain / audio_bucket。
// 用户/运营无法自助处理,与运营公告同款 modal 永停。
showBlockingAlert(message: "音频服务暂不可用(配置缺失),请联系客服。")
} catch {
// 网络 / 权限拒 / 本地资源等可重试错误 → 在 splash 上展示错误态。
// 异步 snapshot NWPath 区分"真断网"和"权限被拒",文案和按钮语义按 kind 精确分流。
let path = await Self.currentPathSnapshot()
let kind = Self.classifyBootError(error, path: path)
// 首次启动权限授予竞争:iOS 弹权限对话框时,pipeline 的第一个网络请求
// 已经在跑;如果用户没在请求 timeout 前点"允许",请求会 fail 且 NWPath
// 报 wifiDenied / cellularDenied —— 但用户可能"正准备点允许"。此时立
// 即弹"前往设置"会让用户困惑("我明明点了允许还弹")。
//
// 策略:只在 kind.isPermissionDenied 时,短暂观察 NWPath5s 内若变
// satisfied → 静默重跑 pipeline(等价用户点允许后我们自动重试);超时
// 或仍拒绝 → 走原逻辑弹"前往设置"。真拒绝的长尾场景多等 5s 可接受。
if kind.isPermissionDenied,
await Self.waitForNetworkPermissionResolution(timeout: 5.0) {
await runBootPipeline()
return
}
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
// 单一接口、单一 4 层 fallback:所有字段(版本 + 音频 + showmessage
// 共用 VersionResolver.resolve(...),统一从 agent → game → channel → market
// 倒序找第一个非空,整链都未声明的字段 fallback 到 RemoteConfig 顶层。
let r = VersionResolver.resolve(
config: cfg,
agentId: bc.agent,
channelId: bc.channel,
marketId: bc.market,
gameId: bc.gameId
)
print("[VersionResolver] resolved appVer=\(r.appVersion) gameVer=\(r.gameVersion) audioDomain=\(r.audioDomain ?? "<nil>") audioBucket=\(r.audioBucket ?? "<nil>")")
// 七牛 audio_domain / audio_bucket 缺失视为后台配置错误,启动期致命
// ——不允许使用空值上传录音。
guard let audioDomain = r.audioDomain, !audioDomain.isEmpty,
let audioBucket = r.audioBucket, !audioBucket.isEmpty
else {
throw BootError.audioConfigMissing
}
await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)
// 运营杀手锏 #2showmessage 非空 → 弹窗永停
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)
}
// 过渡文案:config 已拉到、进入 zip 升级前的"建连等首字节"静默阶段。
// 不补这一步,用户会看到"拉取配置中..."一直显示到 zip CDN 冷 DNS+TLS 握完
// 才跳到"下载更新中 1%",慢网络下感知像卡死。这里把 UI 状态机补齐到与
// 实际工作阶段对应。
splash.update(text: "校验版本中...", progress: nil)
// 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. 加载本地 H5Phase 1.F:从 loadFileURL 迁到 AppSchemeHandler
// URLylgame://h5/lobby/<gameStart>/index.html
// AppSchemeHandler 把 /lobby/<rest> 反向映射到 lobbyRoot/<rest> 读盘。
// 跨目录引用(H5 用 ../foo.js)自动落回 lobbyRoot,等价 file:// 的
// allowingReadAccessTo 语义。
splash.update(text: "加载大厅...", progress: nil)
bridgedWebView.webView.load(URLRequest(url: SandboxPaths.lobbyIndexAppURL))
}
/// 写 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 → setupviewWillDisappear → teardown。
// 配套约束:未来 Phase 6 子游戏 push 上来时,大厅的 viewWillDisappear 会自动
// 触发 teardown,避免桥事件双发(参 §2.4.3 栈深 ≤ 2)。
//
// 业务期变化时**不重写文件**,改为直接 evaluateJavaScript 重新赋值 window.app_*
// 全局变量(H5 已加载完,<script src> 不会再 fetch;重新赋值后 H5 业务期同步读
// app_xxx 永远拿到最新值)。同时触发 §3.2 反向 callback 推送给注册了 handler 的
// H5 业务。详见 docs/H5-Native-Implementation-Design.md §7.5.2 / §7.5.4。
private func setupExternalSubscriptions() {
// 幂等启动 3 个 monitorwriteAppDataFiles 可能已经启动了 NetworkMonitor /
// BatteryMonitorAppLifecycleObserver 在 setup 内首次启动)
NetworkMonitor.shared.start()
BatteryMonitor.shared.start()
AppLifecycleObserver.shared.start()
let bridge = bridgedWebView.bridge
let webView = bridgedWebView.webView
// §3.2 2 getBattery — UIDevice 电量变化
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)
}
// §3.2 3 getnetwork — NWPath 变化(1 无网 / 2 WiFi / 3 蜂窝)
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 — App 前后台切换(值错位沿用历史 msext WKWebView 路径)
// 真·进入后台 → "2",真·回到前台 → "1"。参 daoqi/msext NewRootVC.m:1773/1782、
// gameController.m:1323/1332AppDelegate post 的 NSNotification 名也是错位的,
// 净结果是 H5 收到的桥值与"自然语义"相反,必须照抄)。
AppLifecycleObserver.shared.onBackground = {
bridge.call("appservice", data: .string("2"), callback: nil)
}
AppLifecycleObserver.shared.onForeground = {
bridge.call("appservice", data: .string("1"), callback: nil)
}
// §3.2 12 getWebdata — 反向把 backgameData / 弹层 data 入参透传给大厅 H5。
//
// 与 msext NewRootVC.m:593 等价:observer 在 viewDidLoad 期间一次性挂钩,
// **永久 listen,不随 viewWillDisappear teardown**。这样:
//
// 1. 子游戏 backgameData 自己 pop 回大厅:
// BackGameDataHandler → AppCoordinator.popSubGame → popViewController
// sub-game.viewWillDisappear → sub-game 的 observer 解绑(sub-game 走 lifecycle-tied
// 再 post → 仅大厅 observer 触发 → 大厅 H5 收到 getWebdatamsext 单发等价)
//
// 2. 子游戏内弹层 backgameData pop(弹层 push 自 sub-game):
// OverlayViewController → AppCoordinator.popOverlay → popViewController
// overlay.viewWillDisappear → sub-game.viewWillAppear → sub-game observer 重新挂钩
// 再 post → 大厅 + sub-game observer 同时触发 → 大厅 H5 + sub-game H5 双发收到
// msext NewRootVC + gameController 双观察者等价行为)
//
// 用 installed 标志保证多次 viewWillAppear 不重复挂钩(避免重复触发)。
if !subGameReturnObserverInstalled {
subGameReturnObserverInstalled = true
subGameReturnObserver = NotificationCenter.default.addObserver(
forName: .subGameDidReturn,
object: nil,
queue: .main
) { note in
// queue=.main 保证 closure 在主线程;显式 hop 让 Swift 6 actor 推断通过
let data = (note.userInfo?["data"] as? String) ?? ""
MainActor.assumeIsolated {
bridge.call("getWebdata", data: .string(data), callback: nil)
}
}
}
}
private func teardownExternalSubscriptions() {
// 仅解绑 onChange / on{Background,Foreground} 回调,monitor 继续在
// 后台运行(保持 currentXxx 状态新鲜),下次 setup 时直接 resume。
// 单例 monitor 全局只有一个 closure 引用,子游戏 setup 时会覆盖;
// 大厅 viewWillDisappear 时 teardown 释放对当前 bridge/webView 的引用
// 避免双发。
//
// subGameReturnObserver 是永久挂钩(与 msext NewRootVC observer 等价),
// **不在此 teardown** —— 详见 setupExternalSubscriptions 注释。
BatteryMonitor.shared.onChange = nil
NetworkMonitor.shared.onChange = nil
AppLifecycleObserver.shared.onBackground = nil
AppLifecycleObserver.shared.onForeground = nil
}
// MARK: - Alerts (terminal / retry states)
private var appDisplayName: String {
(Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String)
?? (Bundle.main.infoDictionary?["CFBundleName"] as? String)
?? "提示"
}
private func showBlockingAlert(message: String) {
let alert = UIAlertController(
title: "\(appDisplayName) 提醒",
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "确定", style: .default))
present(alert, animated: true)
}
private func showIPAUpgradeAlert(downloadURL: String) {
// 行为目标:点「确定」直接弹系统安装窗 + App 自动切后台(用户回到桌面),
// 用户在桌面上点「安装」即可看到 App 图标下载进度,App 进程留在内存不退出。
//
// iOS 实际行为:调 itms-services 时系统弹安装确认窗,但本 App 仍处于 inactive
// 状态(弹窗是模态覆盖在 App 上),用户点「安装」后弹窗 dismiss、App 回到 active
// —— 系统**不会主动**切后台。要让用户回桌面,需要本 App 主动调私有 selector
// `suspend` 把自己切后台(等价于用户按 Home / 上滑)。
// 本项目企业签 / 渠道分发,不上架 AppStore,可以用私有 API。
//
// URL 形态:itms-services 直链 / .plist 直链 / 其它(Safari)—— 详见 makeInstallURL。
// Safari 路径不调 suspend:用户已经在 Safarisuspend 反而破坏 Safari 引导页体验。
let alert = UIAlertController(
title: "需要升级",
message: "检测到新版本,点击「确定」开始升级,系统会自动下载并安装。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
guard let url = Self.makeInstallURL(from: downloadURL) else { return }
UIApplication.shared.open(url)
if url.scheme?.lowercased() == "itms-services" {
// 0.3s 等系统接收 itms-services URL(确认弹窗 schedule 完成),
// 然后把 App 切后台。系统弹窗仍会覆盖在桌面上正常显示。
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
Self.suspendAppToHomeScreen()
}
}
})
present(alert, animated: true)
}
/// 把本 App 切到后台(用户看到桌面),App 进程留在内存。
/// 私有 selector `suspend` 等价于按 Home / 上滑回桌面的系统行为。
/// `UIControl.sendAction` 内部会处理 selector 不响应的情况,比 `perform()` 安全。
private static func suspendAppToHomeScreen() {
let sel = NSSelectorFromString("suspend")
UIControl().sendAction(sel, to: UIApplication.shared, for: nil)
}
/// 把远端 `appDownload` 规范化为 iOS 系统可直接接管的安装协议,避免跳 Safari 中转。
///
/// 三种 raw URL 形态:
/// - `itms-services://...` → 原样返回(iOS 直接弹安装窗)
/// - `https?://...*.plist`(企业签 manifest 直链)→ 自动包成
/// `itms-services://?action=download-manifest&url=<encoded>`iOS 直接弹安装窗
/// - 其它(网页 / 短链等)→ 原样跳转,由 Safari 接管。这种情形下后台需要把
/// URL 改成上面两种之一才能跳过 Safari 中转
private static func makeInstallURL(from raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
let lower = trimmed.lowercased()
if lower.hasPrefix("itms-services://") {
return URL(string: trimmed)
}
if (lower.hasPrefix("https://") || lower.hasPrefix("http://")),
lower.hasSuffix(".plist") {
let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? trimmed
return URL(string: "itms-services://?action=download-manifest&url=\(encoded)")
}
return URL(string: trimmed)
}
/// 启动期错误的精确分类。先看 NWPath.unsatisfiedReason 区分"真断网"和"权限被拒"
/// iOS 14+ 两类都报 NSURLError -1009,但 path 里有 wifiDenied / cellularDenied
/// 标记),fallback 看 NSURLError code。
/// 给 `presentBootError` 决定 splash 错误态的标题/正文/按钮语义用。
private enum BootErrorKind {
case noNetworkAccess // 真没网(飞行模式、无 Wi-Fi 无蜂窝)
case wifiDenied // 用户在 iOS 设置里禁了本 app 的 Wi-Fi 访问
case cellularDenied // 用户在 iOS 设置里禁了本 app 的蜂窝数据访问
case networkTimeout
case networkCannotReach
case localResourceMissing
case unknown
/// 该错误是否由"用户拒绝授权"导致(按钮应导向系统设置而非重试)
var isPermissionDenied: Bool {
switch self {
case .wifiDenied, .cellularDenied: return true
default: return false
}
}
}
private static func classifyBootError(_ error: Error, path: NWPath?) -> BootErrorKind {
// 剥洋葱:RemoteConfigError.allRetriesFailed(underlying:) → 内层
var actual: any Error = error
if case let RemoteConfigError.allRetriesFailed(inner) = error {
actual = inner
}
let ns = actual as NSError
guard ns.domain == NSURLErrorDomain else {
// 非网络错误(FileManager / Codable 等)视作本地资源问题
return .localResourceMissing
}
// 优先看 NWPath.unsatisfiedReason 区分权限拒/真断网(iOS 14.2+)。
// iOS 把这两类都映射成 NSURLError -1009,只有 NWPath 能给出"为什么不可用"。
if let path, path.status == .unsatisfied {
if #available(iOS 14.2, *) {
switch path.unsatisfiedReason {
case .wifiDenied: return .wifiDenied
case .cellularDenied: return .cellularDenied
case .notAvailable: return .noNetworkAccess
default: break
}
}
}
// Fallback:按 NSURLError code 分
switch ns.code {
case NSURLErrorNotConnectedToInternet:
return .noNetworkAccess
case NSURLErrorTimedOut:
return .networkTimeout
case NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorInternationalRoamingOff,
NSURLErrorCallIsActive,
NSURLErrorDataNotAllowed:
return .networkCannotReach
default:
return .networkCannotReach
}
}
/// 同步快照当前 NWPathstart 一个临时 NWPathMonitor 等首次 pathUpdateHandler 触发。
/// 用于在错误态 UI 展示前判断"是真没网还是权限被拒"。
private static func currentPathSnapshot() async -> NWPath {
await withCheckedContinuation { (cont: CheckedContinuation<NWPath, Never>) in
let monitor = NWPathMonitor()
// resumed 标记避免 monitor 多次 update 触发重复 resumeNetwork framework
// 在某些设备上启动期会回调多次)
nonisolated(unsafe) var resumed = false
monitor.pathUpdateHandler = { path in
guard !resumed else { return }
resumed = true
monitor.cancel()
cont.resume(returning: path)
}
monitor.start(queue: DispatchQueue.global(qos: .userInitiated))
}
}
/// 等首次启动权限对话框有决定 —— 最长 `timeout` 秒内观察 NWPath 是否变
/// `.satisfied`。用于消除"用户点允许太慢就弹前往设置"的误报体验。
/// 返回 true 表示已授权(可静默重试 pipeline);false 表示超时或明确拒绝
/// (应走原逻辑弹前往设置)。
private static func waitForNetworkPermissionResolution(timeout: TimeInterval) async -> Bool {
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
let monitor = NWPathMonitor()
nonisolated(unsafe) var resumed = false
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
guard !resumed else { return }
resumed = true
monitor.cancel()
cont.resume(returning: false)
}
monitor.pathUpdateHandler = { path in
guard !resumed else { return }
if path.status == .satisfied {
resumed = true
monitor.cancel()
cont.resume(returning: true)
}
}
monitor.start(queue: DispatchQueue.global(qos: .userInitiated))
}
}
}
// MARK: - WKNavigationDelegate
extension WebContainerViewController: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// H5 大厅已经渲染完毕,此时把 splash 从 0.3s 内淡出再 removeFromSuperview
// 避免黑屏闪烁也避免后续 superview 再持有这层不必要的视图。
UIView.animate(withDuration: 0.3, animations: { [weak self] in
self?.splash.alpha = 0
}, completion: { [weak self] _ in
self?.splash.removeFromSuperview()
})
}
}