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