diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index b3a3a3b..5f10e30 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -9,6 +9,7 @@ import UIKit import WebKit +import Network public final class WebContainerViewController: UIViewController { @@ -164,108 +165,165 @@ public final class WebContainerViewController: UIViewController { // MARK: - Boot pipeline private func runBootPipeline() async { - // 重新进入 pipeline 时(重试路径):重置 splash 并显示 + // 重新进入 pipeline 时(重试路径):清除错误态 + 重置 splash + stopBootRetryWaiting() + 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 { - // 走到这里说明:优雅降级也没救(多半是 ResourceUnzipper.ensureReady 失败 / - // writeAppDataFiles 文件系统失败 等本地资源问题,因为网络相关错误已经在 - // runBootPipelineSteps 内被 fallback 吞掉了)。仍按友好文案弹窗,不暴露原始 NSError。 - showRetryableAlert(error: error) + // 网络 / 本地资源 / 解析等可重试错误 → 在 splash 上展示错误态 + // (不弹 modal、不暴露原始 NSError),并启动 NWPathMonitor 静默自动重试 + presentBootError(error) } } + /// 在 splash 上展示错误态(不弹 modal alert)。同时启动 NWPathMonitor 静默监听网络 + /// 恢复事件,自动触发一次重试。raw error 仍 print 到 Xcode console 便于开发期排查。 + private func presentBootError(_ error: any Error) { + let kind = Self.classifyBootError(error) + print("[Boot] presentBootError kind=\(kind) underlying:", error) + + let title: String + let message: String + let showSettings: Bool + switch kind { + case .networkOffline: + title = "当前未联网" + message = "请检查 Wi-Fi 或蜂窝数据后重试。" + showSettings = true + case .networkTimeout: + title = "网络较慢" + message = "连接超时,请稍后重试。" + showSettings = false + case .networkCannotReach: + title = "暂时无法连接服务器" + message = "网络似乎不太通畅,请稍后重试,或前往系统设置检查网络权限。" + showSettings = true + case .localResourceMissing: + title = "资源加载失败" + message = "请尝试重启 app;若仍未恢复,请重新安装。" + showSettings = false + case .unknown: + title = "启动遇到问题" + message = "请稍后重试。" + showSettings = false + } + + splash.onRetry = { [weak self] in + Task { @MainActor in await self?.runBootPipeline() } + } + splash.onOpenSettings = { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + splash.showError(title: title, message: message, showSettings: showSettings) + + // 静默后台监听网络恢复,从无网 → 有网时自动重试一次 + // (行业主流体验:用户切到 Wi-Fi 后无需手动操作即自动进入大厅) + startBootRetryWaiting() + } + + /// 启动期 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-5. 拉远端配置 + 升级判定,整段优雅降级: - // - 网络 / 解析 / 下载失败 → 不抛错,用本地 LocalVersionReader 构造 fallback - // ResolvedVersion,跳过升级判定直接走本地 H5 - // - 业务流程信号(operationalMessage / ipaUpgradeRequired)正常抛出由外层弹窗 + // 2. 拉远端渠道配置(**强约束**:必须拿到正确远端 config 才进大厅) // - // 行业主流做法:远端 config 失败 ≠ 启动失败。本地 zip 在 ensureReady 之后已就绪, - // 没有远端 config 只是失去"能否升级判定",大厅 H5 本身完全可加载。这避免了用户 - // 在 iOS 14+ 网络权限弹窗未响应 / 测试环境域名不可达 / 临时断网等场景下被 - // "启动失败 + 原始 NSError" 卡死的差体验。 + // 关键决策:**不能**在远端 config 失败时走 fallback 直接进大厅。 + // 原因:远端 config 包含「本地 appVersion 是否落后于远端」的升级判定, + // 跳过 = 用户用旧版本不被强制升级 = 业务风险。 + // + // 失败处理 UX(行业主流:停在 splash 显示错误态 + 自动重试): + // - 抛出网络错误 → 外层 runBootPipeline catch → splash.showError + // - 同时启动 NWPathMonitor,网络从无变有时自动重试一次 + // - 用户也可手动点 splash 上的「重试」/「前往系统设置」按钮 + // - 永不弹 modal alert / 永不暴露原始 NSError splash.update(text: "拉取配置中...", progress: nil) let resolved: ResolvedVersion - let usedOfflineFallback: Bool + let outcome = try await RemoteConfigClient.shared.fetch() + switch outcome { + case .shortText(let msg): + // 运营杀手锏 #1:服务端响应被替换为短文本,前端弹窗永停 + throw BootError.operationalMessage(msg) - do { - 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 升级失败也走降级(继续用本地版本,不阻塞启动; - // LobbyZipUpgrader.onProgress 由 URLSession delegate 在后台队列调度, - // 必须显式 hop 到 MainActor 才能动 splash UI) - do { - _ = 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 - ) - } - } - ) - } catch { - print("[Boot] H5 zip 升级失败,继续用本地资源:", error) - } - - resolved = r - usedOfflineFallback = false - } - } catch let e as BootError { - // 业务流程信号继续抛(外层弹 operational / IPA 升级弹窗) - throw e - } catch { - // 远端 config 失败(多数是网络),优雅降级用本地版本继续启动。 - // raw error 打到 Xcode console 便于排查(含 NSURLErrorDomain code 等); - // 用户感知仅是 splash 多停一会儿 → 直接进入大厅,无致命弹窗。 - print("[Boot] 远端配置拉取失败,降级为离线模式继续启动:", error) - resolved = ResolvedVersion( - appVersion: LocalVersionReader.localAppVersion, - appDownload: nil, - gameVersion: LocalVersionReader.localGameVersion, - gameZip: nil, - showmessage: nil + 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 ) - usedOfflineFallback = true + + // 运营杀手锏 #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 等价时序) @@ -273,8 +331,7 @@ public final class WebContainerViewController: UIViewController { try writeAppDataFiles(resolved: resolved) // 7. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源) - // splash 文案区分在线/离线,让用户对状态有感知(不暴露技术细节) - splash.update(text: usedOfflineFallback ? "离线加载..." : "加载大厅...", progress: nil) + splash.update(text: "加载大厅...", progress: nil) bridgedWebView.webView.loadFileURL( SandboxPaths.lobbyIndex, allowingReadAccessTo: SandboxPaths.lobbyRoot @@ -415,67 +472,9 @@ public final class WebContainerViewController: UIViewController { present(alert, animated: true) } - /// 启动期可重试错误的友好弹窗。 - /// - /// **不暴露原始 NSError 给用户**(如 `Error Domain=NSURLErrorDomain Code=-1009 ...`)。 - /// 按错误分类(剥洋葱看 NSURLError 子类型)给出友好标题 + 正文 + 适配的按钮组合: - /// - 网络类(offline / timeout / cannotReach)→ 重试 + 去设置 + 稍后再说 - /// - 本地资源类(unzip / 写文件失败)→ 重试 + 稍后再说 - /// - 未知 → 通用文案 - /// - /// raw error 仍打到 Xcode console 便于开发期排查;用户看不到技术细节。 - /// - /// **重要**:runBootPipelineSteps 已对网络失败做了优雅降级,本方法实际只会在 - /// 本地资源问题时触发。但仍保留网络分类分支,以防未来某条 fallback 漏开 catch。 - private func showRetryableAlert(error: Error) { - let kind = Self.classifyBootError(error) - print("[Boot] showRetryableAlert kind=\(kind) underlying:", error) - - let title: String - let message: String - let showSettingsAction: Bool - switch kind { - case .networkOffline: - title = "网络未连接" - message = "请检查 Wi-Fi 或蜂窝数据后重试。" - showSettingsAction = true - case .networkTimeout: - title = "网络较慢" - message = "连接超时,请稍后重试。" - showSettingsAction = false - case .networkCannotReach: - title = "暂时无法连接服务器" - message = "网络似乎不太通畅,请稍后重试,或前往设置检查网络。" - showSettingsAction = true - case .localResourceMissing: - title = "资源加载失败" - message = "请尝试重启 app;若仍未恢复,请重新安装。" - showSettingsAction = false - case .unknown: - title = "启动遇到问题" - message = "请稍后重试。" - showSettingsAction = false - } - - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "重试", style: .default) { [weak self] _ in - Task { @MainActor in - await self?.runBootPipeline() - } - }) - if showSettingsAction { - alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - }) - } - alert.addAction(UIAlertAction(title: "稍后再说", style: .cancel)) - present(alert, animated: true) - } - /// 启动期错误的分类。剥 RemoteConfigError.allRetriesFailed 找到 underlying NSURLError, /// 按 NSURLErrorDomain code 映射到友好类别;非 NSURLError 视作本地资源问题。 + /// 给 `presentBootError` 决定 splash 错误态的标题/正文/按钮组合用。 private enum BootErrorKind { case networkOffline case networkTimeout @@ -539,6 +538,18 @@ private final class SplashOverlay: UIView { private let progressView = UIProgressView(progressViewStyle: .default) private let label = UILabel() + // 错误态 UI(默认隐藏;showError 时显示,覆盖在 label/progress 区域) + private let errorContainer = UIStackView() + private let errorTitleLabel = UILabel() + private let errorMessageLabel = UILabel() + private let retryButton = UIButton(type: .system) + private let settingsButton = UIButton(type: .system) + + /// 用户点重试按钮时调(启动期 splash 期间) + var onRetry: (() -> Void)? + /// 用户点去设置按钮时调 + var onOpenSettings: (() -> Void)? + init() { super.init(frame: .zero) backgroundColor = .black @@ -564,6 +575,8 @@ private final class SplashOverlay: UIView { progressView.isHidden = true addSubview(progressView) + setupErrorContainer() + NSLayoutConstraint.activate([ imageView.topAnchor.constraint(equalTo: topAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), @@ -577,7 +590,13 @@ private final class SplashOverlay: UIView { progressView.centerXAnchor.constraint(equalTo: centerXAnchor), progressView.bottomAnchor.constraint(equalTo: label.topAnchor, constant: -12), - progressView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45) + progressView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45), + + errorContainer.centerXAnchor.constraint(equalTo: centerXAnchor), + errorContainer.centerYAnchor.constraint(equalTo: centerYAnchor, constant: 60), + errorContainer.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24), + errorContainer.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -24), + errorContainer.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.7) ]) } @@ -594,4 +613,83 @@ private final class SplashOverlay: UIView { progressView.setProgress(0, animated: false) } } + + /// 显示错误态。隐藏 label / progress,展示 title + message + 重试 + 去设置(可选)。 + func showError(title: String, message: String, showSettings: Bool) { + label.isHidden = true + progressView.isHidden = true + errorTitleLabel.text = title + errorMessageLabel.text = message + settingsButton.isHidden = !showSettings + errorContainer.isHidden = false + } + + /// 关闭错误态,恢复 loading 文案展示(runBootPipeline 重新进入时调)。 + func hideError() { + errorContainer.isHidden = true + label.isHidden = false + } + + private func setupErrorContainer() { + errorTitleLabel.textColor = .white + errorTitleLabel.font = .systemFont(ofSize: 18, weight: .semibold) + errorTitleLabel.textAlignment = .center + errorTitleLabel.numberOfLines = 1 + + errorMessageLabel.textColor = UIColor.white.withAlphaComponent(0.85) + errorMessageLabel.font = .systemFont(ofSize: 14) + errorMessageLabel.textAlignment = .center + errorMessageLabel.numberOfLines = 0 + + configureFilledButton(retryButton, title: "重试") + configureOutlineButton(settingsButton, title: "前往系统设置") + + retryButton.addAction(UIAction { [weak self] _ in self?.onRetry?() }, for: .touchUpInside) + settingsButton.addAction(UIAction { [weak self] _ in self?.onOpenSettings?() }, for: .touchUpInside) + + let buttonStack = UIStackView(arrangedSubviews: [retryButton, settingsButton]) + buttonStack.axis = .horizontal + buttonStack.spacing = 12 + buttonStack.alignment = .center + buttonStack.distribution = .fillEqually + + errorContainer.axis = .vertical + errorContainer.alignment = .center + errorContainer.spacing = 12 + errorContainer.isHidden = true + errorContainer.translatesAutoresizingMaskIntoConstraints = false + errorContainer.addArrangedSubview(errorTitleLabel) + errorContainer.addArrangedSubview(errorMessageLabel) + errorContainer.addArrangedSubview(buttonStack) + addSubview(errorContainer) + + NSLayoutConstraint.activate([ + retryButton.heightAnchor.constraint(equalToConstant: 40), + retryButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96), + settingsButton.heightAnchor.constraint(equalToConstant: 40), + settingsButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 130) + ]) + } + + private func configureFilledButton(_ button: UIButton, title: String) { + var cfg = UIButton.Configuration.filled() + cfg.title = title + cfg.baseBackgroundColor = .systemBlue + cfg.baseForegroundColor = .white + cfg.cornerStyle = .medium + cfg.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20) + button.configuration = cfg + } + + private func configureOutlineButton(_ button: UIButton, title: String) { + var cfg = UIButton.Configuration.bordered() + cfg.title = title + cfg.baseForegroundColor = .white + cfg.cornerStyle = .medium + cfg.background.strokeColor = .white + cfg.background.strokeWidth = 1 + cfg.background.backgroundColor = .clear + cfg.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16) + button.configuration = cfg + } }