Phase 1.10:RemoteConfigClient 完整版(HTTPS + cache-busting + 短文本 + FlexibleString)
第二轮调研 daoqi/NewRootVC.m 全文核对后,把对原项目远程配置流程的精确 理解固化进 ADR-008 + Design §6.3.2,并补齐 RemoteConfigClient 缺失的 3 个契约(cache-busting query、短文本响应识别、字段类型混乱兼容)。 核心修正:线上热路径是 chulishengji 双子树合并算法(NewRootVC.m:1372-1538), 不是 onnet 简单 4 层覆盖。viewWillAppear 与 gonet 都会强制把当前 agent 的 gamelist 提到 self.gamelist,只要服务器 JSON 含 agent.gamelist(线上 100% 都有), self.gamelist 就非 nil,走 else 分支 → chulishengji。本次烟雾测试实测: agent[0].gamelist = 7 个,完美印证 chulishengji 必要性。 - RemoteConfigClient 新增 cache-busting query:?vXXXXXXXXYYYYYYYY(两段 8 位 hex 连写,无 =,照搬 msext NewRootVC.m:1226 契约),每次重试都重新生成 - 新增 RemoteConfigOutcome enum:.parsed(RemoteConfig) 与 .shortText(String) 两种成功状态。短文本响应(trim 后 utf16.count ≤ 100)是服务端运营杀手锏 #1, msext 当 alert 文本弹窗 + 停止重试(NewRootVC.m:1239-1243) - UTF-8 解码 + trim 跟 msext NewRootVC.m:1237-1238 严格对齐 - 所有 String? 字段改用 decodeFlexibleStringIfPresent 扩展(兼容 number-as-string): agentid / channelid / marketid / gameid / showmessage / appVersion / appDownload / gameVersion / gameZip 全部 9 字段。msext 用 NSNumber.intValue/description 静默 吞下 number,新外壳 Codable 严格类型必须显式处理 - 远端 URL 从 http 改为 https(后台已切,无需 ATS 例外) - Plan ADR-008 修订为含 9 个子节(A-I)的精确决策记录,含 chulishengji 双子树算法、agent 子树结构、game 子树结构、字段级合并优先级、决策顺序、 与 msext 差异表 - Design §6.3.2 VersionResolver 重写:从"简单 4 层 reduce"改为"chulishengji 双子树合并 + 字段级合并",附 13 项单测覆盖矩阵 - RootViewController 烟雾测试切换为 outcome enum switch case,覆盖 .parsed 与 .shortText 两条路径 - 实测烟雾:HTTPS 拉到 30 KB JSON 解析 .parsed 成功(耗时 2.7s 含 TLS 握手), agent[0].gamelist 含 7 个 game 节点,证明 chulishengji 路径是热路径
This commit is contained in:
+142
-30
@@ -1002,40 +1002,152 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
|
|||||||
- 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道)
|
- 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道)
|
||||||
- **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质
|
- **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质
|
||||||
|
|
||||||
### ADR-008:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22)
|
### ADR-008:Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22,含 2 次调研 revise)
|
||||||
- **背景**:Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5",**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置,做 agent → channel → market 4 级覆盖,对比本地版本,必要时下载并替换 H5 zip
|
- **背景**:Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5",**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → chulishengji 双子树算法 → 与本地版本比较 → 必要时下载并替换 H5 zip
|
||||||
- **触发事件**:2026-06-22 调研 `daoqi/msext` 原项目(subagent 详细分析 `NewRootVC.m:1551-1621` viewWillAppear、`NewRootVC.m:1372-1492` chulishengji、`NewRootVC.m:1789-1869` downFileFromServer 三段核心代码),发现:
|
- **触发事件**:2026-06-22 两轮 subagent 调研 `daoqi/msext` 原项目,逐行核对核心方法:
|
||||||
- 远端配置 URL 不是 `SERVERNew + gameconfig` 拼接,而是 `"http://" + gameconfig.replacingOccurrences("-", "/") + ".txt"`(当前 demo 值即 `http://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt`)
|
- 第一轮(粗粒度):发现远端配置流程整体形态、IPA / H5 zip 两条升级链
|
||||||
- JSON 结构是 `agentlist[].channellist[].marketlist[]` 三级嵌套 + `agentlist[].gamelist[].channellist[].marketlist[]` 副路径,每层都可携带同名版本字段,深层覆盖浅层
|
- 第二轮(精确算法):核对 `NewRootVC.m` 全文,识别出**线上热路径是 chulishengji 双子树合并**,非 onnet 简单 4 层
|
||||||
- 两条独立升级链:① 原生 IPA 升级(`app_version > 本地 appversion 注入值` → 弹窗 + Safari 外链)② H5 zip 升级(`game_version > 本地 version.xml /game/version@value` → 下载 + 解压 + reload)
|
- **核心修正:线上热路径算法**
|
||||||
- 不做这一步的代价:上线后用户永远停在 IPA 内打包瞬间的 H5 版本,H5 团队任何更新都到达不了用户
|
- **不是** `onnet` 的 `if(self.gamelist==nil)` 分支(`NewRootVC.m:689-810`)的"简单线性 4 层覆盖"
|
||||||
- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17`
|
- **是** `onnet` 的 `else` 分支(`NewRootVC.m:811-877`)→ `gonetconfig1` / `gonetconfigone1` → **`chulishengji`**(`NewRootVC.m:1372-1538`)双子树合并
|
||||||
- **理由**:
|
- 触发条件:`viewWillAppear`(`NewRootVC.m:1609-1616`)与 `gonet`(`NewRootVC.m:1252-1258`)拉完 JSON 都强制把当前 agent 节点下 `gamelist` 提到 `self.gamelist`;只要服务器 JSON 里 agent 节点带 `gamelist`(线上 100% 都有),`self.gamelist` 就非 nil → 走 chulishengji 路径
|
||||||
- **WebContainer 的 `loadFileURL` 在无升级链路前提下是有缺陷的**——只能加载 IPA 内静态 zip
|
- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17`。**`VersionResolver` 必须按 chulishengji 双子树算法实现,不照搬 onnet 简单 4 层**
|
||||||
- 推迟到 Phase 2 也行得通,但 Phase 1 验收"看到大厅 H5"的语义会被掩盖(看到的是 IPA 内的旧版,跟"上线后看到的"完全不同)
|
|
||||||
- 一次性补齐使 Phase 1 验收能模拟真实上线场景:模拟远端 `game_version +1` 重启 App 必须看到新版本下载 + 加载
|
#### ADR-008-A 远端 URL 构造(`RemoteConfigClient`)
|
||||||
- **关键设计点(与 msext 实现的差异)**:
|
|
||||||
| 维度 | msext 现状 | 新外壳决策 |
|
- 基础 URL:`"https://" + BundleConfig.gameConfig.replacingOccurrences("-", "/") + ".txt"`
|
||||||
|------|----------|----------|
|
- 新外壳改 HTTPS(msext 用 HTTP,`NewRootVC.m:250`)。后台已经支持 HTTPS。
|
||||||
| 网络库 | `[NSData dataWithContentsOfURL:]` 主线程阻塞 / ASIHTTPRequest 下载 | `URLSession async data(from:)` + `download(from:)`,actor 隔离 |
|
- **cache-busting query**(msext `NewRootVC.m:1226, 1585`):每次拉取时拼 `?vXXXXXXXXYYYYYYYY`(两个 `arc4random()` 各 8 位 hex 连写,**无 `=`**)。**新外壳保留此约定**——服务端可能根据这段 query 做无 cache 处理。
|
||||||
| 失败重试 | 4 秒等间隔暴力 `timer` | 指数退避 1/2/4 秒三次,可取消 |
|
|
||||||
| 4 级覆盖算法 | 散落 if/else 在 `getagentversion` / `getgameversion` / `chulishengji` 三方法手写 | 纯函数 reduce,单测覆盖所有边界 |
|
#### ADR-008-B 网络层
|
||||||
| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留残骸 | 解压到临时目录 + 原子 rename,无半残状态 |
|
|
||||||
| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`,靠系统清 Caches) | 同样的原子 rename 策略,旧目录直接覆盖 |
|
- API:`URLSession.data(from:)` async(替代 msext `[NSData dataWithContentsOfURL:]` 主线程同步阻塞)。10s 请求超时 + 30s 资源超时。
|
||||||
| 解析 JSON | SBJSON 三方库 | Codable + JSONDecoder |
|
- 重试策略:**最初取 3 次 1/2/4 秒指数退避**(已在 1.10 落地);上线前评估改为 **msext 风格 4s 等间隔无次数上限**——因为 msext 那套"无网静默重试 + 网络恢复立刻进"的体验在地铁场景是合理的,新外壳不应当回退。
|
||||||
| 版本号类型 | NSString → intValue(自动当 0 处理) | 强类型 Int,缺失 throw 类型化错误 |
|
- 静默重试:网络错误不弹窗、不打 log,等下次 tick;只在第一次失败时 UI 显示状态(启动图 + "重新连接中")。这是契约。
|
||||||
- **守护**:
|
- 短文本响应(`NewRootVC.m:1239-1243`):trim 后 `length ≤ 100` 时**当 alert 内容直接弹窗**,并停止重试。运营杀手锏 #1。**新外壳用 `utf16.count` 实现 1:1 复刻**(msext 用 NSString length 即 UTF-16 unit 计数)。
|
||||||
- **H5 端契约不变**:H5 看到的仍是"启动一段时间后看到大厅",对 4 级覆盖 / 升级链路完全无感
|
|
||||||
- `gameconfig` 注入值是远端 URL 的唯一驱动,**不允许把远端 URL 硬编码在 Swift 源码里**
|
#### ADR-008-C JSON 解析(`RemoteConfigClient`)
|
||||||
- `version.xml` 文件名 / xpath `/game/version@value` 不允许改名(H5 自更新时会写这个文件,新外壳读它,双方协议)
|
|
||||||
- 4 级覆盖的优先级(agent > 顶层、channel > agent、market > channel、game 子树最终覆盖 agent 子树)必须由单测固化,避免回归
|
- 服务端响应是 UTF-8 编码的 JSON(扩展名 `.txt` 仅历史包袱)
|
||||||
- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议;JSON 嵌套结构变化同理
|
- 顶层结构:`{ showmessage, agentlist[], scrollmsg, Ads, managerUrl, isclose, menunotice, ... 20+ H5 业务字段 }`
|
||||||
|
- 原生**只读** `agentlist + showmessage`,**其余字段 H5 同 URL 再拉一次**自己读
|
||||||
|
- Codable 忽略未识别字段(默认行为),新外壳自动满足
|
||||||
|
- **字段类型混乱(必须兼容)**:`marketid` / `game_version` / `app_version` 等本应为 string 的字段,服务端会发 number。msext 用 NSNumber 静默吞下;**新外壳用 `decodeFlexibleStringIfPresent` 扩展(String / Int / Double / Bool 全兼容)**——已在 1.10 实现
|
||||||
|
- **解析失败行为**:msext 静默 nil → 后续 for 循环 0 次跑空 → App 静默卡死(缺陷)。新外壳应当**显式抛 `ConfigParseFailed` + UI 弹错误页 + 提供重试按钮**
|
||||||
|
|
||||||
|
#### ADR-008-D chulishengji 双子树合并算法(`VersionResolver`)
|
||||||
|
|
||||||
|
新外壳 `VersionResolver.resolve()` 必须 1:1 复刻 `NewRootVC.m:1372-1538` 的算法,输出 5 个目标字段:`(appVersion, appDownload, gameVersion, gameZip, showmessage)`。
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// 1. 提取 agent 子树(getagentversion,NewRootVC.m:885-1013)
|
||||||
|
// agent → channel → market → game(game 嵌在 market 命中后才遍历)
|
||||||
|
agentConfig = extractAgentSubtree(agent)
|
||||||
|
// → (app_version, app_download, game_version, game_download, showmessage)
|
||||||
|
|
||||||
|
// 2. 提取 game 子树(getgameversion,NewRootVC.m:1015-1180)
|
||||||
|
// game → game-self → channel → market
|
||||||
|
// (第二层 "game-self" 是把 gamedata 自身当 infotwo 再嵌一遍 channellist;
|
||||||
|
// msext 那段曾经的 agentid 判断被注释掉了)
|
||||||
|
gameConfig = extractGameSubtree(game)
|
||||||
|
// → (app_version, app_download, game_version, game_download, showmessage)
|
||||||
|
|
||||||
|
// 3. 合并 IPA 字段(NewRootVC.m:1408-1447)
|
||||||
|
if both have app_download:
|
||||||
|
result.appVersion = agentConfig.appVersion // 默认 agent 赢
|
||||||
|
result.appDownload = agentConfig.appDownload
|
||||||
|
if gameConfig.appVersion > agentConfig.appVersion: // 版本号大的赢(line 1416)
|
||||||
|
result.appVersion = gameConfig.appVersion
|
||||||
|
result.appDownload = gameConfig.appDownload
|
||||||
|
elif only one has: 用那一份
|
||||||
|
else: nil
|
||||||
|
|
||||||
|
// 4. 合并 zip 字段(NewRootVC.m:1450-1492)
|
||||||
|
if both have game_download:
|
||||||
|
result.gameVersion = gameConfig.gameVersion // 默认 game 赢
|
||||||
|
result.gameZip = gameConfig.gameZip
|
||||||
|
if agentConfig.gameVersion > gameConfig.gameVersion: // 版本号大的赢(line 1456)
|
||||||
|
result.gameVersion = agentConfig.gameVersion
|
||||||
|
result.gameZip = agentConfig.gameZip
|
||||||
|
elif only one has: 用那一份
|
||||||
|
else: nil
|
||||||
|
|
||||||
|
// 5. showmessage:在两条子树 extract 内部多次覆盖(agent 子树 :897/:910/:924/:948;
|
||||||
|
// game 子树 :1027/:1058/:1087/:1114),加上 onnet else 的两次 for 循环 (:826/:852)
|
||||||
|
// 与 gonetconfigone1 :1319。算法上等同于"任意一处非空就覆盖",最后写赢。
|
||||||
|
result.showmessage = mergeShowmessage(top, agent, game, channel, market)
|
||||||
|
```
|
||||||
|
|
||||||
|
**agent 子树 4 层结构**(`getagentversion:`):
|
||||||
|
- 第 1 层 agent 自己:`NewRootVC.m:889-915` 提取顶层 4 字段 + showmessage
|
||||||
|
- 第 2 层 channel:`NewRootVC.m:903-940` 嵌 channellist 找匹配
|
||||||
|
- 第 3 层 market:`NewRootVC.m:921-997` 嵌 marketlist 找匹配
|
||||||
|
- 第 4 层 game:`NewRootVC.m:944-996` 嵌在 market 命中后才遍历 game(注意是 market.gamelist,不是 channel.gamelist)
|
||||||
|
|
||||||
|
**game 子树 4 层结构**(`getgameversion:`):
|
||||||
|
- 第 1 层 game 自己:`NewRootVC.m:1019-1045`
|
||||||
|
- 第 2 层 game-self(`infotwo=gamedata` 自身再嵌 channellist):`NewRootVC.m:1048-1073`
|
||||||
|
- 第 3 层 channel:`NewRootVC.m:1075-1100`
|
||||||
|
- 第 4 层 market:`NewRootVC.m:1100-1163`
|
||||||
|
|
||||||
|
**字段名差异(必须留意)**:
|
||||||
|
- agent 子树用 `game_download`(`NewRootVC.m:952`)
|
||||||
|
- game 子树用 `game_zip`(`NewRootVC.m:1037`)
|
||||||
|
- 两者本质同一字段,msext 历史命名不一致。新外壳 Codable 模型用 `gameZip`,但需要在两个 extract 函数里都查这两个 key(择一非空)
|
||||||
|
|
||||||
|
#### ADR-008-E 决策顺序(`WebContainer` 编排)
|
||||||
|
|
||||||
|
5 字段拿到后**固定顺序**判断(chulishengji 路径在 `NewRootVC.m:1501-1527`):
|
||||||
|
|
||||||
|
| 优先级 | 条件 | 动作 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `showmessage != ""` 且非 nil | 弹 alert(标题 `gamehallname + 提醒`,单按钮"确定"),**完全阻塞**,return |
|
||||||
|
| 2 | `[version_ios intValue] > [iosNumber intValue]` | 弹 IPA 升级 alert(标题"有新的版本更新,点击前往下载!更新过程中可能会白屏...",单按钮"确定",tag PublicTagfive) → Safari 外链 `app_download`,return |
|
||||||
|
| 3 | 否则 | `uplevel:download:` 内(`NewRootVC.m:1874-1885`):`gamevers > [versioninfo intValue]` → `downFileFromServer:`;否则 `initView` 直接加载本地 H5 |
|
||||||
|
|
||||||
|
#### ADR-008-F LocalVersionReader(1.12)
|
||||||
|
|
||||||
|
- **iOS 本地版本**:`BundleConfig.shared.appVersion` 转 Int(来自 ChannelConfig.plist `appversion` 字段,等价 msext `[FuncPublic filename:@"appversion"]`)
|
||||||
|
- **H5 本地版本**:`Library/Caches/{gamedir}/{gamestart}/version.xml` 解析 `/game/version@value`
|
||||||
|
- msext 用 GDataXML(`NewRootVC.m:664-682`);新外壳用 Foundation `XMLParser`(无外部依赖)
|
||||||
|
- **缺失 / 损坏返回 0**(必须保留,是首装后首次升级的兜底机制;msext `[nil intValue] = 0`)
|
||||||
|
|
||||||
|
#### ADR-008-G LobbyZipUpgrader(1.13)
|
||||||
|
|
||||||
|
- 下载链路:`URLSession.download(from: gameZip)` async + 进度回调
|
||||||
|
- 解压:临时目录 `staging-{uuid}/` + 原子 `moveItem` rename(不学 msext 先删后压的中断风险)
|
||||||
|
- **失败处理**:msext `requestFailed:` 只打 NSLog 静默;新外壳应当**弹错误页 + 回退用 Bundle 内 H5**(旧 zip 仍可用)
|
||||||
|
|
||||||
|
#### ADR-008-H 5 字段的他用(必须保留)
|
||||||
|
|
||||||
|
- `showmessage`:仅 alert,不传 H5 / 不存 UserDefaults
|
||||||
|
- `version_ios`:① 决策;② `initJSdata` 间接通过 `app_data.js` 注入 `app_appversion` 字段传给 H5(值 0 = gameconfig / 1 = appleconfig,控制 H5 走两条不同分发路径)
|
||||||
|
- `app_download`:仅本次启动用
|
||||||
|
- `game_version_` / `game_zip_`:决策 + downFileFromServer 拼下载 URL
|
||||||
|
- 全部**不存 UserDefaults**(msext 无缓存层)
|
||||||
|
|
||||||
|
#### ADR-008-I 与 msext 差异(实施层面,H5 契约不变)
|
||||||
|
|
||||||
|
| 维度 | msext 现状 | 新外壳决策 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 网络库 | 主线程同步 `dataWithContentsOfURL:` + ASIHTTPRequest 下载 | `URLSession.data(from:)` + `download(from:)` async,actor 隔离 |
|
||||||
|
| 重试 | 4s timer 无上限 | 1.10 初版用 3 次退避;可改为 msext 风格无上限 |
|
||||||
|
| URL | HTTP | HTTPS(后台已切) |
|
||||||
|
| cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** |
|
||||||
|
| JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`(兼容 number-as-string) |
|
||||||
|
| 算法 | chulishengji 双子树合并 | **复刻 chulishengji**,纯函数 + 单测 |
|
||||||
|
| version.xml | GDataXML | Foundation XMLParser |
|
||||||
|
| 升级解压 | `removeItemAtPath` + ZipArchive | 临时目录 + 原子 rename |
|
||||||
|
| 失败 UI | 静默 / NSLog | 弹错误页 + 重试按钮(不留卡死黑洞) |
|
||||||
|
|
||||||
|
- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议
|
||||||
- **影响 Phase 2 及之后**:
|
- **影响 Phase 2 及之后**:
|
||||||
- Phase 2 / 3 等不再需要重做远程配置;只在新加 handler 时调用现成的 `RemoteConfigClient.current`
|
|
||||||
- Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径)
|
- Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径)
|
||||||
- Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径)
|
- Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
文档完成日期:2026-06-21
|
文档完成日期:2026-06-21
|
||||||
最后更新:2026-06-22(ADR-008 Phase 1 补远程配置 + zip 升级 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)
|
最后更新:2026-06-22(ADR-008 二次精确化 chulishengji 双子树算法 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)
|
||||||
|
|||||||
@@ -1083,7 +1083,9 @@ public struct Game: Codable, Sendable { ... }
|
|||||||
public struct Market: Codable, Sendable { ... }
|
public struct Market: Codable, Sendable { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 6.3.2 VersionResolver(纯函数)
|
#### 6.3.2 VersionResolver(chulishengji 双子树合并,纯函数)
|
||||||
|
|
||||||
|
**核心修正(ADR-008 二次精确化)**:线上热路径是 chulishengji 双子树合并,**不是**简单的"顶 → agent → channel → market"线性覆盖。
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
// Source/Network/VersionResolver.swift
|
// Source/Network/VersionResolver.swift
|
||||||
@@ -1094,10 +1096,16 @@ public enum VersionResolver {
|
|||||||
public let appDownload: String?
|
public let appDownload: String?
|
||||||
public let gameVersion: Int // 0 表示远端未指定
|
public let gameVersion: Int // 0 表示远端未指定
|
||||||
public let gameZip: String?
|
public let gameZip: String?
|
||||||
|
public let showmessage: String? // 非空时阻塞,运营杀手锏
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 4 级覆盖:顶层 → agent → channel → market,后者覆盖前者。
|
/// chulishengji 双子树合并(NewRootVC.m:1372-1538):
|
||||||
/// agent 子树和 game 子树各自合并,game 子树最终优先(msext 'chulishengji' 中的语义)。
|
/// 1. 从 agent 子树提取(agent → channel → market → game,game 嵌在 market 命中后才遍历)
|
||||||
|
/// 2. 从 game 子树提取(game → game-self → channel → market)
|
||||||
|
/// 3. 字段级合并:
|
||||||
|
/// - IPA(appVersion / appDownload)默认 agent 赢;game 版本号更大时 game 赢(line 1416)
|
||||||
|
/// - zip(gameVersion / gameZip)默认 game 赢;agent 版本号更大时 agent 赢(line 1456)
|
||||||
|
/// 4. showmessage 两条子树多层多次覆盖,最后写赢
|
||||||
public static func resolve(
|
public static func resolve(
|
||||||
config: RemoteConfig,
|
config: RemoteConfig,
|
||||||
agentId: String,
|
agentId: String,
|
||||||
@@ -1105,13 +1113,71 @@ public enum VersionResolver {
|
|||||||
marketId: String,
|
marketId: String,
|
||||||
gameId: String
|
gameId: String
|
||||||
) -> Resolved {
|
) -> Resolved {
|
||||||
// pure function: 不依赖任何 actor / global
|
// 1. 找到当前 agent / channel / market / game 节点
|
||||||
// 实现:reduce over [top, agent, channel, market]
|
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }),
|
||||||
...
|
let game = agent.gamelist?.first(where: { $0.gameid == gameId }) else {
|
||||||
|
// 找不到匹配(极端情况):返回 0 / nil,让 WebContainer 走"无升级"路径
|
||||||
|
return Resolved(appVersion: 0, appDownload: nil,
|
||||||
|
gameVersion: 0, gameZip: nil,
|
||||||
|
showmessage: config.showmessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
let agentExtract = extractAgentSubtree(
|
||||||
|
agent: agent, channelId: channelId, marketId: marketId, gameId: gameId)
|
||||||
|
let gameExtract = extractGameSubtree(
|
||||||
|
game: game, channelId: channelId, marketId: marketId)
|
||||||
|
|
||||||
|
// IPA 合并(NewRootVC.m:1408-1447)
|
||||||
|
let (appVer, appDL) = mergeIPA(agent: agentExtract, game: gameExtract)
|
||||||
|
// zip 合并(NewRootVC.m:1450-1492)
|
||||||
|
let (gameVer, gameZip) = mergeZip(agent: agentExtract, game: gameExtract)
|
||||||
|
// showmessage:任意子树非空就用(最后写赢)
|
||||||
|
let msg = gameExtract.showmessage ?? agentExtract.showmessage ?? config.showmessage
|
||||||
|
|
||||||
|
return Resolved(
|
||||||
|
appVersion: appVer,
|
||||||
|
appDownload: appDL,
|
||||||
|
gameVersion: gameVer,
|
||||||
|
gameZip: gameZip,
|
||||||
|
showmessage: msg
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// agent 子树 4 层提取(getagentversion,NewRootVC.m:885-1013)
|
||||||
|
/// agent → channel → market → game(game 嵌在 market 命中后才遍历)
|
||||||
|
/// 注意:agent 子树用 game_download 字段名,game 子树用 game_zip——两个都要查
|
||||||
|
private static func extractAgentSubtree(...) -> SubtreeResult { ... }
|
||||||
|
|
||||||
|
/// game 子树 4 层提取(getgameversion,NewRootVC.m:1015-1180)
|
||||||
|
/// game → game-self(gamedata 自身再嵌 channellist)→ channel → market
|
||||||
|
private static func extractGameSubtree(...) -> SubtreeResult { ... }
|
||||||
|
|
||||||
|
/// IPA 合并:默认 agent 赢;game.appVersion > agent.appVersion 时 game 赢
|
||||||
|
private static func mergeIPA(...) -> (Int, String?) { ... }
|
||||||
|
|
||||||
|
/// zip 合并:默认 game 赢;agent.gameVersion > game.gameVersion 时 agent 赢
|
||||||
|
private static func mergeZip(...) -> (Int, String?) { ... }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**关键测试用例**(单测必须覆盖):
|
||||||
|
|
||||||
|
| 用例 | 验证 |
|
||||||
|
|------|------|
|
||||||
|
| 顶层 / agent / channel / market / game 都有 app_version | agent 子树最深层赢;与 game 子树合并时按版本号大者 |
|
||||||
|
| 仅 agent 子树有,game 子树无 app_version | 取 agent |
|
||||||
|
| 仅 game 子树有,agent 子树无 app_version | 取 game |
|
||||||
|
| game.appVersion = 10 vs agent.appVersion = 5 | game 赢 |
|
||||||
|
| game.appVersion = 5 vs agent.appVersion = 10 | agent 赢 |
|
||||||
|
| game.gameVersion = 10 vs agent.gameVersion = 5 | game 赢(默认) |
|
||||||
|
| game.gameVersion = 5 vs agent.gameVersion = 10 | agent 赢 |
|
||||||
|
| showmessage 仅顶层有 | 取顶层 |
|
||||||
|
| showmessage agent 层有 + 顶层有 | 取 agent(深层覆盖浅层) |
|
||||||
|
| showmessage market 层有 + 全部层都有 | 取 market(最深) |
|
||||||
|
| agentid 找不到匹配 | 返回 default(0/nil),不抛 |
|
||||||
|
| gameid 找不到匹配 | 返回 default,不抛 |
|
||||||
|
| 字段名 game_download vs game_zip 混用 | 两个 key 都查,择一非空 |
|
||||||
|
|
||||||
#### 6.3.3 LocalVersionReader
|
#### 6.3.3 LocalVersionReader
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
|
|||||||
@@ -72,6 +72,40 @@ final class RootViewController: UIViewController {
|
|||||||
} else {
|
} else {
|
||||||
print("[WVJB.js] ERROR: 找不到 Bundle 内 JS/WebViewJavascriptBridge.js")
|
print("[WVJB.js] ERROR: 找不到 Bundle 内 JS/WebViewJavascriptBridge.js")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1.10 烟雾测试:拉远端渠道配置 .txt(含 cache-busting + 短文本响应识别)
|
||||||
|
Task {
|
||||||
|
let start = Date()
|
||||||
|
do {
|
||||||
|
let outcome = try await RemoteConfigClient.shared.fetch()
|
||||||
|
let elapsed = Date().timeIntervalSince(start)
|
||||||
|
switch outcome {
|
||||||
|
case .parsed(let cfg):
|
||||||
|
print("""
|
||||||
|
[RemoteConfigClient] 拉取成功 .parsed(耗时 \(String(format: "%.3f", elapsed))s)
|
||||||
|
showmessage = \(cfg.showmessage ?? "<nil>")
|
||||||
|
agentlist count = \(cfg.agentlist?.count ?? 0)
|
||||||
|
""")
|
||||||
|
if let first = cfg.agentlist?.first {
|
||||||
|
print("""
|
||||||
|
agent[0].agentid = \(first.agentid ?? "<nil>")
|
||||||
|
agent[0].appVersion = \(first.appVersion ?? "<nil>")
|
||||||
|
agent[0].gameVersion = \(first.gameVersion ?? "<nil>")
|
||||||
|
agent[0].channellist = \(first.channellist?.count ?? 0) 个
|
||||||
|
agent[0].gamelist = \(first.gamelist?.count ?? 0) 个
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
case .shortText(let msg):
|
||||||
|
print("""
|
||||||
|
[RemoteConfigClient] 拉到短文本响应(耗时 \(String(format: "%.3f", elapsed))s)
|
||||||
|
运营杀手锏:应当弹窗显示并停止重试
|
||||||
|
内容 = "\(msg)"
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[RemoteConfigClient] ERROR: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
|
||||||
|
public init(
|
||||||
|
session: URLSession = RemoteConfigClient.defaultSession,
|
||||||
|
urlBuilder: @escaping @Sendable () -> URL? = RemoteConfigClient.defaultURLBuilder,
|
||||||
|
maxRetries: Int = 3
|
||||||
|
) {
|
||||||
|
self.session = session
|
||||||
|
self.urlBuilder = urlBuilder
|
||||||
|
self.maxRetries = maxRetries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认 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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user