Files
youle_app_ios_v2/ylgamehall/Source/Resource/LobbyZipUpgrader.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

212 lines
9.2 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.
//
// LobbyZipUpgrader.swift
// ylgamehall
//
// H5 zip 升级:下载远端 game_zip → 临时目录解压 → 原子 rename 到 lobbyRoot。
// 详见 docs/Development-Plan.md ADR-008-G / Design §6.3.4 / msext NewRootVC.m:1789-1869。
//
import Foundation
import ZIPFoundation
public actor LobbyZipUpgrader {
public static let shared = LobbyZipUpgrader()
public enum Outcome: Sendable {
/// 本地版本已是最新(含远端 gameZip 为空 / 版本号未提升的情况),未做任何下载
case noop
/// 完成升级。`from → to` 是版本号变化。
case upgraded(from: Int, to: Int)
}
public enum UpgradeError: Error, Sendable {
case badGameZipURL(String)
case downloadFailed(any Error)
case httpStatus(Int)
case unzipFailed(any Error)
case stagingMoveFailed(any Error)
}
public init() {}
/// URLSession 配置:60s 单请求 / 300s 总体(zip 约 11 MB4G 约 10s)。
/// 每次 upgradeIfNeeded 用此 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
}
/// 比较远端 game_version 与本地 version.xml。需要升级则下载 + 解压 + 原子 rename。
///
/// - Parameters:
/// - resolved: VersionResolver.resolve(...) 的结果
/// - onProgress: 下载进度回调 0.0…1.0,由 URLSession delegate 在后台线程派发;
/// UI 层负责把闭包 hop 到 MainActor。默认 no-op 兼容仅做版本对比的烟雾测试。
/// - Returns: `.noop` 已是最新;`.upgraded(from:to:)` 完成升级
public func upgradeIfNeeded(
resolved: ResolvedVersion,
onProgress: @escaping @Sendable (Double) -> Void = { _ in }
) async throws -> Outcome {
let localGameVersion = LocalVersionReader.localGameVersion
guard resolved.gameVersion > localGameVersion,
let zipURLString = resolved.gameZip,
let zipURL = URL(string: zipURLString)
else {
return .noop
}
// 1. 下载到 tmp。**必须用手动 downloadTask + 续作**,不能用 async 版
// `await session.download(from:)`:后者内部走 completion-handler 路径,
// session 级 delegate 的 didWriteData 全部被跳过,结果只有下载完成才补一次
// onProgress(1.0),进度条瞬间从 0% 跳到 100%Apple Guide "Downloading files
// from websites" 明确:要进度必须 delegate + manual task.resume())。
// 手动 task 路径下,didWriteData 由 URLSession 在 opQueue 上正常派发;
// didFinishDownloadingTo 内同步把临时文件 move 到 savedURL(否则 delegate
// 返回后系统会立即清掉那个临时位置);didCompleteWithError 里 resume 续作。
let savedURL = SandboxPaths.caches
.appendingPathComponent("lobby-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 UpgradeError.downloadFailed(error)
}
if let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode) {
try? FileManager.default.removeItem(at: savedURL)
throw UpgradeError.httpStatus(http.statusCode)
}
let downloadedURL = savedURL
// 下载完成兜底报 1.0:部分 server 不发 Content-Length 或末尾片段晚到,
// 不显式补这一下,进度条可能停在 99% 直到解压结束才跳走
onProgress(1.0)
// 2. 解压到隔离 staging 目录
let staging = SandboxPaths.caches
.appendingPathComponent("lobby-staging-\(UUID().uuidString)")
let fm = FileManager.default
do {
try fm.createDirectory(at: staging, withIntermediateDirectories: true)
try fm.unzipItem(at: downloadedURL, to: staging)
} catch {
try? fm.removeItem(at: staging)
throw UpgradeError.unzipFailed(error)
}
try? fm.removeItem(at: downloadedURL)
// 3. 原子 rename:删旧 lobbyRoot → 把 staging 移到位
// msext "removeItem + ZipArchive overWrite:YES" 半途崩溃留半残;
// 我们 staging 完整解压后再 rename,半途崩溃只留 staging,下次启动可清)
let lobbyRoot = SandboxPaths.lobbyRoot
do {
if fm.fileExists(atPath: lobbyRoot.path) {
try fm.removeItem(at: lobbyRoot)
}
// 父目录确保存在(Library/Caches 永远存在,无需创建中间路径)
try fm.moveItem(at: staging, to: lobbyRoot)
} catch {
try? fm.removeItem(at: staging)
throw UpgradeError.stagingMoveFailed(error)
}
return .upgraded(from: localGameVersion, to: resolved.gameVersion)
}
}
/// URLSession 下载进度桥接:把 `didWriteData` 的 byte 计数换算成 0…1,回调上层;
/// 同时驱动下载完成的 CheckedContinuation。
///
/// 之所以放在文件内私有:仅 LobbyZipUpgrader 用,不构成对外 API。
/// `@unchecked Sendable`URLSessionDownloadDelegate 协议本身不带 Sendable
/// 内部可变状态(continuation / moveError)跨线程(actor → opQueue),用 NSLock 保护。
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` 处的临时文件。
/// move 出错记录到 moveError,由 didCompleteWithError 统一抛回 continuation。
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: "LobbyZipUpgrader.DownloadProgressDelegate",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "download finished without response"]
))
}
}
}