Files
youle_app_ios_v2/ylgamehall/Source/Network/RemoteConfigClient.swift
T
joywayerandClaude Opus 4.7 e50dfb75af 子游戏跳转链路修复 + 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>
2026-06-23 21:33:46 +08:00

290 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 → marketagent 子树另有 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: - 拉取结果
/// 远端配置拉取的两种成功 outcomemsext `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 ≤ 100msext 契约)
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 解码 + trimmsext 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
}
}