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:
joywayer
2026-06-22 21:01:53 +08:00
co-authored by Claude Opus 4.7
parent 7a5644053d
commit a9eaf86c63
3 changed files with 178 additions and 27 deletions
+8 -5
View File
@@ -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 SubGameHandlersbackgameData 留待 commit C
- [x] 6.4 SwitchOverGameData → AppCoordinator.showSubGame
- [ ] 6.5 backgameData
- [ ] 6.6 getWebdata 通知链
- [ ] 6.7 SubGameDownloader
- [x] 6.7 SubGameDownloaderactorURLSession + 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: 01 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_*.jscontainerRole = .subGameBatteryMonitor / NetworkMonitor
// currentXxx
@@ -187,6 +191,7 @@ public final class SubGameViewController: UIViewController {
// 3. H5allowingReadAccessTo 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