子游戏 zip 升级链路完整对齐 msext(持久化目录 + advance + 版本比对)

之前新外壳的 SubGameDownloader 只看 index.html 是否存在,缺这两个关键行为:
- gamefilepath 持久化(msext NSUserDefaults["game_name"] → gamefilepath)
- 远端 game_version > 本地 version.xml 时升级 + 目录末尾追加 "1"
  (msext gameController.m:1414 等价 [NSString stringWithFormat:@"%@1", gamefilepath])

本次完整补齐:

1. 新增 SubGameDirectoryStore:UserDefaults 持久化 directory → actualDir 映射
   - current(forKey:) / advance(forKey:) / saveInitialIfNeeded(forKey:)
   - nonisolated 让 actor / MainActor 都能直接调

2. SubGameDownloader 三分支决策(msext gameController.m:1239-1255 + uplevel: 等价):
   - 目录不存在(首装)→ 下载到 currentDir,不 advance
   - 目录存在 + 远端 > 本地 → advance + 下载到新目录
   - 远端 ≤ 本地 → 复用 currentDir
   API 改为按入参(directoryKey / gameStart / remoteVersion / remoteZipURL)调用,
   返回 Outcome.actualDirectory 暴露实际生效目录名。

3. LocalAudioHandler.register 的 assetsRoot 改 @MainActor closure
   srcIsloop 每次调用时取最新值,让子游戏升级(effectiveGameDir 末尾追加 "1")
   后下一次 srcIsloop 自动用新目录路径。

4. SubGameViewController 引入 effectiveGameDir:
   - init 时同步从 SubGameDirectoryStore 读初始值(首次=request.gameDir)
   - boot pipeline 内调 ensureReady,从 outcome 同步升级后的新目录名
   - loadFileURL / AppDataWriter / srcIsloop closure 全部走 effectiveGameDir
   - resolveBoot 返回 (lobbyResolved, subGameResolved) 完整 ResolvedVersion,
     给 ensureReady 用 gameVersion 做版本比对

