// // SubGameDownloader.swift // ylgamehall // // 子游戏 zip 下载 + 解压 + 升级。与 daoqi msext gameController.m 路径等价: // // 1) 目录不存在(首装): // 下载 → 解压 → rename 到 `{Caches}/{currentDir}/`,currentDir 不变 // (msext: line 1243-1245 + uplevel:YES → downFileFromServer) // // 2) 目录存在 + 远端 game_version > 本地 version.xml(升级): // advance directory(末尾追加 "1" → newDir,写 UserDefaults)→ 下载 → // 解压 → rename 到 `{Caches}/{newDir}/`,旧目录残留 // (msext: gameController.m:1410-1428 uplevel:download: 路径) // // 3) 目录存在 + 远端 ≤ 本地:直接复用 currentDir // (msext: initView) // // 与 LobbyZipUpgrader 思路一致;不复用是因为目录持久化 + advance 策略 sub-game 专有。 // import Foundation import ZIPFoundation public actor SubGameDownloader { public static let shared = SubGameDownloader() public enum Outcome: Sendable { /// 缓存命中、远端版本未提升 → 直接复用 currentDir case alreadyExists(actualDirectory: String) /// 完成下载 + 解压。`fromVersion = 0` 表示首装。 case upgraded(actualDirectory: String, from: Int, to: Int, zipBytes: Int64) /// 实际生效的子游戏目录名(持久化值,可能与 H5 传的 directory 不同)。 public var actualDirectory: String { switch self { case .alreadyExists(let dir): return dir case .upgraded(let dir, _, _, _): return dir } } } 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) } public init() {} /// URLSession 配置:60s 单请求 / 300s 总体(zip 体量约 1-10 MB)。 /// 每次下载用此 config 临时建 session,绑 download delegate 接收 didWriteData /// 进度回调;ad-hoc delegate 不会触发 download-specific 方法,必须走 session 级。 nonisolated private static var defaultConfig: URLSessionConfiguration { let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 60 cfg.timeoutIntervalForResource = 300 return cfg } /// 准备子游戏 H5 资源。 /// /// - Parameters: /// - directoryKey: H5 传的 Gamedirectory 字面值,作为 UserDefaults 持久化 key /// - gameStart: H5 子游戏入口(msext 的 self.game_name,与 directoryKey 同名) /// - remoteVersion: 远端 ResolvedVersion.gameVersion(VersionResolver 算出的) /// - remoteZipURL: 远端 ResolvedVersion.gameZip 真实下载 URL /// - onProgress: 0…1 进度回调,URLSession delegate 后台线程派发 /// - Returns: Outcome,外层据其 `actualDirectory` 构造 loadFileURL 路径 public func ensureReady( directoryKey: String, gameStart: String, remoteVersion: Int, remoteZipURL: String, onProgress: @escaping @Sendable (Double) -> Void = { _ in } ) async throws -> Outcome { // 首次访问把 directory 自身写入 UserDefaults(msext gameController.m:2187 等价) SubGameDirectoryStore.saveInitialIfNeeded(forKey: directoryKey) let currentDir = SubGameDirectoryStore.current(forKey: directoryKey) let fm = FileManager.default let indexPath = SandboxPaths.subGameIndex(currentDir, gameStart).path let indexExists = fm.fileExists(atPath: indexPath) // 分支 1:目录不存在(首装)—— msext line 1242-1246 等价。 // 下载到 currentDir,不 advance(advance 是升级路径专有)。 if !indexExists { let zipBytes = try await download( to: currentDir, zipURLString: remoteZipURL, onProgress: onProgress ) return .upgraded( actualDirectory: currentDir, from: 0, to: remoteVersion, zipBytes: zipBytes ) } // 分支 2 / 3:目录存在 —— 读本地 version.xml,按远端对比。 let localVer = LocalVersionReader.subGameVersion(dir: currentDir, start: gameStart) guard remoteVersion > localVer else { // 分支 3:远端 ≤ 本地 → 复用(msext: initView 直接加载) return .alreadyExists(actualDirectory: currentDir) } // 分支 2:升级 —— advance 后下载到新目录。 // msext gameController.m:1414-1422 等价 let newDir = SubGameDirectoryStore.advance(forKey: directoryKey) let zipBytes = try await download( to: newDir, zipURLString: remoteZipURL, onProgress: onProgress ) return .upgraded( actualDirectory: newDir, from: localVer, to: remoteVersion, zipBytes: zipBytes ) } // MARK: - 私有:下载 + 解压 + rename 一体化 /// 下载远端 zip → 解压到 staging → 原子 rename 到 `{Caches}/{actualDirectory}/`。 /// 返回下载字节数。 private func download( to actualDirectory: String, zipURLString: String, onProgress: @escaping @Sendable (Double) -> Void ) async throws -> Int64 { guard !zipURLString.isEmpty else { throw DownloadError.missingDownloadURL } guard let zipURL = URL(string: zipURLString) else { throw DownloadError.badDownloadURL(zipURLString) } // 1. 下载到 tmp。delegate 必须在 URLSession init 时绑定,否则 // URLSessionDownloadDelegate.didWriteData 不会被调用(async API // `download(from:delegate:)` 的 ad-hoc delegate 只收 task-level 回调)。 let progressDelegate = DownloadProgressDelegate(onProgress: onProgress) let opQueue = OperationQueue() opQueue.maxConcurrentOperationCount = 1 let session = URLSession( configuration: Self.defaultConfig, delegate: progressDelegate, delegateQueue: opQueue ) defer { session.invalidateAndCancel() } let downloadedURL: URL let response: URLResponse do { (downloadedURL, response) = try await session.download(from: zipURL) } 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 fm = FileManager.default 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:删旧目标 → 把 staging 移到位 // zip 内部已含 `{gameStart}/` 子目录,解压到 staging 后整体 move 到 // `{Caches}/{actualDirectory}/`,结果就是 `{actualDirectory}/{gameStart}/index.html` let target = SandboxPaths.subGameRoot(actualDirectory) do { if fm.fileExists(atPath: target.path) { try fm.removeItem(at: target) } try fm.moveItem(at: staging, to: target) } catch { try? fm.removeItem(at: staging) throw DownloadError.stagingMoveFailed(error) } return 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 } }