LobbyZipUpgrader 和 SubGameDownloader 之前用 `session.download(from:delegate:)` 的 iOS 15+ async API,把 DownloadProgressDelegate 作为 ad-hoc delegate 传入。 该 API 的 delegate 参数实际只收 URLSessionTaskDelegate 回调,**不会**触发 URLSessionDownloadDelegate.didWriteData,所以 0…1 进度从未上报,splash 进度条 一直停在 0,只有末尾兜底的 onProgress(1.0) 跑一下,看起来"一出现就 100%"。 改为每次下载临时新建一个 URLSession,把 progressDelegate 在 init 时绑到 session 级别(带独立 OperationQueue),下载结束 defer invalidateAndCancel 释放。delegate 现在能收到 didWriteData,真实字节比例上报,进度条 0→1 平滑。 副作用:删除 actor 字段 `session` 与可注入的 `init(session:)`,改用静态 `defaultConfig: URLSessionConfiguration` 复用超时配置(无外部调用方使用过自定义 session init)。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
163 lines
6.4 KiB
Swift
163 lines
6.4 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)
|
||
}
|
||
|
||
public init() {}
|
||
|
||
/// URLSession 配置:60s 单请求 / 300s 总体(zip 体量约 1-10 MB)。
|
||
/// 每次 ensureReady 用此 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
|
||
}
|
||
|
||
/// 确保 `{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。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 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
|
||
}
|
||
}
|