diff --git a/ylgamehall/Source/Bridge/Handlers/LocalAudioHandler.swift b/ylgamehall/Source/Bridge/Handlers/LocalAudioHandler.swift index 77a57e7..ec5eeba 100644 --- a/ylgamehall/Source/Bridge/Handlers/LocalAudioHandler.swift +++ b/ylgamehall/Source/Bridge/Handlers/LocalAudioHandler.swift @@ -22,9 +22,13 @@ public enum LocalAudioHandler { /// 注册 srcIsloop handler。 /// - Parameters: /// - bridge: 桥接器 - /// - assetsRoot: 该容器(lobby / subGame)的 H5 资源根目录; - /// 音频文件实际路径会拼接 `assets/wav/{src}` 在该根下查找。 - public static func register(on bridge: any BridgeProtocol, assetsRoot: URL) { + /// - assetsRoot: 闭包,返回该容器(lobby / subGame)的 H5 资源根目录; + /// 每次 srcIsloop 调用时调一次,让子游戏 zip 升级(effectiveGameDir 末尾追加 "1") + /// 后能立刻拿到新目录路径。音频文件实际路径会拼接 `assets/wav/{src}` 在该根下查找。 + public static func register( + on bridge: any BridgeProtocol, + assetsRoot: @escaping @MainActor @Sendable () -> URL + ) { // 【3】srcIsloop — 本地音频播放 // 入参 src : string 音频文件名(位于 {assetsRoot}/assets/wav/{src}) // 入参 isloop : int 0 单次 / 1 循环背景 / -1 停同名循环 @@ -39,7 +43,7 @@ public enum LocalAudioHandler { return } - let url = assetsRoot + let url = await assetsRoot() .appendingPathComponent("assets") .appendingPathComponent("wav") .appendingPathComponent(src) diff --git a/ylgamehall/Source/Resource/SubGameDirectoryStore.swift b/ylgamehall/Source/Resource/SubGameDirectoryStore.swift new file mode 100644 index 0000000..3ba58a4 --- /dev/null +++ b/ylgamehall/Source/Resource/SubGameDirectoryStore.swift @@ -0,0 +1,44 @@ +// +// SubGameDirectoryStore.swift +// ylgamehall +// +// 子游戏目录名持久化映射:H5 传的 Gamedirectory(也是 gameStart 同名)→ 实际沙盒 +// 目录名。与 daoqi msext gameController.m:1414 / 2183-2188 等价: +// - 首次安装:actualDir = directory,写入 NSUserDefaults +// - 每次 zip 升级(远端 game_version > 本地 version.xml):actualDir 末尾追加 "1", +// 新版本解压到新目录,旧目录残留在沙盒(msext 同款"追加而非原子替换"策略) +// +// 存储约定:UserDefaults key = "ylgh.subGameDir.",value = 实际目录名 +// + +import Foundation + +/// UserDefaults 线程安全,所有方法 nonisolated 让 actor / MainActor / 后台线程都能直接调用。 +public enum SubGameDirectoryStore { + + private static let keyPrefix = "ylgh.subGameDir." + + /// 当前实际目录名。已持久化 → 返回持久化值;未持久化 → 返回 directory 本身 + /// (对应 msext NSUserDefaults 缺 key 时 gamefilepath = gamename 的默认)。 + nonisolated public static func current(forKey directory: String) -> String { + UserDefaults.standard.string(forKey: keyPrefix + directory) ?? directory + } + + /// 触发升级:末尾追加 "1",写回 UserDefaults,返回新值。 + /// msext gameController.m:1414 等价 `gamefilepath = [NSString stringWithFormat:@"%@1", gamefilepath]`。 + @discardableResult + nonisolated public static func advance(forKey directory: String) -> String { + let next = current(forKey: directory) + "1" + UserDefaults.standard.set(next, forKey: keyPrefix + directory) + return next + } + + /// 首次访问时把 directory 自身写入(与 msext gameController.m:2187 + /// SaveDefaultInfo 等价)。后续 advance 会基于此值追加。 + nonisolated public static func saveInitialIfNeeded(forKey directory: String) { + let k = keyPrefix + directory + if UserDefaults.standard.string(forKey: k) == nil { + UserDefaults.standard.set(directory, forKey: k) + } + } +} diff --git a/ylgamehall/Source/Resource/SubGameDownloader.swift b/ylgamehall/Source/Resource/SubGameDownloader.swift index 68c662e..8ab3952 100644 --- a/ylgamehall/Source/Resource/SubGameDownloader.swift +++ b/ylgamehall/Source/Resource/SubGameDownloader.swift @@ -2,15 +2,21 @@ // SubGameDownloader.swift // ylgamehall // -// 子游戏 zip 下载 + 解压:与 msext gameController.m:1340-1401 等价。 -// - 缓存命中(`{Caches}/{gameDir}/{gameStart}/index.html` 已在)→ 直接复用 -// - 缓存未命中 → URLSession 下载 → 解压到 staging → 原子 rename +// 子游戏 zip 下载 + 解压 + 升级。与 daoqi msext gameController.m 路径等价: // -// 与 LobbyZipUpgrader 思路一致;不复用是因为目录路径/版本判定/缓存策略不同 -// (子游戏只看是否存在,不比版本号;msext 子游戏升级走另一条 uplevel 路径, -// 本项目暂未接入 — 待业务确认远端是否真有 sub-game 版本下发)。 +// 1) 目录不存在(首装): +// 下载 → 解压 → rename 到 `{Caches}/{currentDir}/`,currentDir 不变 +// (msext: line 1243-1245 + uplevel:YES → downFileFromServer) // -// 详见 docs/Development-Plan.md §5.6 / §6.7。 +// 2) 目录存在 + 远端 game_version > 本地 version.xml(升级): +// advance directory(末尾追加 "1" → newDir,写 UserDefaults)→ 下载 → +// 解压 → rename 到 `{Caches}/{newDir}/`,旧目录残留 +// (msext: gameController.m:1410-1428 uplevel:download: 路径) +// +// 3) 目录存在 + 远端 ≤ 本地:直接复用 currentDir +// (msext: initView) +// +// 与 LobbyZipUpgrader 思路一致;不复用是因为目录持久化 + advance 策略 sub-game 专有。 // import Foundation @@ -21,10 +27,18 @@ public actor SubGameDownloader { public static let shared = SubGameDownloader() public enum Outcome: Sendable { - /// 缓存命中,未下载 - case alreadyExists - /// 完成下载 + 解压(zipBytes 为下载字节数,便于日志 / 测试) - case downloaded(zipBytes: Int64) + /// 缓存命中、远端版本未提升 → 直接复用 currentDir + case alreadyExists(actualDirectory: String) + /// 完成下载 + 解压。`fromVersion = 0` 表示首装。 + case upgraded(actualDirectory: String, from: Int, to: Int, zipBytes: Int64) + + /// 实际生效的子游戏目录名(持久化值,可能与 H5 传的 directory 不同)。 + public var actualDirectory: String { + switch self { + case .alreadyExists(let dir): return dir + case .upgraded(let dir, _, _, _): return dir + } + } } public enum DownloadError: Error, Sendable { @@ -39,9 +53,8 @@ public actor SubGameDownloader { public init() {} /// URLSession 配置:60s 单请求 / 300s 总体(zip 体量约 1-10 MB)。 - /// 每次 ensureReady 用此 config 临时建 session,绑 download delegate 接收 - /// didWriteData 进度回调;ad-hoc delegate(`session.download(from:delegate:)`) - /// 不会触发 URLSessionDownloadDelegate 的 download-specific 方法,必须走 session 级。 + /// 每次下载用此 config 临时建 session,绑 download delegate 接收 didWriteData + /// 进度回调;ad-hoc delegate 不会触发 download-specific 方法,必须走 session 级。 nonisolated private static var defaultConfig: URLSessionConfiguration { let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 60 @@ -49,27 +62,83 @@ public actor SubGameDownloader { return cfg } - /// 确保 `{Caches}/{request.gameDir}/{request.gameStart}/index.html` 已就绪。 + /// 准备子游戏 H5 资源。 /// /// - Parameters: - /// - request: SwitchOverGameData 解出的入参 - /// - onProgress: 0…1 进度,URLSession delegate 后台线程派发;UI 层负责 hop MainActor - /// - Returns: `.alreadyExists` 命中缓存;`.downloaded(zipBytes:)` 完成下载 + /// - directoryKey: H5 传的 Gamedirectory 字面值,作为 UserDefaults 持久化 key + /// - gameStart: H5 子游戏入口(msext 的 self.game_name,与 directoryKey 同名) + /// - remoteVersion: 远端 ResolvedVersion.gameVersion(VersionResolver 算出的) + /// - remoteZipURL: 远端 ResolvedVersion.gameZip 真实下载 URL + /// - onProgress: 0…1 进度回调,URLSession delegate 后台线程派发 + /// - Returns: Outcome,外层据其 `actualDirectory` 构造 loadFileURL 路径 public func ensureReady( - request: SubGameRequest, + directoryKey: String, + gameStart: String, + remoteVersion: Int, + remoteZipURL: String, onProgress: @escaping @Sendable (Double) -> Void = { _ in } ) async throws -> Outcome { + // 首次访问把 directory 自身写入 UserDefaults(msext gameController.m:2187 等价) + SubGameDirectoryStore.saveInitialIfNeeded(forKey: directoryKey) + let currentDir = SubGameDirectoryStore.current(forKey: directoryKey) + let fm = FileManager.default - let indexPath = SandboxPaths.subGameIndex(request.gameDir, request.gameStart).path - if fm.fileExists(atPath: indexPath) { - return .alreadyExists + let indexPath = SandboxPaths.subGameIndex(currentDir, gameStart).path + let indexExists = fm.fileExists(atPath: indexPath) + + // 分支 1:目录不存在(首装)—— msext line 1242-1246 等价。 + // 下载到 currentDir,不 advance(advance 是升级路径专有)。 + if !indexExists { + let zipBytes = try await download( + to: currentDir, + zipURLString: remoteZipURL, + onProgress: onProgress + ) + return .upgraded( + actualDirectory: currentDir, + from: 0, + to: remoteVersion, + zipBytes: zipBytes + ) } - guard !request.downloadURL.isEmpty else { + // 分支 2 / 3:目录存在 —— 读本地 version.xml,按远端对比。 + let localVer = LocalVersionReader.subGameVersion(dir: currentDir, start: gameStart) + guard remoteVersion > localVer else { + // 分支 3:远端 ≤ 本地 → 复用(msext: initView 直接加载) + return .alreadyExists(actualDirectory: currentDir) + } + + // 分支 2:升级 —— advance 后下载到新目录。 + // msext gameController.m:1414-1422 等价 + let newDir = SubGameDirectoryStore.advance(forKey: directoryKey) + let zipBytes = try await download( + to: newDir, + zipURLString: remoteZipURL, + onProgress: onProgress + ) + return .upgraded( + actualDirectory: newDir, + from: localVer, + to: remoteVersion, + zipBytes: zipBytes + ) + } + + // MARK: - 私有:下载 + 解压 + rename 一体化 + + /// 下载远端 zip → 解压到 staging → 原子 rename 到 `{Caches}/{actualDirectory}/`。 + /// 返回下载字节数。 + private func download( + to actualDirectory: String, + zipURLString: String, + onProgress: @escaping @Sendable (Double) -> Void + ) async throws -> Int64 { + guard !zipURLString.isEmpty else { throw DownloadError.missingDownloadURL } - guard let zipURL = URL(string: request.downloadURL) else { - throw DownloadError.badDownloadURL(request.downloadURL) + guard let zipURL = URL(string: zipURLString) else { + throw DownloadError.badDownloadURL(zipURLString) } // 1. 下载到 tmp。delegate 必须在 URLSession init 时绑定,否则 @@ -98,6 +167,7 @@ public actor SubGameDownloader { } onProgress(1.0) + let fm = FileManager.default let zipBytes = (try? fm.attributesOfItem(atPath: downloadedURL.path)[.size] as? NSNumber)? .int64Value ?? 0 @@ -113,21 +183,21 @@ public actor SubGameDownloader { } 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) + // 3. 原子 rename:删旧目标 → 把 staging 移到位 + // zip 内部已含 `{gameStart}/` 子目录,解压到 staging 后整体 move 到 + // `{Caches}/{actualDirectory}/`,结果就是 `{actualDirectory}/{gameStart}/index.html` + let target = SandboxPaths.subGameRoot(actualDirectory) do { - if fm.fileExists(atPath: subGameRoot.path) { - try fm.removeItem(at: subGameRoot) + if fm.fileExists(atPath: target.path) { + try fm.removeItem(at: target) } - try fm.moveItem(at: staging, to: subGameRoot) + try fm.moveItem(at: staging, to: target) } catch { try? fm.removeItem(at: staging) throw DownloadError.stagingMoveFailed(error) } - return .downloaded(zipBytes: zipBytes) + return zipBytes } } diff --git a/ylgamehall/Source/WebView/SubGameViewController.swift b/ylgamehall/Source/WebView/SubGameViewController.swift index 601457d..cc1d34a 100644 --- a/ylgamehall/Source/WebView/SubGameViewController.swift +++ b/ylgamehall/Source/WebView/SubGameViewController.swift @@ -21,6 +21,16 @@ public final class SubGameViewController: UIViewController { private let request: SubGameRequest + // MARK: - 实际生效的子游戏目录名 + // + // init 时从 SubGameDirectoryStore 同步读:首次 = request.gameDir,已升级 = 末尾追加 + // 若干 "1" 的持久化值(msext gameController.m:1414 等价的 advance 链)。 + // boot pipeline 内 SubGameDownloader.ensureReady 若触发升级(远端 game_version > + // 本地 version.xml),返回的 actualDirectory 会更新本属性。所有资源路径(loadFileURL / + // AppDataWriter / srcIsloop assets/wav)必须走它,而不是 request.gameDir。 + + private var effectiveGameDir: String + // MARK: - UI private let bridgedWebView = BridgedWebView() @@ -38,6 +48,8 @@ public final class SubGameViewController: UIViewController { public init(request: SubGameRequest) { self.request = request + // 同步读持久化目录名,registerBridgeHandlers 的 closure 捕获 self 即可读到当前值 + self.effectiveGameDir = SubGameDirectoryStore.current(forKey: request.gameDir) super.init(nibName: nil, bundle: nil) } @@ -78,11 +90,18 @@ public final class SubGameViewController: UIViewController { OpenSaomaHandler.register(on: bridge) StartLocationHandler.register(on: bridge) - // assetsRoot = 子游戏 H5 根目录(msext gameController.m:364 等价) + // assetsRoot 闭包 = 子游戏 H5 根目录(msext gameController.m:364 等价)。 + // 用 closure 是因为 effectiveGameDir 在 boot pipeline 升级路径下会变化, + // 必须运行时读最新值,否则升级后 srcIsloop 仍指旧目录。 LocalAudioHandler.register( on: bridge, - assetsRoot: SandboxPaths.subGameIndex(request.gameDir, request.gameStart) - .deletingLastPathComponent() + assetsRoot: { [weak self] in + guard let self else { + return SandboxPaths.caches + } + return SandboxPaths.subGameIndex(self.effectiveGameDir, self.request.gameStart) + .deletingLastPathComponent() + } ) RemoteAudioHandler.register(on: bridge) @@ -176,20 +195,21 @@ public final class SubGameViewController: UIViewController { // 需要做两次 resolve: // - lobbyResolved(gameId = bc.gameId)→ 给 AppDataWriter 算 app_appversion 审核标志 // 与 msext NewRootVC 算 result 后 initinfo 传给 gameController 等价 - // - subGameResolved(gameId = H5 传的 gameid)→ 拿真实 game_zip URL + // - subGameResolved(gameId = H5 传的 gameid)→ 拿 game_version 和 game_zip URL // 与 msext gameController.uplevel: 等价 - let (lobbyResolved, subGameZipURL) = try await resolveBoot() - let resolvedRequest = SubGameRequest( - gameDir: request.gameDir, - gameStart: request.gameStart, - downloadURL: subGameZipURL, - webData: request.webData - ) + let (lobbyResolved, subGameResolved) = try await resolveBoot() + let subGameZipURL = subGameResolved.gameZip ?? "" - // 1. 确保子游戏 zip 已下载解压(缓存命中直接复用,未命中下载 + 解压 + 原子 rename) + // 1. 准备子游戏 zip(msext gameController.m:1239-1255 等价): + // - 目录不存在(首装):下载到 currentDir + // - 目录存在 + 远端 > 本地:advance + 下载到新目录 + // - 远端 ≤ 本地:复用 currentDir splash.update(text: "准备子游戏...", progress: nil) - _ = try await SubGameDownloader.shared.ensureReady( - request: resolvedRequest, + let outcome = try await SubGameDownloader.shared.ensureReady( + directoryKey: request.gameDir, + gameStart: request.gameStart, + remoteVersion: subGameResolved.gameVersion, + remoteZipURL: subGameZipURL, onProgress: { [weak self] p in Task { @MainActor in self?.splash.update( @@ -200,14 +220,20 @@ public final class SubGameViewController: UIViewController { } ) + // ensureReady 内可能 advance 了目录名,同步到本地状态。 + // 此后所有资源路径(loadFileURL / AppDataWriter / srcIsloop closure)都走它。 + effectiveGameDir = outcome.actualDirectory + // 2. 写 4 个 app_*.js(containerRole = .subGame)。BatteryMonitor / NetworkMonitor // 单例已由大厅启动,此处仅复用其 currentXxx。 // resolvedVersion 必须传大厅视角的版本判定结果,让 app_appversion 审核标志 // 与大厅一致(msext result_state 传递路径等价)。 + // containerRole.dir 传 effectiveGameDir,让 app_gamedir = msext gamefilepath + // (升级后的"xxx1"形式),与 msext app_data.js:2160 等价。 let writer = AppDataWriter( bundleConfig: .shared, resolvedVersion: lobbyResolved, - containerRole: .subGame(name: request.gameStart, dir: request.gameDir) + containerRole: .subGame(name: request.gameStart, dir: effectiveGameDir) ) try writer.writeInitial() try writer.writeBattery(BatteryMonitor.shared.currentLevel) @@ -215,17 +241,17 @@ public final class SubGameViewController: UIViewController { // 3. 加载子游戏 H5(allowingReadAccessTo 给 subGameRoot 才能跨目录引用资源) splash.update(text: "进入子游戏...", progress: nil) - let indexURL = SandboxPaths.subGameIndex(request.gameDir, request.gameStart) + let indexURL = SandboxPaths.subGameIndex(effectiveGameDir, request.gameStart) bridgedWebView.webView.loadFileURL( indexURL, - allowingReadAccessTo: SandboxPaths.subGameRoot(request.gameDir) + allowingReadAccessTo: SandboxPaths.subGameRoot(effectiveGameDir) ) } /// 子游戏启动期两次 resolve: /// - lobby 视角(gameId = bc.gameId)给 app_appversion 审核标志(msext result_state 等价) - /// - sub-game 视角(gameId = H5 传的 gameid)给真实 game_zip 下载 URL(msext gameController.uplevel: 等价) - private func resolveBoot() async throws -> (lobbyResolved: ResolvedVersion, gameZipURL: String) { + /// - sub-game 视角(gameId = H5 传的 gameid)给 game_version + game_zip URL(msext gameController.uplevel: 等价) + private func resolveBoot() async throws -> (lobbyResolved: ResolvedVersion, subGameResolved: ResolvedVersion) { guard let cfg = await RemoteConfigClient.shared.current() else { throw SubGameBootError.remoteConfigUnavailable } @@ -244,10 +270,17 @@ public final class SubGameViewController: UIViewController { marketId: bc.market, gameId: request.downloadURL // ← H5 字段名 url,实为 gameid ) - guard let zipURL = subGameResolved.gameZip, !zipURL.isEmpty else { + // 远端必须给出 game_zip URL,否则 SubGameDownloader 首装路径无法落地。 + // 已有本地缓存且远端不需要升级时(≤本地版本),URL 空也允许 → 让 ensureReady 走复用分支。 + let zip = subGameResolved.gameZip ?? "" + let localVer = LocalVersionReader.subGameVersion( + dir: SubGameDirectoryStore.current(forKey: request.gameDir), + start: request.gameStart + ) + if zip.isEmpty && subGameResolved.gameVersion > localVer { throw SubGameBootError.zipURLNotFound(gameId: request.downloadURL) } - return (lobbyResolved, zipURL) + return (lobbyResolved, subGameResolved) } enum SubGameBootError: Error, CustomStringConvertible { diff --git a/ylgamehall/Source/WebView/WebContainerViewController.swift b/ylgamehall/Source/WebView/WebContainerViewController.swift index a18f8e7..217893b 100644 --- a/ylgamehall/Source/WebView/WebContainerViewController.swift +++ b/ylgamehall/Source/WebView/WebContainerViewController.swift @@ -74,10 +74,11 @@ public final class WebContainerViewController: UIViewController { StartLocationHandler.register(on: bridge) // §3.1 [20]Phase 5 完整实现,Phase 2 stub // Phase 3.A 本地音频 + 3.C/3.D stub - // assetsRoot = 大厅 H5 根目录,srcIsloop 拼 assets/wav/{src} 在该根下查找 + // assetsRoot 闭包 = 大厅 H5 根目录(lobbyIndex 父目录),lobby 不做升级 + // 路径不变,但 closure 形式保持与子游戏对称 LocalAudioHandler.register( on: bridge, - assetsRoot: SandboxPaths.lobbyIndex.deletingLastPathComponent() + assetsRoot: { SandboxPaths.lobbyIndex.deletingLastPathComponent() } ) // §3.1 [3]srcIsloop 完整实现 RemoteAudioHandler.register(on: bridge) // §3.1 [4][5]Phase 3.B/3.C/3.D stub