Files
youle_app_ios_v2/ylgamehall/Source/Resource/LobbyZipUpgrader.swift
T
joywayerandClaude Opus 4.7 f3c66b670d Phase 1.14.c:LobbyZipUpgrader 加 0…1 progress 回调
- 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>
2026-06-22 02:46:11 +08:00

149 lines
5.9 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)
}
private let session: URLSession
public init(session: URLSession = LobbyZipUpgrader.defaultSession) {
self.session = session
}
/// 默认 URLSession:下载用 60s 超时(zip 大约 11 MB4G 网络约 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. 下载到 tmpURLSession 默认管理临时位置),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
}
}