子游戏跳转链路修复 + 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:
co-authored by
Claude Opus 4.7
parent
053232ed26
commit
e50dfb75af
@@ -4,6 +4,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
@@ -12,6 +13,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
application.applicationSupportsShakeToEdit = true
|
application.applicationSupportsShakeToEdit = true
|
||||||
|
|
||||||
|
// AVAudioSession 默认 category 是 .soloAmbient,静音键开启时不响。
|
||||||
|
// msext RootVC.m:1302 / fourviewVC.m:325 等多处显式设为 .playback,
|
||||||
|
// 让 H5 <audio> 和 AVAudioPlayer 都不受静音键影响(用户用音量键控制)。
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
||||||
|
|
||||||
// 微信 SDK 1.8.2 内部仍调已弃用的 -[UIApplication openURL:],iOS 17+ SDK
|
// 微信 SDK 1.8.2 内部仍调已弃用的 -[UIApplication openURL:],iOS 17+ SDK
|
||||||
// 链接的 app 会被系统强制返回 NO。必须在 WXApi.registerApp 之前 swizzle。
|
// 链接的 app 会被系统强制返回 NO。必须在 WXApi.registerApp 之前 swizzle。
|
||||||
WXOpenURLShim.install()
|
WXOpenURLShim.install()
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
//
|
//
|
||||||
// H5 → Native handler:srcIsloop — 本地音频播放(背景循环 / 按钮单次)
|
// H5 → Native handler:srcIsloop — 本地音频播放(背景循环 / 按钮单次)
|
||||||
// 契约:docs/H5-Native-Contract.md §3.1 [3]
|
// 契约: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}`
|
// 音频文件实际路径:`{assetsRoot}/assets/wav/{src}`
|
||||||
// 与 SandboxPaths.lobbyIndex 同一根目录下 assets/wav 子目录
|
// - lobby:assetsRoot = SandboxPaths.lobbyIndex.deletingLastPathComponent()
|
||||||
|
// - subGame:assetsRoot = SandboxPaths.subGameIndex(dir,name).deletingLastPathComponent()
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -15,9 +19,14 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
public enum LocalAudioHandler {
|
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) {
|
||||||
// 【3】srcIsloop — 本地音频播放
|
// 【3】srcIsloop — 本地音频播放
|
||||||
// 入参 src : string 音频文件名(位于 lobbyAssets/assets/wav/{src})
|
// 入参 src : string 音频文件名(位于 {assetsRoot}/assets/wav/{src})
|
||||||
// 入参 isloop : int 0 单次 / 1 循环背景 / -1 停同名循环
|
// 入参 isloop : int 0 单次 / 1 循环背景 / -1 停同名循环
|
||||||
// cb: "Response from srcIsloop"
|
// cb: "Response from srcIsloop"
|
||||||
bridge.register("srcIsloop") { data, callback in
|
bridge.register("srcIsloop") { data, callback in
|
||||||
@@ -30,7 +39,10 @@ public enum LocalAudioHandler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = audioFileURL(src: src)
|
let url = assetsRoot
|
||||||
|
.appendingPathComponent("assets")
|
||||||
|
.appendingPathComponent("wav")
|
||||||
|
.appendingPathComponent(src)
|
||||||
|
|
||||||
switch isloop {
|
switch isloop {
|
||||||
case 0:
|
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()
|
lastSwitchAt = Date()
|
||||||
|
|
||||||
|
// push 子游戏前停大厅背景音(msext NewRootVC.m:549-552 等价)。
|
||||||
|
// AudioPlayer 是 app 内单一背景音通道,stop 后子游戏可自由 loopBackground 覆盖。
|
||||||
|
AudioPlayer.shared.stopAllBackground()
|
||||||
|
|
||||||
let subGame = SubGameViewController(request: request)
|
let subGame = SubGameViewController(request: request)
|
||||||
nav.pushViewController(subGame, animated: true)
|
nav.pushViewController(subGame, animated: true)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ public actor RemoteConfigClient {
|
|||||||
private let urlBuilder: @Sendable () -> URL?
|
private let urlBuilder: @Sendable () -> URL?
|
||||||
private let maxRetries: Int
|
private let maxRetries: Int
|
||||||
|
|
||||||
|
/// 最近一次 `.parsed(...)` 成功的配置。子游戏跳转链路(SubGameViewController)需要按
|
||||||
|
/// H5 传的 gameid 重新跑一次 VersionResolver 拿真实 zip URL —— 没缓存就要再发请求。
|
||||||
|
/// `.shortText` 与失败路径不更新此值,保持上一份"已知好"配置可用。
|
||||||
|
private var lastParsed: RemoteConfig?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
session: URLSession = RemoteConfigClient.defaultSession,
|
session: URLSession = RemoteConfigClient.defaultSession,
|
||||||
urlBuilder: @escaping @Sendable () -> URL? = RemoteConfigClient.defaultURLBuilder,
|
urlBuilder: @escaping @Sendable () -> URL? = RemoteConfigClient.defaultURLBuilder,
|
||||||
@@ -186,6 +191,11 @@ public actor RemoteConfigClient {
|
|||||||
self.maxRetries = maxRetries
|
self.maxRetries = maxRetries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 启动期 fetch 成功后保留的 RemoteConfig;nil 表示从未拉到过。
|
||||||
|
public func current() -> RemoteConfig? {
|
||||||
|
lastParsed
|
||||||
|
}
|
||||||
|
|
||||||
/// 默认 URLSession:每次请求 10 s 超时。
|
/// 默认 URLSession:每次请求 10 s 超时。
|
||||||
nonisolated public static var defaultSession: URLSession {
|
nonisolated public static var defaultSession: URLSession {
|
||||||
let cfg = URLSessionConfiguration.default
|
let cfg = URLSessionConfiguration.default
|
||||||
@@ -258,6 +268,7 @@ public actor RemoteConfigClient {
|
|||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
do {
|
do {
|
||||||
let parsed = try decoder.decode(RemoteConfig.self, from: data)
|
let parsed = try decoder.decode(RemoteConfig.self, from: data)
|
||||||
|
lastParsed = parsed
|
||||||
return .parsed(parsed)
|
return .parsed(parsed)
|
||||||
} catch {
|
} catch {
|
||||||
throw RemoteConfigError.decodingFailed(error)
|
throw RemoteConfigError.decodingFailed(error)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ public final class BridgedWebView: UIView {
|
|||||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||||
configuration.preferences.minimumFontSize = 10
|
configuration.preferences.minimumFontSize = 10
|
||||||
configuration.allowsInlineMediaPlayback = true
|
configuration.allowsInlineMediaPlayback = true
|
||||||
|
// H5 背景音自播:默认 .all 要求该 WKWebView 实例有 user gesture 才放行媒体,
|
||||||
|
// 大厅页用户点击过没问题;子游戏 push 后立即 loadFileURL 自播背景音被静默拦截,
|
||||||
|
// 表现为"子游戏 webview 没声音"。设空集让所有媒体都不要求 user gesture。
|
||||||
|
configuration.mediaTypesRequiringUserActionForPlayback = []
|
||||||
configuration.processPool = SharedProcessPool.shared
|
configuration.processPool = SharedProcessPool.shared
|
||||||
|
|
||||||
// ── 在 documentStart 注入 WebViewJavascriptBridge.js ─────
|
// ── 在 documentStart 注入 WebViewJavascriptBridge.js ─────
|
||||||
|
|||||||
@@ -78,7 +78,12 @@ public final class SubGameViewController: UIViewController {
|
|||||||
OpenSaomaHandler.register(on: bridge)
|
OpenSaomaHandler.register(on: bridge)
|
||||||
StartLocationHandler.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)
|
RemoteAudioHandler.register(on: bridge)
|
||||||
|
|
||||||
AccreditLoginHandler.register(on: bridge)
|
AccreditLoginHandler.register(on: bridge)
|
||||||
@@ -163,10 +168,28 @@ public final class SubGameViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func runBootPipelineSteps() async throws {
|
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:
|
||||||
|
// - lobbyResolved(gameId = bc.gameId)→ 给 AppDataWriter 算 app_appversion 审核标志
|
||||||
|
// 与 msext NewRootVC 算 result 后 initinfo 传给 gameController 等价
|
||||||
|
// - subGameResolved(gameId = 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)
|
// 1. 确保子游戏 zip 已下载解压(缓存命中直接复用,未命中下载 + 解压 + 原子 rename)
|
||||||
splash.update(text: "准备子游戏...", progress: nil)
|
splash.update(text: "准备子游戏...", progress: nil)
|
||||||
_ = try await SubGameDownloader.shared.ensureReady(
|
_ = try await SubGameDownloader.shared.ensureReady(
|
||||||
request: request,
|
request: resolvedRequest,
|
||||||
onProgress: { [weak self] p in
|
onProgress: { [weak self] p in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.splash.update(
|
self?.splash.update(
|
||||||
@@ -179,9 +202,11 @@ public final class SubGameViewController: UIViewController {
|
|||||||
|
|
||||||
// 2. 写 4 个 app_*.js(containerRole = .subGame)。BatteryMonitor / NetworkMonitor
|
// 2. 写 4 个 app_*.js(containerRole = .subGame)。BatteryMonitor / NetworkMonitor
|
||||||
// 单例已由大厅启动,此处仅复用其 currentXxx。
|
// 单例已由大厅启动,此处仅复用其 currentXxx。
|
||||||
|
// resolvedVersion 必须传大厅视角的版本判定结果,让 app_appversion 审核标志
|
||||||
|
// 与大厅一致(msext result_state 传递路径等价)。
|
||||||
let writer = AppDataWriter(
|
let writer = AppDataWriter(
|
||||||
bundleConfig: .shared,
|
bundleConfig: .shared,
|
||||||
resolvedVersion: nil,
|
resolvedVersion: lobbyResolved,
|
||||||
containerRole: .subGame(name: request.gameStart, dir: request.gameDir)
|
containerRole: .subGame(name: request.gameStart, dir: request.gameDir)
|
||||||
)
|
)
|
||||||
try writer.writeInitial()
|
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 下载 URL(msext 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)
|
// MARK: - 外部订阅生命周期(参 §2.4.2)
|
||||||
//
|
//
|
||||||
// 子游戏栈顶时挂钩 battery / network / appservice。大厅 push 子游戏后大厅
|
// 子游戏栈顶时挂钩 battery / network / appservice。大厅 push 子游戏后大厅
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ public final class WebContainerViewController: UIViewController {
|
|||||||
StartLocationHandler.register(on: bridge) // §3.1 [20]Phase 5 完整实现,Phase 2 stub
|
StartLocationHandler.register(on: bridge) // §3.1 [20]Phase 5 完整实现,Phase 2 stub
|
||||||
|
|
||||||
// Phase 3.A 本地音频 + 3.C/3.D stub
|
// Phase 3.A 本地音频 + 3.C/3.D stub
|
||||||
LocalAudioHandler.register(on: bridge) // §3.1 [3]srcIsloop 完整实现
|
// assetsRoot = 大厅 H5 根目录,srcIsloop 拼 assets/wav/{src} 在该根下查找
|
||||||
|
LocalAudioHandler.register(
|
||||||
|
on: bridge,
|
||||||
|
assetsRoot: SandboxPaths.lobbyIndex.deletingLastPathComponent()
|
||||||
|
) // §3.1 [3]srcIsloop 完整实现
|
||||||
RemoteAudioHandler.register(on: bridge) // §3.1 [4][5]Phase 3.B/3.C/3.D stub
|
RemoteAudioHandler.register(on: bridge) // §3.1 [4][5]Phase 3.B/3.C/3.D stub
|
||||||
|
|
||||||
// Phase 4.A 登录 + 分享 stub(Phase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级)
|
// Phase 4.A 登录 + 分享 stub(Phase 4.B 微信 SDK / Phase 4.C QQ URL Scheme 后升级)
|
||||||
|
|||||||
Reference in New Issue
Block a user