From 0887088bb82981c48b55b40751bc994bb6ae73e2 Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 02:27:48 +0800 Subject: [PATCH] =?UTF-8?q?Phase=201.13=EF=BC=9ALobbyZipUpgrader=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=20H5=20zip=20=E5=8D=87=E7=BA=A7=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 远程配置链路(1.10-1.13)最后一环:把 VersionResolver 解出的 gameZip URL 下载 + 解压 + 原子 rename 到 lobbyRoot,触发条件是远端 gameVersion > 本地 version.xml /game/version@value。 - 新增 ylgamehall/Source/Resource/LobbyZipUpgrader.swift - public actor,单例 LobbyZipUpgrader.shared - upgradeIfNeeded(resolved:) async throws -> Outcome - .noop:远端版本 ≤ 本地,或 gameZip nil / 非法 URL,直接返回 - .upgraded(from:to:):完成升级,附前后版本号 - URLSession.download(from:) 异步下载(60s 请求 / 300s 资源超时), HTTP 状态码非 2xx 抛 .httpStatus - 解压到 caches/lobby-staging-{UUID}/ 隔离目录(不直接覆盖 lobbyRoot, msext "removeItem + ZipArchive overWrite:YES" 半途崩溃会留半残; 我们 staging 完整解压成功后才 rename) - 原子 rename:fm.removeItem(lobbyRoot) → fm.moveItem(staging, to: lobbyRoot);任一步失败抛 .stagingMoveFailed,调用方决定回退 - 类型化 UpgradeError 5 种:badGameZipURL / downloadFailed / httpStatus / unzipFailed / stagingMoveFailed - 实现照搬 msext NewRootVC.m:1789-1869 downFileFromServer + unzip + unzipDone 的语义,但用现代 async + 原子 rename 替代 ASIHTTPRequest + detachNewThreadSelector + ZipArchive overWrite:YES - RootViewController 烟雾测试串接 LobbyZipUpgrader: 调 upgradeIfNeeded → switch outcome → 升级后重新读 version.xml 验证 - 真机实测端到端链路: 第一次:本地 260 vs 远端 261 → 升级完成 261,耗时 2.007s (下载 11.5 MB HTTP zip + ZIPFoundation 解压 + 原子 rename) 重新读 version.xml = 261 ✓ lobbyIndex 仍存在 ✓ 第二次:本地 261 vs 远端 261 → .noop,耗时 0.001s(幂等验证) Phase 1 远程配置链路(1.10-1.13)完整闭环。下一步 Phase 1.14 WebContainer 把这条链路插到 viewDidLoad 之前 + 16:9 letterbox 加载 lobbyIndex,第一次让真实大厅 H5 渲染出来。 --- ylgamehall/RootViewController.swift | 22 ++++ .../Source/Resource/LobbyZipUpgrader.swift | 101 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 ylgamehall/Source/Resource/LobbyZipUpgrader.swift diff --git a/ylgamehall/RootViewController.swift b/ylgamehall/RootViewController.swift index 6f76861..c42f04c 100644 --- a/ylgamehall/RootViewController.swift +++ b/ylgamehall/RootViewController.swift @@ -128,6 +128,28 @@ final class RootViewController: UIViewController { 远端 gameVersion = \(resolved.gameVersion) → H5 zip 升级 = \(needZipUpgrade ? "需要" : "不需要") """) + + // Phase 1.13 烟雾测试:触发 LobbyZipUpgrader(如果需要升级) + let upgradeStart = Date() + do { + let outcome = try await LobbyZipUpgrader.shared.upgradeIfNeeded(resolved: resolved) + let elapsed = Date().timeIntervalSince(upgradeStart) + switch outcome { + case .noop: + print(""" + [LobbyZipUpgrader] 无需升级,跳过(耗时 \(String(format: "%.3f", elapsed))s) + """) + case .upgraded(let from, let to): + let newLocalGame = LocalVersionReader.localGameVersion + print(""" + [LobbyZipUpgrader] 升级完成 \(from) → \(to)(耗时 \(String(format: "%.3f", elapsed))s) + 重新读 version.xml: localGameVersion = \(newLocalGame) + lobbyIndex 存在: \(FileManager.default.fileExists(atPath: SandboxPaths.lobbyIndex.path)) + """) + } + } catch { + print("[LobbyZipUpgrader] ERROR: \(error)") + } case .shortText(let msg): print(""" [RemoteConfigClient] 拉到短文本响应(耗时 \(String(format: "%.3f", elapsed))s) diff --git a/ylgamehall/Source/Resource/LobbyZipUpgrader.swift b/ylgamehall/Source/Resource/LobbyZipUpgrader.swift new file mode 100644 index 0000000..ed81570 --- /dev/null +++ b/ylgamehall/Source/Resource/LobbyZipUpgrader.swift @@ -0,0 +1,101 @@ +// +// LobbyZipUpgrader.swift +// ylgamehall +// +// H5 zip 升级:下载远端 game_zip → 临时目录解压 → 原子 rename 到 lobbyRoot。 +// 详见 docs/Development-Plan.md ADR-008-G / Design §6.3.4 / msext NewRootVC.m:1789-1869。 +// + +import Foundation +import ZIPFoundation + +public actor LobbyZipUpgrader { + + public static let shared = LobbyZipUpgrader() + + public enum Outcome: Sendable { + /// 本地版本已是最新(含远端 gameZip 为空 / 版本号未提升的情况),未做任何下载 + case noop + /// 完成升级。`from → to` 是版本号变化。 + case upgraded(from: Int, to: Int) + } + + public enum UpgradeError: Error, Sendable { + case badGameZipURL(String) + case downloadFailed(any Error) + case httpStatus(Int) + case unzipFailed(any Error) + case stagingMoveFailed(any Error) + } + + private let session: URLSession + + public init(session: URLSession = LobbyZipUpgrader.defaultSession) { + self.session = session + } + + /// 默认 URLSession:下载用 60s 超时(zip 大约 11 MB,4G 网络约 10s)。 + nonisolated public static var defaultSession: URLSession { + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = 60 + cfg.timeoutIntervalForResource = 300 + return URLSession(configuration: cfg) + } + + /// 比较远端 game_version 与本地 version.xml。需要升级则下载 + 解压 + 原子 rename。 + /// + /// - Parameter resolved: VersionResolver.resolve(...) 的结果 + /// - Returns: `.noop` 已是最新;`.upgraded(from:to:)` 完成升级 + public func upgradeIfNeeded(resolved: ResolvedVersion) async throws -> Outcome { + let localGameVersion = LocalVersionReader.localGameVersion + guard resolved.gameVersion > localGameVersion, + let zipURLString = resolved.gameZip, + let zipURL = URL(string: zipURLString) + else { + return .noop + } + + // 1. 下载到 tmp(URLSession 默认管理临时位置) + let downloadedURL: URL + let response: URLResponse + do { + (downloadedURL, response) = try await session.download(from: zipURL) + } catch { + throw UpgradeError.downloadFailed(error) + } + if let http = response as? HTTPURLResponse, + !(200..<300).contains(http.statusCode) { + throw UpgradeError.httpStatus(http.statusCode) + } + + // 2. 解压到隔离 staging 目录 + let staging = SandboxPaths.caches + .appendingPathComponent("lobby-staging-\(UUID().uuidString)") + let fm = FileManager.default + do { + try fm.createDirectory(at: staging, withIntermediateDirectories: true) + try fm.unzipItem(at: downloadedURL, to: staging) + } catch { + try? fm.removeItem(at: staging) + throw UpgradeError.unzipFailed(error) + } + try? fm.removeItem(at: downloadedURL) + + // 3. 原子 rename:删旧 lobbyRoot → 把 staging 移到位 + // (msext "removeItem + ZipArchive overWrite:YES" 半途崩溃留半残; + // 我们 staging 完整解压后再 rename,半途崩溃只留 staging,下次启动可清) + let lobbyRoot = SandboxPaths.lobbyRoot + do { + if fm.fileExists(atPath: lobbyRoot.path) { + try fm.removeItem(at: lobbyRoot) + } + // 父目录确保存在(Library/Caches 永远存在,无需创建中间路径) + try fm.moveItem(at: staging, to: lobbyRoot) + } catch { + try? fm.removeItem(at: staging) + throw UpgradeError.stagingMoveFailed(error) + } + + return .upgraded(from: localGameVersion, to: resolved.gameVersion) + } +}