Phase 1.13:LobbyZipUpgrader 完成 H5 zip 升级端到端链路

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 渲染出来。
This commit is contained in:
joywayer
2026-06-22 02:27:48 +08:00
parent 9e50c38323
commit 0887088bb8
2 changed files with 123 additions and 0 deletions
+22
View File
@@ -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
@@ -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 MB4G 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. tmpURLSession
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)
}
}