diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index 94961c3..41f4616 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -165,8 +165,9 @@ public final class WebContainerViewController: UIViewController { // MARK: - Boot pipeline private func runBootPipeline() async { - // 重新进入 pipeline 时(重试路径):清除错误态 + 重置 splash + // 重新进入 pipeline 时(重试路径):清除所有错误态监听 + 重置 splash stopBootRetryWaiting() + stopWaitingForUserReturnFromSettings() splash.hideError() splash.isHidden = false splash.update(text: "拼命启动中...", progress: nil) @@ -180,53 +181,104 @@ public final class WebContainerViewController: UIViewController { // IPA 升级 modal alert(业务流程信号) showIPAUpgradeAlert(downloadURL: dl) } catch { - // 网络 / 本地资源 / 解析等可重试错误 → 在 splash 上展示错误态 - // (不弹 modal、不暴露原始 NSError),并启动 NWPathMonitor 静默自动重试 - presentBootError(error) + // 网络 / 权限拒 / 本地资源等可重试错误 → 在 splash 上展示错误态。 + // 异步 snapshot NWPath 区分"真断网"和"权限被拒",文案和按钮语义按 kind 精确分流。 + let path = await Self.currentPathSnapshot() + let kind = Self.classifyBootError(error, path: path) + presentBootError(error, kind: kind) } } - /// 在 splash 上展示错误态(不弹 modal alert)。同时启动 NWPathMonitor 静默监听网络 - /// 恢复事件,自动触发一次重试。raw error 仍 print 到 Xcode console 便于开发期排查。 - /// - /// 用户点"重试"会直接重跑 runBootPipeline → RemoteConfigClient 重新发起 URLSession - /// 请求。**这正是 Apple 推荐的「让网络请求自然触发权限弹窗」模式**: - /// - 首次启动若系统网络权限弹窗未响应/被拒,重新发请求会再次触发系统对权限状态 - /// 的评估,可能再次弹窗(视 iOS 行为,多数情况下未响应的弹窗会复弹) - /// - 用户在 iOS 隐私管理里改了权限后回 app,点重试就能成功 - /// - NWPathMonitor 也在静默监听,无需用户点重试,切到正常网络后自动重试 - private func presentBootError(_ error: any Error) { - let kind = Self.classifyBootError(error) + /// 在 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 .networkOffline: + 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 = "重试" } - splash.onRetry = { [weak self] in - Task { @MainActor in await self?.runBootPipeline() } + 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) - // 静默后台监听网络恢复,从无网 → 有网时自动重试一次 - // (行业主流体验:用户切到 Wi-Fi 后无需手动操作即自动进入大厅) - 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 错误态期间存活;进入大厅或用户主动重试时销毁。 @@ -468,18 +520,29 @@ public final class WebContainerViewController: UIViewController { present(alert, animated: true) } - /// 启动期错误的分类。剥 RemoteConfigError.allRetriesFailed 找到 underlying NSURLError, - /// 按 NSURLErrorDomain code 映射到友好类别;非 NSURLError 视作本地资源问题。 - /// 给 `presentBootError` 决定 splash 错误态的标题/正文/按钮组合用。 + /// 启动期错误的精确分类。先看 NWPath.unsatisfiedReason 区分"真断网"和"权限被拒" + /// (iOS 14+ 两类都报 NSURLError -1009,但 path 里有 wifiDenied / cellularDenied + /// 标记),fallback 看 NSURLError code。 + /// 给 `presentBootError` 决定 splash 错误态的标题/正文/按钮语义用。 private enum BootErrorKind { - case networkOffline + 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) -> BootErrorKind { + private static func classifyBootError(_ error: Error, path: NWPath?) -> BootErrorKind { // 剥洋葱:RemoteConfigError.allRetriesFailed(underlying:) → 内层 var actual: any Error = error if case let RemoteConfigError.allRetriesFailed(inner) = error { @@ -490,9 +553,24 @@ public final class WebContainerViewController: UIViewController { // 非网络错误(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 .networkOffline + return .noNetworkAccess case NSURLErrorTimedOut: return .networkTimeout case NSURLErrorCannotFindHost, @@ -507,6 +585,24 @@ public final class WebContainerViewController: UIViewController { return .networkCannotReach } } + + /// 同步快照当前 NWPath:start 一个临时 NWPathMonitor 等首次 pathUpdateHandler 触发。 + /// 用于在错误态 UI 展示前判断"是真没网还是权限被拒"。 + private static func currentPathSnapshot() async -> NWPath { + await withCheckedContinuation { (cont: CheckedContinuation) 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)) + } + } } // MARK: - WKNavigationDelegate @@ -612,15 +708,19 @@ private final class SplashOverlay: UIView { } /// 显示错误态。隐藏 imageView / label / progress,背景改为浅色系统背景, - /// 展示 title + message + 单个"重试"按钮(点击触发 onRetry)。 + /// 展示 title + message + 单个按钮(点击触发 onRetry)。 + /// 按钮文字由调用方按错误 kind 决定("重试" / "前往设置"),同一个按钮承担双语义。 /// 启动图是浅色 + 复杂图案(不适合在上面叠文字),错误态下改纯净浅背景对比清晰。 - func showError(title: String, message: String) { + func showError(title: String, message: String, actionTitle: String = "重试") { imageView.isHidden = true label.isHidden = true progressView.isHidden = true backgroundColor = .systemBackground errorTitleLabel.text = title errorMessageLabel.text = message + var cfg = retryButton.configuration + cfg?.title = actionTitle + retryButton.configuration = cfg errorContainer.isHidden = false }