修复后子游戏 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>
290 lines
12 KiB
Swift
290 lines
12 KiB
Swift
//
|
||
// RemoteConfigClient.swift
|
||
// ylgamehall
|
||
//
|
||
// 从 BundleConfig.gameConfig 拼出的远端 .txt(实为 JSON)拉取渠道升级配置。
|
||
// 详见 docs/H5-Native-Implementation-Design.md §6.3.1 / Plan ADR-008。
|
||
//
|
||
|
||
import Foundation
|
||
|
||
// MARK: - Codable 模型(三级嵌套:agent → channel → market;agent 子树另有 gamelist 副路径)
|
||
|
||
nonisolated public struct RemoteConfig: Codable, Sendable {
|
||
public let showmessage: String?
|
||
public let agentlist: [Agent]?
|
||
}
|
||
|
||
nonisolated public struct Agent: Codable, Sendable {
|
||
public let agentid: String?
|
||
public let showmessage: String?
|
||
public let appVersion: String?
|
||
public let appDownload: String?
|
||
public let gameVersion: String?
|
||
public let gameZip: String?
|
||
public let channellist: [Channel]?
|
||
public let gamelist: [Game]?
|
||
|
||
public init(from decoder: any Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
agentid = try c.decodeFlexibleStringIfPresent(forKey: .agentid)
|
||
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
|
||
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
|
||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
||
channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist)
|
||
gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist)
|
||
}
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case agentid, showmessage, channellist, gamelist
|
||
case appVersion, appDownload, gameVersion, gameZip
|
||
}
|
||
}
|
||
|
||
nonisolated public struct Channel: Codable, Sendable {
|
||
public let channelid: String?
|
||
public let showmessage: String?
|
||
public let appVersion: String?
|
||
public let appDownload: String?
|
||
public let gameVersion: String?
|
||
public let gameZip: String?
|
||
public let marketlist: [Market]?
|
||
|
||
public init(from decoder: any Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
channelid = try c.decodeFlexibleStringIfPresent(forKey: .channelid)
|
||
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
|
||
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
|
||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
||
marketlist = try c.decodeIfPresent([Market].self, forKey: .marketlist)
|
||
}
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case channelid, showmessage, marketlist
|
||
case appVersion, appDownload, gameVersion, gameZip
|
||
}
|
||
}
|
||
|
||
nonisolated public struct Market: Codable, Sendable {
|
||
public let marketid: String?
|
||
public let showmessage: String?
|
||
public let appVersion: String?
|
||
public let appDownload: String?
|
||
public let gameVersion: String?
|
||
public let gameZip: String?
|
||
|
||
public init(from decoder: any Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
marketid = try c.decodeFlexibleStringIfPresent(forKey: .marketid)
|
||
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
|
||
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
|
||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
||
}
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case marketid, showmessage
|
||
case appVersion, appDownload, gameVersion, gameZip
|
||
}
|
||
}
|
||
|
||
nonisolated public struct Game: Codable, Sendable {
|
||
public let gameid: String?
|
||
public let showmessage: String?
|
||
public let appVersion: String?
|
||
public let appDownload: String?
|
||
public let gameVersion: String?
|
||
public let gameZip: String?
|
||
public let channellist: [Channel]?
|
||
|
||
public init(from decoder: any Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
gameid = try c.decodeFlexibleStringIfPresent(forKey: .gameid)
|
||
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
|
||
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
|
||
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
|
||
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
|
||
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
|
||
channellist = try c.decodeIfPresent([Channel].self, forKey: .channellist)
|
||
}
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case gameid, showmessage, channellist
|
||
case appVersion, appDownload, gameVersion, gameZip
|
||
}
|
||
}
|
||
|
||
/// `KeyedDecodingContainer` 扩展:宽松解 String —— 服务端历史包袱让 app_version/game_version
|
||
/// 等字段可能以 String / Int / Double 任一形式回,msext 用 NSString 自动吞了;我们 Codable
|
||
/// 严格校验下需要自己兼容。Bool 兜底是防御性的(极不可能但 0 成本)。
|
||
nonisolated extension KeyedDecodingContainer {
|
||
func decodeFlexibleStringIfPresent(forKey key: Key) throws -> String? {
|
||
// key 不存在直接返回 nil(避免 decodeNil(forKey:) 在 key 缺失时 throw keyNotFound)
|
||
guard contains(key) else { return nil }
|
||
if (try? decodeNil(forKey: key)) == true { return nil }
|
||
if let s = try? decode(String.self, forKey: key) { return s }
|
||
if let i = try? decode(Int.self, forKey: key) { return String(i) }
|
||
if let d = try? decode(Double.self, forKey: key) {
|
||
// 整数值的 Double 去掉末尾 .0(如 "43.0" → "43")
|
||
return d.truncatingRemainder(dividingBy: 1) == 0
|
||
? String(Int(d))
|
||
: String(d)
|
||
}
|
||
if let b = try? decode(Bool.self, forKey: key) { return String(b) }
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// MARK: - 拉取结果
|
||
|
||
/// 远端配置拉取的两种成功 outcome(msext `NewRootVC.m:1239-1244` 契约)。
|
||
public enum RemoteConfigOutcome: Sendable {
|
||
/// 正常长文本响应,JSON 反序列化成功
|
||
case parsed(RemoteConfig)
|
||
/// 短文本响应(trim 后 utf16.count ≤ 100)—— 服务端运营杀手锏 #1:
|
||
/// 配置异常时返回一段错误文本,客户端当弹窗内容直接显示并停止重试。
|
||
/// 详见 ADR-008-B / msext NewRootVC.m:1239-1243。
|
||
case shortText(String)
|
||
}
|
||
|
||
// MARK: - 错误
|
||
|
||
public enum RemoteConfigError: Error, Sendable {
|
||
/// `BundleConfig.gameConfig` 为空,无法构造 URL
|
||
case urlConstructionFailed
|
||
/// HTTP 非 2xx
|
||
case httpStatus(Int)
|
||
/// JSON 反序列化失败
|
||
case decodingFailed(any Error)
|
||
/// 全部重试用尽
|
||
case allRetriesFailed(underlying: any Error)
|
||
}
|
||
|
||
// MARK: - 客户端
|
||
|
||
/// 远端渠道配置拉取器。actor 隔离,URLSession async + 指数退避重试。
|
||
public actor RemoteConfigClient {
|
||
|
||
public static let shared = RemoteConfigClient()
|
||
|
||
private let session: URLSession
|
||
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,
|
||
maxRetries: Int = 3
|
||
) {
|
||
self.session = session
|
||
self.urlBuilder = urlBuilder
|
||
self.maxRetries = maxRetries
|
||
}
|
||
|
||
/// 启动期 fetch 成功后保留的 RemoteConfig;nil 表示从未拉到过。
|
||
public func current() -> RemoteConfig? {
|
||
lastParsed
|
||
}
|
||
|
||
/// 默认 URLSession:每次请求 10 s 超时。
|
||
nonisolated public static var defaultSession: URLSession {
|
||
let cfg = URLSessionConfiguration.default
|
||
cfg.timeoutIntervalForRequest = 10
|
||
cfg.timeoutIntervalForResource = 30
|
||
return URLSession(configuration: cfg)
|
||
}
|
||
|
||
/// 默认 URL 构造:`https://` + `BundleConfig.gameConfig.replacingOccurrences("-", "/")` + `.txt`。
|
||
/// 当前 demo 渠道注入值 → https://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt
|
||
///
|
||
/// 与 msext `NewRootVC.m:250` 用 http 的历史相比,本项目后台已切 HTTPS,
|
||
/// 故无需 NSAllowsArbitraryLoads 等 ATS 例外。不带 SERVERNew 前缀的拼装规则保留。
|
||
nonisolated public static let defaultURLBuilder: @Sendable () -> URL? = {
|
||
let gameConfig = BundleConfig.shared.gameConfig
|
||
guard !gameConfig.isEmpty else { return nil }
|
||
let path = gameConfig.replacingOccurrences(of: "-", with: "/")
|
||
return URL(string: "https://\(path).txt")
|
||
}
|
||
|
||
/// 拉远端配置。指数退避 1/2/4 秒最多 maxRetries 次。
|
||
///
|
||
/// - Returns: `.parsed(RemoteConfig)` 长文本 JSON 成功,或 `.shortText(String)`
|
||
/// 服务端运营截短响应(trim 后 utf16.count ≤ 100,msext 契约)
|
||
public func fetch() async throws -> RemoteConfigOutcome {
|
||
guard let baseURL = urlBuilder() else {
|
||
throw RemoteConfigError.urlConstructionFailed
|
||
}
|
||
|
||
var lastError: any Error = RemoteConfigError.urlConstructionFailed
|
||
for attempt in 0..<maxRetries {
|
||
do {
|
||
// 每次重试都生成新 cache-busting query,避免 CDN / Proxy 复用旧响应
|
||
// 格式:?vXXXXXXXXYYYYYYYY(两段 8 位 hex,无 `=`,msext NewRootVC.m:1226 契约)
|
||
let url = appendCacheBuster(to: baseURL)
|
||
return try await fetchOnce(url: url)
|
||
} catch {
|
||
lastError = error
|
||
if attempt < maxRetries - 1 {
|
||
let delaySeconds = pow(2.0, Double(attempt))
|
||
try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000))
|
||
}
|
||
}
|
||
}
|
||
throw RemoteConfigError.allRetriesFailed(underlying: lastError)
|
||
}
|
||
|
||
private func fetchOnce(url: URL) async throws -> RemoteConfigOutcome {
|
||
let (data, response) = try await session.data(from: url)
|
||
if let http = response as? HTTPURLResponse,
|
||
!(200..<300).contains(http.statusCode) {
|
||
throw RemoteConfigError.httpStatus(http.statusCode)
|
||
}
|
||
// UTF-8 解码 + trim(msext NewRootVC.m:1237-1238 契约)
|
||
guard let raw = String(data: data, encoding: .utf8) else {
|
||
throw RemoteConfigError.decodingFailed(
|
||
NSError(domain: "RemoteConfigClient", code: -1,
|
||
userInfo: [NSLocalizedDescriptionKey: "response is not UTF-8"])
|
||
)
|
||
}
|
||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
// 短文本响应(≤100 utf16 unit):服务端运营截短响应,当 alert 文本用
|
||
// msext NewRootVC.m:1239-1243 用 NSString length(即 UTF-16 unit count),新外壳用 utf16.count 1:1
|
||
if trimmed.utf16.count <= 100 {
|
||
return .shortText(trimmed)
|
||
}
|
||
|
||
let decoder = JSONDecoder()
|
||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||
do {
|
||
let parsed = try decoder.decode(RemoteConfig.self, from: data)
|
||
lastParsed = parsed
|
||
return .parsed(parsed)
|
||
} catch {
|
||
throw RemoteConfigError.decodingFailed(error)
|
||
}
|
||
}
|
||
|
||
/// 给 URL 追加 cache-busting query `?vXXXXXXXXYYYYYYYY`(msext NewRootVC.m:1226)。
|
||
/// 注意:不是 `?v=XXXX` 标准 query 而是裸 `?v<16hex>`,照搬契约。
|
||
nonisolated private func appendCacheBuster(to url: URL) -> URL {
|
||
let token = String(format: "%08X%08X",
|
||
UInt32.random(in: 0...UInt32.max),
|
||
UInt32.random(in: 0...UInt32.max))
|
||
// URLComponents 会把 query 编码,这里手动拼字符串
|
||
let absolute = url.absoluteString
|
||
let separator = absolute.contains("?") ? "&" : "?"
|
||
return URL(string: "\(absolute)\(separator)v\(token)") ?? url
|
||
}
|
||
}
|