// // WebContainerViewController.swift // ylgamehall // // H5 大厅承载容器:BridgedWebView 嵌入 + 16:9 letterbox + Splash 覆盖层 // + 启动流水线(ensureReady → fetch → resolve → upgrade → loadFileURL)。 // 详见 docs/H5-Native-Implementation-Design.md §6.3.5。 // import UIKit import WebKit public final class WebContainerViewController: UIViewController { // MARK: - UI private let bridgedWebView = BridgedWebView() private let splash = SplashOverlay() // 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 Task { @MainActor in await runBootPipeline() } } public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .landscape } public override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { .landscapeRight } public override var prefersStatusBarHidden: Bool { false } // MARK: - 16:9 letterbox 布局 // // H5 设计分辨率 1280×720(16:9)。设备比例与 16:9 不匹配时用黑边补齐: // - 比 16:9 宽(如现代 iPhone 横屏 2.16:1)→ 左右黑边 // - 比 16:9 窄(如 iPad 4:3)→ 上下黑边 // // 约束策略: // required:aspect = 16:9,width ≤ view.width,height ≤ view.height // low: width = view.width,height = view.height(尽量撑满) // 居中: centerX / centerY // Auto Layout 会自动选择"短边贴边、长边居中"的解。 private func setupBridgedWebView() { bridgedWebView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(bridgedWebView) let aspect = bridgedWebView.widthAnchor.constraint( equalTo: bridgedWebView.heightAnchor, multiplier: 16.0 / 9.0 ) let widthMax = bridgedWebView.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor) let heightMax = bridgedWebView.heightAnchor.constraint(lessThanOrEqualTo: view.heightAnchor) let widthFill = bridgedWebView.widthAnchor.constraint(equalTo: view.widthAnchor) let heightFill = bridgedWebView.heightAnchor.constraint(equalTo: view.heightAnchor) widthFill.priority = .defaultLow heightFill.priority = .defaultLow NSLayoutConstraint.activate([ bridgedWebView.centerXAnchor.constraint(equalTo: view.centerXAnchor), bridgedWebView.centerYAnchor.constraint(equalTo: view.centerYAnchor), aspect, widthMax, heightMax, widthFill, heightFill ]) } 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 并显示 splash.isHidden = false splash.update(text: "拼命启动中...", progress: nil) do { try await runBootPipelineSteps() } catch BootError.operationalMessage(let msg) { showBlockingAlert(message: msg) } catch BootError.ipaUpgradeRequired(let dl) { showIPAUpgradeAlert(downloadURL: dl) } catch { showFatalAlert(error: error) } } private func runBootPipelineSteps() async throws { // 1. 首装解压(仅首次安装走真正的 unzip;之后 fileExists 短路 ≤ 1ms) try await ResourceUnzipper.shared.ensureReady() // 2. 拉远端渠道配置 splash.update(text: "拉取配置中...", progress: nil) 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 resolved = VersionResolver.resolve( config: cfg, agentId: bc.agent, channelId: bc.channel, marketId: bc.market, gameId: bc.gameId ) // 3. 运营杀手锏 #2:showmessage 非空 → 弹窗永停 if let msg = resolved.showmessage, !msg.isEmpty { throw BootError.operationalMessage(msg) } // 4. IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停 if resolved.appVersion > LocalVersionReader.localAppVersion, let dl = resolved.appDownload { throw BootError.ipaUpgradeRequired(downloadURL: dl) } // 5. H5 zip 升级(远端 gameVersion > 本地):进度条实时更新 // LobbyZipUpgrader 的 onProgress 由 URLSession delegate 在后台队列调度, // 必须显式 hop 到 MainActor 才能动 splash UI。 _ = try await LobbyZipUpgrader.shared.upgradeIfNeeded( resolved: resolved, onProgress: { [weak self] p in Task { @MainActor in self?.splash.update( text: String(format: "下载更新中 %d%%", Int(p * 100)), progress: p ) } } ) } // 6. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源) splash.update(text: "加载大厅...", progress: nil) bridgedWebView.webView.loadFileURL( SandboxPaths.lobbyIndex, allowingReadAccessTo: SandboxPaths.lobbyRoot ) } // 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) } private func showFatalAlert(error: Error) { let alert = UIAlertController( title: "启动失败", message: "\(error)", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "重试", style: .default) { [weak self] _ in Task { @MainActor in await self?.runBootPipeline() } }) present(alert, animated: true) } } // 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() }) } } // MARK: - SplashOverlay // // 与 LaunchScreen 同款启动图 + UIProgressView + 状态文字标签的复合 view。 // 单文件私有:仅 WebContainerViewController 使用;若 Phase 6 子游戏容器复用,再抽出。 private final class SplashOverlay: UIView { private let imageView = UIImageView() private let progressView = UIProgressView(progressViewStyle: .default) private let label = UILabel() init() { super.init(frame: .zero) backgroundColor = .black imageView.contentMode = .scaleAspectFill imageView.image = UIImage(named: "SplashImage") imageView.clipsToBounds = true imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) label.textColor = .white label.font = .systemFont(ofSize: 14, weight: .medium) label.textAlignment = .center label.numberOfLines = 1 label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) progressView.translatesAutoresizingMaskIntoConstraints = false progressView.isHidden = true addSubview(progressView) NSLayoutConstraint.activate([ imageView.topAnchor.constraint(equalTo: topAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), label.centerXAnchor.constraint(equalTo: centerXAnchor), label.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -32), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 24), label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -24), progressView.centerXAnchor.constraint(equalTo: centerXAnchor), progressView.bottomAnchor.constraint(equalTo: label.topAnchor, constant: -12), progressView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.45) ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("SplashOverlay does not support init(coder:)") } func update(text: String, progress: Double?) { label.text = text if let p = progress { progressView.isHidden = false progressView.setProgress(Float(p), animated: true) } else { progressView.isHidden = true progressView.setProgress(0, animated: false) } } }