5. WebContainerViewController 大厅侧 LocalAudioHandler 改 closure 形式(路径不变)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-23 23:04:57 +08:00
co-authored by Claude Opus 4.7
parent 18fd782156
commit 7be7a2abbc
5 changed files with 212 additions and 60 deletions
@@ -22,9 +22,13 @@ public enum LocalAudioHandler {
/// srcIsloop handler /// srcIsloop handler
/// - Parameters: /// - Parameters:
/// - bridge: /// - bridge:
/// - assetsRoot: lobby / subGame H5 /// - assetsRoot: lobby / subGame H5
/// `assets/wav/{src}` /// srcIsloop zip effectiveGameDir "1"
public static func register(on bridge: any BridgeProtocol, assetsRoot: URL) { /// `assets/wav/{src}`
public static func register(
on bridge: any BridgeProtocol,
assetsRoot: @escaping @MainActor @Sendable () -> URL
) {
// 3srcIsloop // 3srcIsloop
// src : string {assetsRoot}/assets/wav/{src} // src : string {assetsRoot}/assets/wav/{src}
// isloop : int 0 / 1 / -1 // isloop : int 0 / 1 / -1
@@ -39,7 +43,7 @@ public enum LocalAudioHandler {
return return
} }
let url = assetsRoot let url = await assetsRoot()
.appendingPathComponent("assets") .appendingPathComponent("assets")
.appendingPathComponent("wav") .appendingPathComponent("wav")
.appendingPathComponent(src) .appendingPathComponent(src)
@@ -0,0 +1,44 @@
//
// SubGameDirectoryStore.swift
// ylgamehall
//
// H5 Gamedirectory gameStart
// daoqi msext gameController.m:1414 / 2183-2188
// - actualDir = directory NSUserDefaults
// - zip game_version > version.xmlactualDir "1"
// msext ""
//
// UserDefaults key = "ylgh.subGameDir.<directory>"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)
}
}
}
@@ -2,15 +2,21 @@
// SubGameDownloader.swift // SubGameDownloader.swift
// ylgamehall // ylgamehall
// //
// zip + msext gameController.m:1340-1401 // zip + + daoqi msext gameController.m
// - `{Caches}/{gameDir}/{gameStart}/index.html`
// - URLSession staging rename
// //
// LobbyZipUpgrader // // 1)
// msext uplevel // rename `{Caches}/{currentDir}/`currentDir
// sub-game // 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 import Foundation
@@ -21,10 +27,18 @@ public actor SubGameDownloader {
public static let shared = SubGameDownloader() public static let shared = SubGameDownloader()
public enum Outcome: Sendable { public enum Outcome: Sendable {
/// /// currentDir
case alreadyExists case alreadyExists(actualDirectory: String)
/// + zipBytes 便 / /// + `fromVersion = 0`
case downloaded(zipBytes: Int64) 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 { public enum DownloadError: Error, Sendable {
@@ -39,9 +53,8 @@ public actor SubGameDownloader {
public init() {} public init() {}
/// URLSession 60s / 300s zip 1-10 MB /// URLSession 60s / 300s zip 1-10 MB
/// ensureReady config session download delegate /// config session download delegate didWriteData
/// didWriteData ad-hoc delegate`session.download(from:delegate:)` /// ad-hoc delegate download-specific session
/// URLSessionDownloadDelegate download-specific session
nonisolated private static var defaultConfig: URLSessionConfiguration { nonisolated private static var defaultConfig: URLSessionConfiguration {
let cfg = URLSessionConfiguration.default let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 60 cfg.timeoutIntervalForRequest = 60
@@ -49,27 +62,83 @@ public actor SubGameDownloader {
return cfg return cfg
} }
/// `{Caches}/{request.gameDir}/{request.gameStart}/index.html` /// H5
/// ///
/// - Parameters: /// - Parameters:
/// - request: SwitchOverGameData /// - directoryKey: H5 Gamedirectory UserDefaults key
/// - onProgress: 01 URLSession delegate 线UI hop MainActor /// - gameStart: H5 msext self.game_name directoryKey
/// - Returns: `.alreadyExists` `.downloaded(zipBytes:)` /// - remoteVersion: ResolvedVersion.gameVersionVersionResolver
/// - remoteZipURL: ResolvedVersion.gameZip URL
/// - onProgress: 01 URLSession delegate 线
/// - Returns: Outcome `actualDirectory` loadFileURL
public func ensureReady( public func ensureReady(
request: SubGameRequest, directoryKey: String,
gameStart: String,
remoteVersion: Int,
remoteZipURL: String,
onProgress: @escaping @Sendable (Double) -> Void = { _ in } onProgress: @escaping @Sendable (Double) -> Void = { _ in }
) async throws -> Outcome { ) async throws -> Outcome {
// 访 directory UserDefaultsmsext gameController.m:2187
SubGameDirectoryStore.saveInitialIfNeeded(forKey: directoryKey)
let currentDir = SubGameDirectoryStore.current(forKey: directoryKey)
let fm = FileManager.default let fm = FileManager.default
let indexPath = SandboxPaths.subGameIndex(request.gameDir, request.gameStart).path let indexPath = SandboxPaths.subGameIndex(currentDir, gameStart).path
if fm.fileExists(atPath: indexPath) { let indexExists = fm.fileExists(atPath: indexPath)
return .alreadyExists
// 1 msext line 1242-1246
// currentDir advanceadvance
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 throw DownloadError.missingDownloadURL
} }
guard let zipURL = URL(string: request.downloadURL) else { guard let zipURL = URL(string: zipURLString) else {
throw DownloadError.badDownloadURL(request.downloadURL) throw DownloadError.badDownloadURL(zipURLString)
} }
// 1. tmpdelegate URLSession init // 1. tmpdelegate URLSession init
@@ -98,6 +167,7 @@ public actor SubGameDownloader {
} }
onProgress(1.0) onProgress(1.0)
let fm = FileManager.default
let zipBytes = (try? fm.attributesOfItem(atPath: downloadedURL.path)[.size] as? NSNumber)? let zipBytes = (try? fm.attributesOfItem(atPath: downloadedURL.path)[.size] as? NSNumber)?
.int64Value ?? 0 .int64Value ?? 0
@@ -113,21 +183,21 @@ public actor SubGameDownloader {
} }
try? fm.removeItem(at: downloadedURL) try? fm.removeItem(at: downloadedURL)
// 3. rename subGameRoot staging // 3. rename staging
// msext zip `{gameStart}/` staging // zip `{gameStart}/` staging move
// move `{Caches}/{gameDir}/` `{gameDir}/{gameStart}/index.html` // `{Caches}/{actualDirectory}/` `{actualDirectory}/{gameStart}/index.html`
let subGameRoot = SandboxPaths.subGameRoot(request.gameDir) let target = SandboxPaths.subGameRoot(actualDirectory)
do { do {
if fm.fileExists(atPath: subGameRoot.path) { if fm.fileExists(atPath: target.path) {
try fm.removeItem(at: subGameRoot) try fm.removeItem(at: target)
} }
try fm.moveItem(at: staging, to: subGameRoot) try fm.moveItem(at: staging, to: target)
} catch { } catch {
try? fm.removeItem(at: staging) try? fm.removeItem(at: staging)
throw DownloadError.stagingMoveFailed(error) throw DownloadError.stagingMoveFailed(error)
} }
return .downloaded(zipBytes: zipBytes) return zipBytes
} }
} }
@@ -21,6 +21,16 @@ public final class SubGameViewController: UIViewController {
private let request: SubGameRequest 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 // MARK: - UI
private let bridgedWebView = BridgedWebView() private let bridgedWebView = BridgedWebView()
@@ -38,6 +48,8 @@ public final class SubGameViewController: UIViewController {
public init(request: SubGameRequest) { public init(request: SubGameRequest) {
self.request = request self.request = request
// registerBridgeHandlers closure self
self.effectiveGameDir = SubGameDirectoryStore.current(forKey: request.gameDir)
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@@ -78,11 +90,18 @@ public final class SubGameViewController: UIViewController {
OpenSaomaHandler.register(on: bridge) OpenSaomaHandler.register(on: bridge)
StartLocationHandler.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( LocalAudioHandler.register(
on: bridge, on: bridge,
assetsRoot: SandboxPaths.subGameIndex(request.gameDir, request.gameStart) assetsRoot: { [weak self] in
.deletingLastPathComponent() guard let self else {
return SandboxPaths.caches
}
return SandboxPaths.subGameIndex(self.effectiveGameDir, self.request.gameStart)
.deletingLastPathComponent()
}
) )
RemoteAudioHandler.register(on: bridge) RemoteAudioHandler.register(on: bridge)
@@ -176,20 +195,21 @@ public final class SubGameViewController: UIViewController {
// resolve // resolve
// - lobbyResolvedgameId = bc.gameId AppDataWriter app_appversion // - lobbyResolvedgameId = bc.gameId AppDataWriter app_appversion
// msext NewRootVC result initinfo gameController // msext NewRootVC result initinfo gameController
// - subGameResolvedgameId = H5 gameid game_zip URL // - subGameResolvedgameId = H5 gameid game_version game_zip URL
// msext gameController.uplevel: // msext gameController.uplevel:
let (lobbyResolved, subGameZipURL) = try await resolveBoot() let (lobbyResolved, subGameResolved) = try await resolveBoot()
let resolvedRequest = SubGameRequest( let subGameZipURL = subGameResolved.gameZip ?? ""
gameDir: request.gameDir,
gameStart: request.gameStart,
downloadURL: subGameZipURL,
webData: request.webData
)
// 1. zip + + rename // 1. zipmsext gameController.m:1239-1255
// - currentDir
// - + > advance +
// - currentDir
splash.update(text: "准备子游戏...", progress: nil) splash.update(text: "准备子游戏...", progress: nil)
_ = try await SubGameDownloader.shared.ensureReady( let outcome = try await SubGameDownloader.shared.ensureReady(
request: resolvedRequest, directoryKey: request.gameDir,
gameStart: request.gameStart,
remoteVersion: subGameResolved.gameVersion,
remoteZipURL: subGameZipURL,
onProgress: { [weak self] p in onProgress: { [weak self] p in
Task { @MainActor in Task { @MainActor in
self?.splash.update( self?.splash.update(
@@ -200,14 +220,20 @@ public final class SubGameViewController: UIViewController {
} }
) )
// ensureReady advance
// loadFileURL / AppDataWriter / srcIsloop closure
effectiveGameDir = outcome.actualDirectory
// 2. 4 app_*.jscontainerRole = .subGameBatteryMonitor / NetworkMonitor // 2. 4 app_*.jscontainerRole = .subGameBatteryMonitor / NetworkMonitor
// currentXxx // currentXxx
// resolvedVersion app_appversion // resolvedVersion app_appversion
// msext result_state // msext result_state
// containerRole.dir effectiveGameDir app_gamedir = msext gamefilepath
// "xxx1" msext app_data.js:2160
let writer = AppDataWriter( let writer = AppDataWriter(
bundleConfig: .shared, bundleConfig: .shared,
resolvedVersion: lobbyResolved, resolvedVersion: lobbyResolved,
containerRole: .subGame(name: request.gameStart, dir: request.gameDir) containerRole: .subGame(name: request.gameStart, dir: effectiveGameDir)
) )
try writer.writeInitial() try writer.writeInitial()
try writer.writeBattery(BatteryMonitor.shared.currentLevel) try writer.writeBattery(BatteryMonitor.shared.currentLevel)
@@ -215,17 +241,17 @@ public final class SubGameViewController: UIViewController {
// 3. H5allowingReadAccessTo subGameRoot // 3. H5allowingReadAccessTo subGameRoot
splash.update(text: "进入子游戏...", progress: nil) splash.update(text: "进入子游戏...", progress: nil)
let indexURL = SandboxPaths.subGameIndex(request.gameDir, request.gameStart) let indexURL = SandboxPaths.subGameIndex(effectiveGameDir, request.gameStart)
bridgedWebView.webView.loadFileURL( bridgedWebView.webView.loadFileURL(
indexURL, indexURL,
allowingReadAccessTo: SandboxPaths.subGameRoot(request.gameDir) allowingReadAccessTo: SandboxPaths.subGameRoot(effectiveGameDir)
) )
} }
/// resolve /// resolve
/// - lobby gameId = bc.gameId app_appversion msext result_state /// - lobby gameId = bc.gameId app_appversion msext result_state
/// - sub-game gameId = H5 gameid game_zip URLmsext gameController.uplevel: /// - sub-game gameId = H5 gameid game_version + game_zip URLmsext gameController.uplevel:
private func resolveBoot() async throws -> (lobbyResolved: ResolvedVersion, gameZipURL: String) { private func resolveBoot() async throws -> (lobbyResolved: ResolvedVersion, subGameResolved: ResolvedVersion) {
guard let cfg = await RemoteConfigClient.shared.current() else { guard let cfg = await RemoteConfigClient.shared.current() else {
throw SubGameBootError.remoteConfigUnavailable throw SubGameBootError.remoteConfigUnavailable
} }
@@ -244,10 +270,17 @@ public final class SubGameViewController: UIViewController {
marketId: bc.market, marketId: bc.market,
gameId: request.downloadURL // H5 url gameid 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) throw SubGameBootError.zipURLNotFound(gameId: request.downloadURL)
} }
return (lobbyResolved, zipURL) return (lobbyResolved, subGameResolved)
} }
enum SubGameBootError: Error, CustomStringConvertible { enum SubGameBootError: Error, CustomStringConvertible {
@@ -74,10 +74,11 @@ public final class WebContainerViewController: UIViewController {
StartLocationHandler.register(on: bridge) // §3.1 20Phase 5 Phase 2 stub StartLocationHandler.register(on: bridge) // §3.1 20Phase 5 Phase 2 stub
// Phase 3.A + 3.C/3.D stub // Phase 3.A + 3.C/3.D stub
// assetsRoot = H5 srcIsloop assets/wav/{src} // assetsRoot = H5 lobbyIndex lobby
// closure
LocalAudioHandler.register( LocalAudioHandler.register(
on: bridge, on: bridge,
assetsRoot: SandboxPaths.lobbyIndex.deletingLastPathComponent() assetsRoot: { SandboxPaths.lobbyIndex.deletingLastPathComponent() }
) // §3.1 3srcIsloop ) // §3.1 3srcIsloop
RemoteAudioHandler.register(on: bridge) // §3.1 45Phase 3.B/3.C/3.D stub RemoteAudioHandler.register(on: bridge) // §3.1 45Phase 3.B/3.C/3.D stub