Files
youle_app_ios_v2/ylgamehall/Source/Resource/SubGameDownloader.swift
T
joywayerandClaude Opus 4.7 522087f398 修复下载进度瞬间跳 100%:换手动 downloadTask + 续作
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>
2026-06-24 22:40:59 +08:00

286 lines
12 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 下载 + 解压 + 升级。与 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.gameVersionVersionResolver 算出的)
/// - 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 自身写入 UserDefaultsmsext 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,不 advanceadvance 是升级路径专有)。
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"]
))
}
}
}