// // SubGameDownloader.swift // ylgamehall // // 子游戏 zip 下载 + 解压:与 msext gameController.m:1340-1401 等价。 // - 缓存命中(`{Caches}/{gameDir}/{gameStart}/index.html` 已在)→ 直接复用 // - 缓存未命中 → URLSession 下载 → 解压到 staging → 原子 rename // // 与 LobbyZipUpgrader 思路一致;不复用是因为目录路径/版本判定/缓存策略不同 // (子游戏只看是否存在,不比版本号;msext 子游戏升级走另一条 uplevel 路径, // 本项目暂未接入 — 待业务确认远端是否真有 sub-game 版本下发)。 // // 详见 docs/Development-Plan.md §5.6 / §6.7。 // import Foundation import ZIPFoundation public actor SubGameDownloader { public static let shared = SubGameDownloader() public enum Outcome: Sendable { /// 缓存命中,未下载 case alreadyExists /// 完成下载 + 解压(zipBytes 为下载字节数,便于日志 / 测试) case downloaded(zipBytes: Int64) } public enum DownloadError: Error, Sendable { case missingDownloadURL case badDownloadURL(String) case downloadFailed(any Error) case httpStatus(Int) case unzipFailed(any Error) case stagingMoveFailed(any Error) } private let session: URLSession public init(session: URLSession = SubGameDownloader.defaultSession) { self.session = session } nonisolated public static var defaultSession: URLSession { let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 60 cfg.timeoutIntervalForResource = 300 return URLSession(configuration: cfg) } /// 确保 `{Caches}/{request.gameDir}/{request.gameStart}/index.html` 已就绪。 /// /// - Parameters: /// - request: SwitchOverGameData 解出的入参 /// - onProgress: 0…1 进度,URLSession delegate 后台线程派发;UI 层负责 hop MainActor /// - Returns: `.alreadyExists` 命中缓存;`.downloaded(zipBytes:)` 完成下载 public func ensureReady( request: SubGameRequest, onProgress: @escaping @Sendable (Double) -> Void = { _ in } ) async throws -> Outcome { let fm = FileManager.default let indexPath = SandboxPaths.subGameIndex(request.gameDir, request.gameStart).path if fm.fileExists(atPath: indexPath) { return .alreadyExists } guard !request.downloadURL.isEmpty else { throw DownloadError.missingDownloadURL } guard let zipURL = URL(string: request.downloadURL) else { throw DownloadError.badDownloadURL(request.downloadURL) } // 1. 下载到 tmp let progressDelegate = DownloadProgressDelegate(onProgress: onProgress) let downloadedURL: URL let response: URLResponse do { (downloadedURL, response) = try await session.download( from: zipURL, delegate: progressDelegate ) } catch { throw DownloadError.downloadFailed(error) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw DownloadError.httpStatus(http.statusCode) } onProgress(1.0) let zipBytes = (try? fm.attributesOfItem(atPath: downloadedURL.path)[.size] as? NSNumber)? .int64Value ?? 0 // 2. 解压到隔离 staging let staging = SandboxPaths.caches .appendingPathComponent("subgame-staging-\(UUID().uuidString)") do { try fm.createDirectory(at: staging, withIntermediateDirectories: true) try fm.unzipItem(at: downloadedURL, to: staging) } catch { try? fm.removeItem(at: staging) throw DownloadError.unzipFailed(error) } try? fm.removeItem(at: downloadedURL) // 3. 原子 rename:删旧 subGameRoot → 把 staging 移到位 // msext 行为:zip 内部已含 `{gameStart}/` 子目录,解压到 staging 后 // 再整体 move 到 `{Caches}/{gameDir}/`,结果就是 `{gameDir}/{gameStart}/index.html` let subGameRoot = SandboxPaths.subGameRoot(request.gameDir) do { if fm.fileExists(atPath: subGameRoot.path) { try fm.removeItem(at: subGameRoot) } try fm.moveItem(at: staging, to: subGameRoot) } catch { try? fm.removeItem(at: staging) throw DownloadError.stagingMoveFailed(error) } return .downloaded(zipBytes: zipBytes) } } /// URLSession 下载进度桥接。 /// /// 与 LobbyZipUpgrader 同款实现就地复制(两处使用,不抽公共 — 三处出现时再合并)。 private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { private let onProgress: @Sendable (Double) -> Void init(onProgress: @escaping @Sendable (Double) -> Void) { self.onProgress = onProgress super.init() } 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)) } func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { // no-op } }