Files
youle_app_ios_v2/ylgamehall/Source/Resource/SubGameDownloader.swift
T
joywayerandClaude Opus 4.7 fd9976c4b9 下载进度回调改 session-level delegate(ad-hoc 不触发 didWriteData)
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>
2026-06-23 21:34:38 +08:00

163 lines
6.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
}
}