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) + } +}