761 lines
37 KiB
Swift
761 lines
37 KiB
Swift
//
|
||
// 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 仅 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, mode: .lobby) // §3.1 [2]大厅路径:直发微信,3 type 分支(对齐 msext NewRootVC.m)
|
||
|
||
// 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 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 时,短暂观察 NWPath;5s 内若变
|
||
// 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, .cellularDenied:
|
||
// 文案与 iOS Settings → 本应用 → 「无线数据」combined toggle 对齐:
|
||
// 该开关有"关闭 / WLAN / WLAN 与蜂窝数据"三档,是用户唯一可操作的
|
||
// 入口。NWPath 的 wifiDenied vs cellularDenied 内部区分保留在
|
||
// BootErrorKind 里(供日志排查),但用户文案不再区分——两种情况用户
|
||
// 都要走同一条路径(打开"无线数据"),精确区分反而让"文案对不上设置项"。
|
||
title = "未授权使用无线数据"
|
||
message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,授权后将自动重试。"
|
||
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)
|
||
|
||
// 运营杀手锏 #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)
|
||
}
|
||
|
||
// 过渡文案: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. 加载本地 H5(Phase 1.F:从 loadFileURL 迁到 AppSchemeHandler)
|
||
// URL:ylgame://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 → setup,viewWillDisappear → 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 个 monitor(writeAppDataFiles 可能已经启动了 NetworkMonitor /
|
||
// BatteryMonitor;AppLifecycleObserver 在 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/1332(AppDelegate 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 收到 getWebdata(msext 单发等价)
|
||
//
|
||
// 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:用户已经在 Safari,suspend 反而破坏 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
|
||
}
|
||
}
|
||
|
||
/// 同步快照当前 NWPath:start 一个临时 NWPathMonitor 等首次 pathUpdateHandler 触发。
|
||
/// 用于在错误态 UI 展示前判断"是真没网还是权限被拒"。
|
||
private static func currentPathSnapshot() async -> NWPath {
|
||
await withCheckedContinuation { (cont: CheckedContinuation<NWPath, Never>) in
|
||
let monitor = NWPathMonitor()
|
||
// resumed 标记避免 monitor 多次 update 触发重复 resume(Network 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()
|
||
})
|
||
}
|
||
}
|
||
|