子游戏跳转链路修复 + H5 音频统一可用

修复后子游戏 SwitchOverGameData 跳转完全可用,行为对齐 daoqi msext:

【zip URL 解析】H5 字段 gamedownloadurl 实为 gameid(msext gameController.m:544
注释"游戏ID")。RemoteConfigClient 新增 lastParsed 缓存 + current() getter;
SubGameViewController.resolveBoot 做两次 VersionResolver.resolve:
  - lobby 视角(gameId=bc.gameId)→ ResolvedVersion 传 AppDataWriter 算
    app_appversion 审核标志(msext result_state 等价)
  - sub-game 视角(gameId=H5 传的 gameid)→ 真实 game_zip 下载 URL

之前 SubGameDownloader 直接把 token 当 URL 用,URLSession 报 -1002
unsupported URL;AppDataWriter resolvedVersion 始终 nil 导致子游戏 app_appversion
永远 0、丢失审核切换能力。

【push 前停大厅背景音】AppCoordinator.showSubGame 节流通过后、push 前调
AudioPlayer.stopAllBackground(msext NewRootVC.m:549-552 等价)。

【H5 音频】
- AVAudioSession.setCategory(.playback) 在 AppDelegate 启动期设置,让原生
  + WKWebView 内 <audio> 都不受静音键影响(msext RootVC.m:1302 等多处等价)
- WKWebViewConfiguration.mediaTypesRequiringUserActionForPlayback = [],子游戏
  push 后立即 loadFileURL 自播背景音不再被 WebKit 静默拦截
- LocalAudioHandler.register 增 assetsRoot 参数,srcIsloop 按容器各自的 H5
  根目录拼 assets/wav/{src}(msext gameController.m:364 等价,大厅 / 子游戏
  音频文件在各自 zip 内不重叠)。修前所有路径都指向大厅根,子游戏读不到自己的
  音频文件,AVAudioPlayer 报 OSStatus 2003334207 (wht?
  kAudioFileUnsupportedFileTypeError)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-23 21:33:46 +08:00
