Files
youle_app_ios_v2/ylgamehall/Source/WebView/WebContainerViewController.swift
T
joywayer 5d4bdc2e29 4.B 分享按页面 mode 拆分:大厅直发微信 3 分支 / 子游戏 SharePanel
【契约影响】docs/H5-Native-Contract.md §3.1[2]

原 msext 把 friendsSharetypeUrlToptitleDescript 同名 handler 在两个 VC
各注册一份且**行为完全不同**:

- NewRootVC.m(大厅):直发微信,3 type 分支
    type==1 sendLinkURL(sharelogo 缩略图)
    type==2 sendImageData(截图 40% 缩放为 thumb)
    其他    NSData fromURL → sendImageData(公众号二维码场景)
- gameController.m(子游戏,活路径,早 return):
    sharefriend==1 SharePanel 三选一
    其他           WechatShareManager 朋友圈兜底(2 分支:截图/链接)

先前新外壳两个 VC 都注册同一份 handler 走"子游戏"逻辑,导致大厅页:
1. 强行弹 SharePanel 多一步操作
2. 丢失"远端图"分支(公众号二维码被错当链接发)

按页面 mode 拆开:

- FriendsShareHandler 加 Mode { lobby, subGame } 参数
- ShareCenter 拆 dispatchLobby(直发微信)/ dispatchSubGame(SharePanel)
- WechatShare 加 shareLobby() 实现完整 3 type 分支:
    type==1 → shareLink + mediaTagName "游戏下载链接" + appIcon thumb
              (原 msext 用 sharelogo.png,按用户决定 fallback 到 app icon)
    type==2 → shareImage + mediaTagName "游戏截图分享" + 截图 0.4x thumb
              + messageExt = H5 description
    其他    → URLSession 下载 NSData 直发 + 远端图 0.4x thumb
              + mediaTagName/messageExt 固定字面"公众号二维码分享"
- WebContainerViewController 注册 .lobby;SubGameViewController .subGame

附带修 sharefriend 默认值:之前 ?? "1"(弹面板)与原 msext nil→[intValue]
==0→朋友圈微信不一致。改成 ?? "",由 ShareCenter default 分支兜底走
朋友圈微信,与原项目完全等价。
2026-06-24 08:19:21 +08:00

640 lines
29 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)
}
// 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 {
// 网络 / 权限拒 / 本地资源等可重试错误 → 在 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
)
// 运营杀手锏 #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)
}
// 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. 加载本地 H5allowingReadAccessTo 必须给 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 → 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 前后台切换(命名错位沿用历史)
AppLifecycleObserver.shared.onBackground = {
bridge.call("appservice", data: .string("1"), callback: nil)
}
AppLifecycleObserver.shared.onForeground = {
bridge.call("appservice", data: .string("2"), 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) {
let alert = UIAlertController(
title: "需要升级",
message: "检测到新版本,请前往下载安装。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
if let url = URL(string: downloadURL) {
UIApplication.shared.open(url)
}
})
present(alert, animated: true)
}
/// 启动期错误的精确分类。先看 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))
}
}
}
// 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()
})
}
}