diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index a027db5..b3a3a3b 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -175,7 +175,10 @@ public final class WebContainerViewController: UIViewController { } catch BootError.ipaUpgradeRequired(let dl) { showIPAUpgradeAlert(downloadURL: dl) } catch { - showFatalAlert(error: error) + // 走到这里说明:优雅降级也没救(多半是 ResourceUnzipper.ensureReady 失败 / + // writeAppDataFiles 文件系统失败 等本地资源问题,因为网络相关错误已经在 + // runBootPipelineSteps 内被 fallback 吞掉了)。仍按友好文案弹窗,不暴露原始 NSError。 + showRetryableAlert(error: error) } } @@ -183,52 +186,86 @@ public final class WebContainerViewController: UIViewController { // 1. 首装解压(仅首次安装走真正的 unzip;之后 fileExists 短路 ≤ 1ms) try await ResourceUnzipper.shared.ensureReady() - // 2. 拉远端渠道配置 + // 2-5. 拉远端配置 + 升级判定,整段优雅降级: + // - 网络 / 解析 / 下载失败 → 不抛错,用本地 LocalVersionReader 构造 fallback + // ResolvedVersion,跳过升级判定直接走本地 H5 + // - 业务流程信号(operationalMessage / ipaUpgradeRequired)正常抛出由外层弹窗 + // + // 行业主流做法:远端 config 失败 ≠ 启动失败。本地 zip 在 ensureReady 之后已就绪, + // 没有远端 config 只是失去"能否升级判定",大厅 H5 本身完全可加载。这避免了用户 + // 在 iOS 14+ 网络权限弹窗未响应 / 测试环境域名不可达 / 临时断网等场景下被 + // "启动失败 + 原始 NSError" 卡死的差体验。 splash.update(text: "拉取配置中...", progress: nil) - let outcome = try await RemoteConfigClient.shared.fetch() let resolved: ResolvedVersion - switch outcome { - case .shortText(let msg): - // 运营杀手锏 #1:运营把整段服务端响应替换为短文本,前端弹窗永停 - throw BootError.operationalMessage(msg) + let usedOfflineFallback: Bool - 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 - ) - - // 3. 运营杀手锏 #2:showmessage 非空 → 弹窗永停 - if let msg = r.showmessage, !msg.isEmpty { + do { + let outcome = try await RemoteConfigClient.shared.fetch() + switch outcome { + case .shortText(let msg): + // 运营杀手锏 #1:服务端响应被替换为短文本,前端弹窗永停 throw BootError.operationalMessage(msg) - } - // 4. IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停 - if r.appVersion > LocalVersionReader.localAppVersion, - let dl = r.appDownload { - throw BootError.ipaUpgradeRequired(downloadURL: dl) - } + 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 + ) - // 5. H5 zip 升级(远端 gameVersion > 本地):进度条实时更新 - // 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 - ) - } + // 运营杀手锏 #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 ) - resolved = r + usedOfflineFallback = true } // 6. 在 loadFileURL 之前写 4 个 app_*.js(与原 msext NewRootVC.initJSdata 等价时序) @@ -236,7 +273,8 @@ public final class WebContainerViewController: UIViewController { try writeAppDataFiles(resolved: resolved) // 7. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源) - splash.update(text: "加载大厅...", progress: nil) + // splash 文案区分在线/离线,让用户对状态有感知(不暴露技术细节) + splash.update(text: usedOfflineFallback ? "离线加载..." : "加载大厅...", progress: nil) bridgedWebView.webView.loadFileURL( SandboxPaths.lobbyIndex, allowingReadAccessTo: SandboxPaths.lobbyRoot @@ -377,19 +415,103 @@ public final class WebContainerViewController: UIViewController { present(alert, animated: true) } - private func showFatalAlert(error: Error) { - let alert = UIAlertController( - title: "启动失败", - message: "\(error)", - preferredStyle: .alert - ) + /// 启动期可重试错误的友好弹窗。 + /// + /// **不暴露原始 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 视作本地资源问题。 + private enum BootErrorKind { + case networkOffline + case networkTimeout + case networkCannotReach + case localResourceMissing + case unknown + } + + private static func classifyBootError(_ error: Error) -> 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 + } + switch ns.code { + case NSURLErrorNotConnectedToInternet: + return .networkOffline + case NSURLErrorTimedOut: + return .networkTimeout + case NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorNetworkConnectionLost, + NSURLErrorDNSLookupFailed, + NSURLErrorInternationalRoamingOff, + NSURLErrorCallIsActive, + NSURLErrorDataNotAllowed: + return .networkCannotReach + default: + return .networkCannotReach + } + } } // MARK: - WKNavigationDelegate