- upgradeIfNeeded 新增 onProgress: @escaping @Sendable (Double) -> Void 参数, 默认 no-op 兼容仅做版本对比的烟雾测试 - 新增文件内私有 DownloadProgressDelegate(URLSessionDownloadDelegate + @unchecked Sendable),在 didWriteData 回调里把 totalBytesWritten / totalBytesExpectedToWrite 换算成 0…1 上报,clamp 到 [0,1] - 通过 session.download(from:delegate:) 注入;下载完成后兜底报 1.0 (部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%) - RootViewController 烟雾测试用 ProgressTicker 把回调节流到 10% 阶梯打印, 避免几百行刷屏(NSLock 保护单 Int 状态,@unchecked Sendable) - BuildProject 通过 Plan 进度已勾选(§5 Phase 1.14.c + §8) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
149 lines
5.9 KiB
Swift
149 lines
5.9 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
|
||
private let session: URLSession
|
||
|
||
public init(session: URLSession = LobbyZipUpgrader.defaultSession) {
|
||
self.session = session
|
||
}
|
||
|
||
/// 默认 URLSession:下载用 60s 超时(zip 大约 11 MB,4G 网络约 10s)。
|
||
nonisolated public static var defaultSession: URLSession {
|
||
let cfg = URLSessionConfiguration.default
|
||
cfg.timeoutIntervalForRequest = 60
|
||
cfg.timeoutIntervalForResource = 300
|
||
return URLSession(configuration: 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(URLSession 默认管理临时位置),delegate 上报 0…1 进度
|
||
let progressDelegate = DownloadProgressDelegate(onProgress: onProgress)
|
||
let downloadedURL: URL
|
||
let response: URLResponse
|
||
do {
|
||
(downloadedURL, response) = try await session.download(
|
||
from: zipURL,
|
||
delegate: progressDelegate
|
||
)
|
||
} catch {
|
||
throw UpgradeError.downloadFailed(error)
|
||
}
|
||
if let http = response as? HTTPURLResponse,
|
||
!(200..<300).contains(http.statusCode) {
|
||
throw UpgradeError.httpStatus(http.statusCode)
|
||
}
|
||
// 下载完成兜底报 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,回调上层。
|
||
///
|
||
/// 之所以放在文件内私有:仅 LobbyZipUpgrader 用,不构成对外 API。
|
||
/// `@unchecked Sendable`:URLSessionDownloadDelegate 协议本身不带 Sendable;
|
||
/// 我们的状态仅是一个 `@Sendable` 闭包,无可变共享,标记安全。
|
||
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))
|
||
}
|
||
|
||
/// 必须实现的协议方法。async download API 内部已接管下载文件落地,
|
||
/// 这里无需把文件搬到任何位置。
|
||
func urlSession(_ session: URLSession,
|
||
downloadTask: URLSessionDownloadTask,
|
||
didFinishDownloadingTo location: URL) {
|
||
// no-op
|
||
}
|
||
}
|