async 版 await session.download(from:) 内部走 completion-handler 路径, session 级 delegate 的 didWriteData 全部被跳过,结果只有兜底 onProgress(1.0) 在下载完成后才触发——用户看到的"瞬间 100%"就是这个唯一一次回调。 换成手动 session.downloadTask(with:).resume() + CheckedContinuation: - didWriteData 正常按 URLSession 节奏在 opQueue 上派发,进度真实推进 - didFinishDownloadingTo 内同步把临时文件 move 到沙盒 Caches 的已知位置 (否则 delegate 返回后系统立即清掉那个临时位置) - didCompleteWithError 里 resume 续作 - continuation/moveError 跨线程(actor → opQueue)用 NSLock 保护 LobbyZipUpgrader / SubGameDownloader 同款修复(大厅 zip 下载 + 子游戏 zip 下载)。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
286 lines
12 KiB
Swift
286 lines
12 KiB
Swift
//
|
||
// 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。**必须用手动 downloadTask + 续作**,不能用 async 版
|
||
// `await session.download(from:)`:后者内部走 completion-handler 路径,
|
||
// session 级 delegate 的 didWriteData 全部被跳过,结果只有下载完成才补一次
|
||
// onProgress(1.0),进度条瞬间从 0% 跳到 100%。详见 LobbyZipUpgrader 同款修复注释。
|
||
let savedURL = SandboxPaths.caches
|
||
.appendingPathComponent("subgame-download-\(UUID().uuidString).zip")
|
||
let progressDelegate = DownloadProgressDelegate(
|
||
onProgress: onProgress,
|
||
moveDownloadedTo: savedURL
|
||
)
|
||
let opQueue = OperationQueue()
|
||
opQueue.maxConcurrentOperationCount = 1
|
||
let session = URLSession(
|
||
configuration: Self.defaultConfig,
|
||
delegate: progressDelegate,
|
||
delegateQueue: opQueue
|
||
)
|
||
defer { session.invalidateAndCancel() }
|
||
|
||
let response: URLResponse
|
||
do {
|
||
response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<URLResponse, any Error>) in
|
||
progressDelegate.setContinuation(cont)
|
||
session.downloadTask(with: zipURL).resume()
|
||
}
|
||
} catch {
|
||
try? FileManager.default.removeItem(at: savedURL)
|
||
throw DownloadError.downloadFailed(error)
|
||
}
|
||
if let http = response as? HTTPURLResponse,
|
||
!(200..<300).contains(http.statusCode) {
|
||
try? FileManager.default.removeItem(at: savedURL)
|
||
throw DownloadError.httpStatus(http.statusCode)
|
||
}
|
||
let downloadedURL = savedURL
|
||
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 下载进度桥接:把 didWriteData 的 byte 计数换算成 0…1,回调上层;
|
||
/// 同时驱动下载完成的 CheckedContinuation。
|
||
///
|
||
/// 与 LobbyZipUpgrader 同款实现就地复制(两处使用,不抽公共 — 三处出现时再合并)。
|
||
private final class DownloadProgressDelegate: NSObject,
|
||
URLSessionDownloadDelegate,
|
||
@unchecked Sendable {
|
||
private let onProgress: @Sendable (Double) -> Void
|
||
private let moveDownloadedTo: URL
|
||
private let lock = NSLock()
|
||
private var continuation: CheckedContinuation<URLResponse, any Error>?
|
||
private var moveError: (any Error)?
|
||
|
||
init(onProgress: @escaping @Sendable (Double) -> Void, moveDownloadedTo: URL) {
|
||
self.onProgress = onProgress
|
||
self.moveDownloadedTo = moveDownloadedTo
|
||
super.init()
|
||
}
|
||
|
||
/// 由 actor 在启动 downloadTask 前调一次。必须在 `task.resume()` 之前调,
|
||
/// 否则 didCompleteWithError 可能先于 setContinuation 发生(极端情况下短下载)。
|
||
func setContinuation(_ c: CheckedContinuation<URLResponse, any Error>) {
|
||
lock.lock(); defer { lock.unlock() }
|
||
continuation = c
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
/// 必须在 delegate 方法返回前同步把临时文件 move 到 `moveDownloadedTo`:
|
||
/// 系统会在本方法返回后立即清理 `location` 处的临时文件。
|
||
func urlSession(_ session: URLSession,
|
||
downloadTask: URLSessionDownloadTask,
|
||
didFinishDownloadingTo location: URL) {
|
||
do {
|
||
try? FileManager.default.removeItem(at: moveDownloadedTo)
|
||
try FileManager.default.moveItem(at: location, to: moveDownloadedTo)
|
||
} catch {
|
||
lock.lock(); defer { lock.unlock() }
|
||
moveError = error
|
||
}
|
||
}
|
||
|
||
func urlSession(_ session: URLSession,
|
||
task: URLSessionTask,
|
||
didCompleteWithError error: (any Error)?) {
|
||
lock.lock(); defer { lock.unlock() }
|
||
guard let c = continuation else { return }
|
||
continuation = nil
|
||
if let error {
|
||
c.resume(throwing: error)
|
||
} else if let me = moveError {
|
||
c.resume(throwing: me)
|
||
} else if let response = task.response {
|
||
c.resume(returning: response)
|
||
} else {
|
||
c.resume(throwing: NSError(
|
||
domain: "SubGameDownloader.DownloadProgressDelegate",
|
||
code: -1,
|
||
userInfo: [NSLocalizedDescriptionKey: "download finished without response"]
|
||
))
|
||
}
|
||
}
|
||
}
|