diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index 91d957d..3729605 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -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 diff --git a/ylgamehall/Source/Resource/SubGameDownloader.swift b/ylgamehall/Source/Resource/SubGameDownloader.swift new file mode 100644 index 0000000..27311a2 --- /dev/null +++ b/ylgamehall/Source/Resource/SubGameDownloader.swift @@ -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 + } +} diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift index 102fb52..535bdf3 100644 --- a/ylgamehall/Source/WebView/SubGameViewController.swift +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -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