Files
youle_app_ios_v2/ylgamehall/Source/Resource/SubGameDownloader.swift
T
joywayerandClaude Opus 4.7 a9eaf86c63 Phase 6 commit B:SubGameDownloader zip 下载 + 解压
- 新增 Source/Resource/SubGameDownloader.swift(actor):URLSession + ZIPFoundation,
  缓存命中直接复用(看 {Caches}/{gameDir}/{gameStart}/index.html 是否存在),
  未命中下载 → staging 解压 → 原子 rename(与 LobbyZipUpgrader 同思路;
  msext gameController.m:1340-1401 等价,以 staging 替代 msext 直接覆盖避免半残留)
- SubGameViewController.runBootPipelineSteps:接入 ensureReady,splash 实时更新下载进度,
  移除 commit A 占位的 SubGameBootError
- DownloadProgressDelegate 与 LobbyZipUpgrader 同款就地复制(两处用不抽公共,
  三处出现时再合并)
- Plan §5.6.7 / §8 进度已勾选

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 21:01:53 +08:00

155 lines
5.7 KiB
Swift

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