co-authored by Claude Opus 4.7
parent 053232ed26
commit e50dfb75af
7 changed files with 118 additions and 20 deletions
@@ -4,10 +4,14 @@
//
// H5 Native handlersrcIsloop /
// docs/H5-Native-Contract.md §3.1 3
// daoqi/msext NewRootVC.m initJSdata buttunPlayer / backgroundPlayer
//
// - daoqi/msext NewRootVC.m lobby gamedir/gamestart
// - daoqi/msext gameController.m:359-419 self.gamefilepath/self.game_name
// enum assetsRoot
//
// `{Caches}/{gamedir}/{gamestart}/assets/wav/{src}`
// SandboxPaths.lobbyIndex assets/wav
// `{assetsRoot}/assets/wav/{src}`
// - lobbyassetsRoot = SandboxPaths.lobbyIndex.deletingLastPathComponent()
// - subGameassetsRoot = SandboxPaths.subGameIndex(dir,name).deletingLastPathComponent()
//
import Foundation
@@ -15,9 +19,14 @@ import Foundation
@MainActor
public enum LocalAudioHandler {
public static func register(on bridge: any BridgeProtocol) {
/// srcIsloop handler
/// - Parameters:
/// - bridge:
/// - assetsRoot: lobby / subGame H5
/// `assets/wav/{src}`
public static func register(on bridge: any BridgeProtocol, assetsRoot: URL) {
// 3srcIsloop
// src : string lobbyAssets/assets/wav/{src}
// src : string {assetsRoot}/assets/wav/{src}
// isloop : int 0 / 1 / -1
// cb: "Response from srcIsloop"
bridge.register("srcIsloop") { data, callback in
@@ -30,7 +39,10 @@ public enum LocalAudioHandler {
return
}
let url = audioFileURL(src: src)
let url = assetsRoot
.appendingPathComponent("assets")
.appendingPathComponent("wav")
.appendingPathComponent(src)
switch isloop {
case 0:
@@ -44,14 +56,4 @@ public enum LocalAudioHandler {
}
}
}
/// `{lobbyAssets}/assets/wav/{src}` msext NewRootVC
/// H5 zip gamehall.zip assets/wav/*.wav
nonisolated private static func audioFileURL(src: String) -> URL {
SandboxPaths.lobbyIndex
.deletingLastPathComponent() // {Caches}/{gamedir}/{gamestart}/
.appendingPathComponent("assets")
.appendingPathComponent("wav")
.appendingPathComponent(src)
}
}
@@ -85,6 +85,10 @@ public final class AppCoordinator {
}
lastSwitchAt = Date()
// push msext NewRootVC.m:549-552
// AudioPlayer app stop loopBackground
AudioPlayer.shared.stopAllBackground()
let subGame = SubGameViewController(request: request)
nav.pushViewController(subGame, animated: true)
return true
@@ -176,6 +176,11 @@ public actor RemoteConfigClient {
private let urlBuilder: @Sendable () -> URL?
private let maxRetries: Int
/// `.parsed(...)` SubGameViewController
/// H5 gameid VersionResolver zip URL
/// `.shortText` ""
private var lastParsed: RemoteConfig?
public init(
session: URLSession = RemoteConfigClient.defaultSession,
urlBuilder: @escaping @Sendable () -> URL? = RemoteConfigClient.defaultURLBuilder,
@@ -186,6 +191,11 @@ public actor RemoteConfigClient {
self.maxRetries = maxRetries
}
/// fetch RemoteConfignil
public func current() -> RemoteConfig? {
lastParsed
}
/// URLSession 10 s
nonisolated public static var defaultSession: URLSession {
let cfg = URLSessionConfiguration.default
@@ -258,6 +268,7 @@ public actor RemoteConfigClient {
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let parsed = try decoder.decode(RemoteConfig.self, from: data)
lastParsed = parsed
return .parsed(parsed)
} catch {
throw RemoteConfigError.decodingFailed(error)
@@ -24,6 +24,10 @@ public final class BridgedWebView: UIView {
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
configuration.preferences.minimumFontSize = 10
configuration.allowsInlineMediaPlayback = true
// H5 .all WKWebView user gesture
// push loadFileURL
// " webview " user gesture
configuration.mediaTypesRequiringUserActionForPlayback = []
configuration.processPool = SharedProcessPool.shared
// documentStart WebViewJavascriptBridge.js
@@ -78,7 +78,12 @@ public final class SubGameViewController: UIViewController {
OpenSaomaHandler.register(on: bridge)
StartLocationHandler.register(on: bridge)
LocalAudioHandler.register(on: bridge)
// assetsRoot = H5 msext gameController.m:364
LocalAudioHandler.register(
on: bridge,
assetsRoot: SandboxPaths.subGameIndex(request.gameDir, request.gameStart)
.deletingLastPathComponent()
)
RemoteAudioHandler.register(on: bridge)
AccreditLoginHandler.register(on: bridge)
@@ -163,10 +168,28 @@ public final class SubGameViewController: UIViewController {
}
private func runBootPipelineSteps() async throws {
// 0. H5 SwitchOverGameData.gamedownloadurl ** url gameid**
// msext gameController.m:544 "ID" zip
// gameid RemoteConfig `game_zip` msext uplevel:
// chulishengji VersionResolver
//
// resolve
// - lobbyResolvedgameId = bc.gameId AppDataWriter app_appversion
// msext NewRootVC result initinfo gameController
// - subGameResolvedgameId = H5 gameid 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
)
// 1. zip + + rename
splash.update(text: "准备子游戏...", progress: nil)
_ = try await SubGameDownloader.shared.ensureReady(
request: request,
request: resolvedRequest,
onProgress: { [weak self] p in
Task { @MainActor in
self?.splash.update(
@@ -179,9 +202,11 @@ public final class SubGameViewController: UIViewController {
// 2. 4 app_*.jscontainerRole = .subGameBatteryMonitor / NetworkMonitor
// currentXxx
// resolvedVersion app_appversion
// msext result_state
let writer = AppDataWriter(
bundleConfig: .shared,
resolvedVersion: nil,
resolvedVersion: lobbyResolved,
containerRole: .subGame(name: request.gameStart, dir: request.gameDir)
)
try writer.writeInitial()
@@ -197,6 +222,48 @@ public final class SubGameViewController: UIViewController {
)
}
/// resolve
/// - lobby gameId = bc.gameId app_appversion msext result_state
/// - sub-game gameId = H5 gameid game_zip URLmsext gameController.uplevel:
private func resolveBoot() async throws -> (lobbyResolved: ResolvedVersion, gameZipURL: String) {
guard let cfg = await RemoteConfigClient.shared.current() else {
throw SubGameBootError.remoteConfigUnavailable
}
let bc = BundleConfig.shared
let lobbyResolved = VersionResolver.resolve(
config: cfg,
agentId: bc.agent,
channelId: bc.channel,
marketId: bc.market,
gameId: bc.gameId
)
let subGameResolved = VersionResolver.resolve(
config: cfg,
agentId: bc.agent,
channelId: bc.channel,
marketId: bc.market,
gameId: request.downloadURL // H5 url gameid
)
guard let zipURL = subGameResolved.gameZip, !zipURL.isEmpty else {
throw SubGameBootError.zipURLNotFound(gameId: request.downloadURL)
}
return (lobbyResolved, zipURL)
}
enum SubGameBootError: Error, CustomStringConvertible {
case remoteConfigUnavailable
case zipURLNotFound(gameId: String)
var description: String {
switch self {
case .remoteConfigUnavailable:
return "远端配置未就绪,请返回大厅重试"
case .zipURLNotFound(let id):
return "未找到子游戏 \(id) 的下载地址"
}
}
}
// MARK: - §2.4.2
//
// battery / network / appservice push
@@ -74,7 +74,11 @@ public final class WebContainerViewController: UIViewController {
StartLocationHandler.register(on: bridge) // §3.1 20Phase 5 Phase 2 stub
// Phase 3.A + 3.C/3.D stub
LocalAudioHandler.register(on: bridge) // §3.1 3srcIsloop
// assetsRoot = H5 srcIsloop assets/wav/{src}
LocalAudioHandler.register(
on: bridge,
assetsRoot: SandboxPaths.lobbyIndex.deletingLastPathComponent()
) // §3.1 3srcIsloop
RemoteAudioHandler.register(on: bridge) // §3.1 45Phase 3.B/3.C/3.D stub
// Phase 4.A + stubPhase 4.B SDK / Phase 4.C QQ URL Scheme