// // 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) } public init() {} /// URLSession 配置:60s 单请求 / 300s 总体(zip 约 11 MB,4G 约 10s)。 /// 每次 upgradeIfNeeded 用此 config 临时建 session,绑 download delegate 接收 /// didWriteData 进度回调;ad-hoc delegate(`session.download(from:delegate:)`) /// 不会触发 URLSessionDownloadDelegate 的 download-specific 方法,必须走 session 级。 nonisolated private static var defaultConfig: URLSessionConfiguration { let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 60 cfg.timeoutIntervalForResource = 300 return cfg } /// 比较远端 game_version 与本地 version.xml。需要升级则下载 + 解压 + 原子 rename。 /// /// - Parameters: /// - resolved: VersionResolver.resolve(...) 的结果 /// - onProgress: 下载进度回调 0.0…1.0,由 URLSession delegate 在后台线程派发; /// UI 层负责把闭包 hop 到 MainActor。默认 no-op 兼容仅做版本对比的烟雾测试。 /// - Returns: `.noop` 已是最新;`.upgraded(from:to:)` 完成升级 public func upgradeIfNeeded( resolved: ResolvedVersion, onProgress: @escaping @Sendable (Double) -> Void = { _ in } ) 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。**必须用手动 downloadTask + 续作**,不能用 async 版 // `await session.download(from:)`:后者内部走 completion-handler 路径, // session 级 delegate 的 didWriteData 全部被跳过,结果只有下载完成才补一次 // onProgress(1.0),进度条瞬间从 0% 跳到 100%(Apple Guide "Downloading files // from websites" 明确:要进度必须 delegate + manual task.resume())。 // 手动 task 路径下,didWriteData 由 URLSession 在 opQueue 上正常派发; // didFinishDownloadingTo 内同步把临时文件 move 到 savedURL(否则 delegate // 返回后系统会立即清掉那个临时位置);didCompleteWithError 里 resume 续作。 let savedURL = SandboxPaths.caches .appendingPathComponent("lobby-download-\(UUID().uuidString).zip") let progressDelegate = DownloadProgressDelegate( onProgress: onProgress, moveDownloadedTo: savedURL ) let opQueue = OperationQueue() opQueue.maxConcurrentOperationCount = 1 let session = URLSession( configuration: Self.defaultConfig, delegate: progressDelegate, delegateQueue: opQueue ) defer { session.invalidateAndCancel() } let response: URLResponse do { response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in progressDelegate.setContinuation(cont) session.downloadTask(with: zipURL).resume() } } catch { try? FileManager.default.removeItem(at: savedURL) throw UpgradeError.downloadFailed(error) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { try? FileManager.default.removeItem(at: savedURL) throw UpgradeError.httpStatus(http.statusCode) } let downloadedURL = savedURL // 下载完成兜底报 1.0:部分 server 不发 Content-Length 或末尾片段晚到, // 不显式补这一下,进度条可能停在 99% 直到解压结束才跳走 onProgress(1.0) // 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) } } /// URLSession 下载进度桥接:把 `didWriteData` 的 byte 计数换算成 0…1,回调上层; /// 同时驱动下载完成的 CheckedContinuation。 /// /// 之所以放在文件内私有:仅 LobbyZipUpgrader 用,不构成对外 API。 /// `@unchecked Sendable`:URLSessionDownloadDelegate 协议本身不带 Sendable; /// 内部可变状态(continuation / moveError)跨线程(actor → opQueue),用 NSLock 保护。 private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { private let onProgress: @Sendable (Double) -> Void private let moveDownloadedTo: URL private let lock = NSLock() private var continuation: CheckedContinuation? private var moveError: (any Error)? init(onProgress: @escaping @Sendable (Double) -> Void, moveDownloadedTo: URL) { self.onProgress = onProgress self.moveDownloadedTo = moveDownloadedTo super.init() } /// 由 actor 在启动 downloadTask 前调一次。必须在 `task.resume()` 之前调, /// 否则 didCompleteWithError 可能先于 setContinuation 发生(极端情况下短下载)。 func setContinuation(_ c: CheckedContinuation) { lock.lock(); defer { lock.unlock() } continuation = c } func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { guard totalBytesExpectedToWrite > 0 else { return } let p = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) onProgress(min(max(p, 0), 1)) } /// 必须在 delegate 方法返回前同步把临时文件 move 到 `moveDownloadedTo`: /// 系统会在本方法返回后立即清理 `location` 处的临时文件。 /// move 出错记录到 moveError,由 didCompleteWithError 统一抛回 continuation。 func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { do { try? FileManager.default.removeItem(at: moveDownloadedTo) try FileManager.default.moveItem(at: location, to: moveDownloadedTo) } catch { lock.lock(); defer { lock.unlock() } moveError = error } } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { lock.lock(); defer { lock.unlock() } guard let c = continuation else { return } continuation = nil if let error { c.resume(throwing: error) } else if let me = moveError { c.resume(throwing: me) } else if let response = task.response { c.resume(returning: response) } else { c.resume(throwing: NSError( domain: "LobbyZipUpgrader.DownloadProgressDelegate", code: -1, userInfo: [NSLocalizedDescriptionKey: "download finished without response"] )) } } }