Phase 6 commit B:SubGameDownloader zip 下载 + 解压
- 新增 Source/Resource/SubGameDownloader.swift(actor):URLSession + ZIPFoundation,
缓存命中直接复用(看 {Caches}/{gameDir}/{gameStart}/index.html 是否存在),
未命中下载 → staging 解压 → 原子 rename(与 LobbyZipUpgrader 同思路;
msext gameController.m:1340-1401 等价,以 staging 替代 msext 直接覆盖避免半残留)
- SubGameViewController.runBootPipelineSteps:接入 ensureReady,splash 实时更新下载进度,
移除 commit A 占位的 SubGameBootError
- DownloadProgressDelegate 与 LobbyZipUpgrader 同款就地复制(两处用不抽公共,
三处出现时再合并)
- Plan §5.6.7 / §8 进度已勾选
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7a5644053d
commit
a9eaf86c63
@@ -594,9 +594,12 @@ Contract Design Plan(本文档)
|
||||
- [x] **SceneDelegate**:以 `UINavigationController` 承载大厅,`AppCoordinator.shared.navigationController` 持引;导航栏隐藏 + 禁用边缘 pop 手势
|
||||
- [ ] **6.5** `backgameData` handler(仅子游戏注册):停 audio → 发 `.subGameDidReturn` 通知 → coordinator.popSubGame
|
||||
- [ ] **6.6** 大厅监听 `.subGameDidReturn` → `bridge.call("getWebdata", data)`
|
||||
- [ ] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl`)
|
||||
- `Source/Resource/SubGameDownloader.swift`:URLSession + ZIPFoundation
|
||||
- 缓存策略:`Library/Caches/{Gamedirectory}/` 已存在则跳过
|
||||
- [x] **6.7** 子游戏 zip 下载 / 解压(H5 端通过 SwitchOverGameData 传 `gamedownloadurl`)
|
||||
- `Source/Resource/SubGameDownloader.swift`:actor 单例,URLSession + ZIPFoundation
|
||||
- 缓存策略:`{Caches}/{gameDir}/{gameStart}/index.html` 已存在 → `.alreadyExists` 直接复用;
|
||||
未命中 → 下载 → staging 解压 → 原子 rename(与 LobbyZipUpgrader 同思路;
|
||||
msext gameController.m:1340-1401 行为等价,本项目以 staging 替代 msext 直接覆盖避免半残留)
|
||||
- SubGameViewController.runBootPipelineSteps 接入:splash 实时更新下载进度
|
||||
|
||||
#### 验收
|
||||
|
||||
@@ -920,12 +923,12 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
|
||||
|
||||
### Phase 6 子游戏
|
||||
- [x] 6.1 AppCoordinator 栈深节流(2s + viewControllers.count < 2)
|
||||
- [x] 6.2 SubGameViewController 框架(commit A:H5 未安装则抛错,待 6.7 接入)
|
||||
- [x] 6.2 SubGameViewController 框架(commit B 接入 SubGameDownloader 后真实下载/缓存命中)
|
||||
- [ ] 6.3 SubGameHandlers(backgameData 留待 commit C)
|
||||
- [x] 6.4 SwitchOverGameData → AppCoordinator.showSubGame
|
||||
- [ ] 6.5 backgameData
|
||||
- [ ] 6.6 getWebdata 通知链
|
||||
- [ ] 6.7 SubGameDownloader
|
||||
- [x] 6.7 SubGameDownloader(actor,URLSession + ZIPFoundation + staging + 原子 rename)
|
||||
|
||||
### Phase 7 弹层
|
||||
- [ ] 7.1 OverlayViewController + 私有 dataStore
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// SubGameDownloader.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 子游戏 zip 下载 + 解压:与 msext gameController.m:1340-1401 等价。
|
||||
// - 缓存命中(`{Caches}/{gameDir}/{gameStart}/index.html` 已在)→ 直接复用
|
||||
// - 缓存未命中 → URLSession 下载 → 解压到 staging → 原子 rename
|
||||
//
|
||||
// 与 LobbyZipUpgrader 思路一致;不复用是因为目录路径/版本判定/缓存策略不同
|
||||
// (子游戏只看是否存在,不比版本号;msext 子游戏升级走另一条 uplevel 路径,
|
||||
// 本项目暂未接入 — 待业务确认远端是否真有 sub-game 版本下发)。
|
||||
//
|
||||
// 详见 docs/Development-Plan.md §5.6 / §6.7。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
|
||||
public actor SubGameDownloader {
|
||||
|
||||
public static let shared = SubGameDownloader()
|
||||
|
||||
public enum Outcome: Sendable {
|
||||
/// 缓存命中,未下载
|
||||
case alreadyExists
|
||||
/// 完成下载 + 解压(zipBytes 为下载字节数,便于日志 / 测试)
|
||||
case downloaded(zipBytes: Int64)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
public init(session: URLSession = SubGameDownloader.defaultSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
nonisolated public static var defaultSession: URLSession {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.timeoutIntervalForRequest = 60
|
||||
cfg.timeoutIntervalForResource = 300
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
/// 确保 `{Caches}/{request.gameDir}/{request.gameStart}/index.html` 已就绪。
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - request: SwitchOverGameData 解出的入参
|
||||
/// - onProgress: 0…1 进度,URLSession delegate 后台线程派发;UI 层负责 hop MainActor
|
||||
/// - Returns: `.alreadyExists` 命中缓存;`.downloaded(zipBytes:)` 完成下载
|
||||
public func ensureReady(
|
||||
request: SubGameRequest,
|
||||
onProgress: @escaping @Sendable (Double) -> Void = { _ in }
|
||||
) async throws -> Outcome {
|
||||
let fm = FileManager.default
|
||||
let indexPath = SandboxPaths.subGameIndex(request.gameDir, request.gameStart).path
|
||||
if fm.fileExists(atPath: indexPath) {
|
||||
return .alreadyExists
|
||||
}
|
||||
|
||||
guard !request.downloadURL.isEmpty else {
|
||||
throw DownloadError.missingDownloadURL
|
||||
}
|
||||
guard let zipURL = URL(string: request.downloadURL) else {
|
||||
throw DownloadError.badDownloadURL(request.downloadURL)
|
||||
}
|
||||
|
||||
// 1. 下载到 tmp
|
||||
let progressDelegate = DownloadProgressDelegate(onProgress: onProgress)
|
||||
let downloadedURL: URL
|
||||
let response: URLResponse
|
||||
do {
|
||||
(downloadedURL, response) = try await session.download(
|
||||
from: zipURL,
|
||||
delegate: progressDelegate
|
||||
)
|
||||
} catch {
|
||||
throw DownloadError.downloadFailed(error)
|
||||
}
|
||||
if let http = response as? HTTPURLResponse,
|
||||
!(200..<300).contains(http.statusCode) {
|
||||
throw DownloadError.httpStatus(http.statusCode)
|
||||
}
|
||||
onProgress(1.0)
|
||||
|
||||
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:删旧 subGameRoot → 把 staging 移到位
|
||||
// msext 行为:zip 内部已含 `{gameStart}/` 子目录,解压到 staging 后
|
||||
// 再整体 move 到 `{Caches}/{gameDir}/`,结果就是 `{gameDir}/{gameStart}/index.html`
|
||||
let subGameRoot = SandboxPaths.subGameRoot(request.gameDir)
|
||||
do {
|
||||
if fm.fileExists(atPath: subGameRoot.path) {
|
||||
try fm.removeItem(at: subGameRoot)
|
||||
}
|
||||
try fm.moveItem(at: staging, to: subGameRoot)
|
||||
} catch {
|
||||
try? fm.removeItem(at: staging)
|
||||
throw DownloadError.stagingMoveFailed(error)
|
||||
}
|
||||
|
||||
return .downloaded(zipBytes: zipBytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession 下载进度桥接。
|
||||
///
|
||||
/// 与 LobbyZipUpgrader 同款实现就地复制(两处使用,不抽公共 — 三处出现时再合并)。
|
||||
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))
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
@@ -164,15 +164,19 @@ public final class SubGameViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func runBootPipelineSteps() async throws {
|
||||
// 1. 确保子游戏 zip 已下载解压(commit B 接入 SubGameDownloader)
|
||||
// commit A 暂时直接走 FileManager 检查,不存在则抛错让 commit B 接入下载。
|
||||
let indexURL = SandboxPaths.subGameIndex(request.gameDir, request.gameStart)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else {
|
||||
throw SubGameBootError.subGameNotInstalled(
|
||||
dir: request.gameDir,
|
||||
downloadURL: request.downloadURL
|
||||
)
|
||||
}
|
||||
// 1. 确保子游戏 zip 已下载解压(缓存命中直接复用,未命中下载 + 解压 + 原子 rename)
|
||||
splash.update(text: "准备子游戏...", progress: nil)
|
||||
_ = try await SubGameDownloader.shared.ensureReady(
|
||||
request: request,
|
||||
onProgress: { [weak self] p in
|
||||
Task { @MainActor in
|
||||
self?.splash.update(
|
||||
text: String(format: "下载子游戏 %d%%", Int(p * 100)),
|
||||
progress: p
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 2. 写 4 个 app_*.js(containerRole = .subGame)。BatteryMonitor / NetworkMonitor
|
||||
// 单例已由大厅启动,此处仅复用其 currentXxx。
|
||||
@@ -187,6 +191,7 @@ public final class SubGameViewController: UIViewController {
|
||||
|
||||
// 3. 加载子游戏 H5(allowingReadAccessTo 给 subGameRoot 才能跨目录引用资源)
|
||||
splash.update(text: "进入子游戏...", progress: nil)
|
||||
let indexURL = SandboxPaths.subGameIndex(request.gameDir, request.gameStart)
|
||||
bridgedWebView.webView.loadFileURL(
|
||||
indexURL,
|
||||
allowingReadAccessTo: SandboxPaths.subGameRoot(request.gameDir)
|
||||
@@ -239,23 +244,12 @@ public final class SubGameViewController: UIViewController {
|
||||
AppLifecycleObserver.shared.onForeground = nil
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
private enum SubGameBootError: LocalizedError {
|
||||
case subGameNotInstalled(dir: String, downloadURL: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .subGameNotInstalled(let dir, _):
|
||||
return "子游戏未安装(\(dir)),下载流程待 Phase 6.B 接入"
|
||||
}
|
||||
}
|
||||
}
|
||||
// MARK: - Error alert
|
||||
|
||||
private func showFatalAlert(error: Error) {
|
||||
let alert = UIAlertController(
|
||||
title: "加载失败",
|
||||
message: "\(error.localizedDescription)",
|
||||
message: "\(error)",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "返回大厅", style: .default) { [weak self] _ in
|
||||
|
||||
Reference in New Issue
Block a user