// // RemoteConfigClient.swift // ylgamehall // // 从 BundleConfig.gameConfig 拼出的远端 .txt(实为 JSON)拉取渠道升级配置。 // 详见 docs/H5-Native-Implementation-Design.md §6.3.1 / Plan ADR-008。 // import Foundation // MARK: - Codable 模型(单链 4 层嵌套:agent → game → channel → market) 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 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) gamelist = try c.decodeIfPresent([Game].self, forKey: .gamelist) } private enum CodingKeys: String, CodingKey { case agentid, showmessage, 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.. 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 } }