docs:Plan / Design 补 Phase 1 远程配置 + zip 升级流水线(ADR-008)

调研 daoqi/msext 原项目 NewRootVC.m:1551-1621 viewWillAppear、1372-1492
chulishengji、1789-1869 downFileFromServer 三段核心代码后发现 Plan 初版
Phase 1 漏掉了启动流程里最关键的一环——从 gameconfig 拼远端 .txt 拉
真实配置、做 agent → channel → market 4 级覆盖、对比本地版本、必要时
下载并替换 H5 zip。不做的代价是:上线后用户永远停留在 IPA 内打包瞬间
的 H5 旧版本,H5 团队任何更新都到达不了。

- Plan Phase 1 任务清单从 13 项扩为 17 项,插入 4 个新子项:
  - 1.10 RemoteConfigClient(actor,URLSession async + 指数退避)
  - 1.11 VersionResolver(纯函数 + 单测,reduce 实现 4 级覆盖)
  - 1.12 LocalVersionReader(解析 version.xml)
  - 1.13 LobbyZipUpgrader(actor,原子 rename)
  原 1.10-1.13 后移为 1.14-1.17。已完成的 1.1-1.9 标记为 
- Plan 新增 ADR-008 完整记录决策背景 / 触发事件 / 与 msext 实现差异
  对照表 / 守护条款 / 子游戏复用规划
- Design §6.3 整节重写:从原"ConfigService 一锅端"扩为完整 4 模块流水线
  (§6.3.1-6.3.7),含 Codable RemoteConfig 模型、纯函数 VersionResolver、
  nonisolated LocalVersionReader、actor LobbyZipUpgrader、WebContainer
  调用串、与 msext 差异表、子游戏 Phase 6 复用规划
- 关键设计纠正:
  - 远端 URL 不带 SERVERNew 前缀,仅 gameconfig.replace("-","/") + ".txt"
  - 4 级覆盖:顶层 → agent → channel → market 深层胜出 + game 子树覆盖
    agent 子树
  - 解压策略:staging-{uuid}/ 临时目录 + 原子 moveItem rename,不学
    msext "目录名 +1" 累积 hack
This commit is contained in:
joywayer
2026-06-22 00:42:30 +08:00
parent 65505b0a79
commit 5f032342e7
2 changed files with 298 additions and 47 deletions
+105 -36
View File
@@ -212,15 +212,17 @@ Contract Design Plan(本文档)
---
### Phase 1: 最小垂直闭环(资源 + 桥 + 第一个 handler
### Phase 1: 最小垂直闭环(资源 + 桥 + 远程配置 + 第一个 handler
#### 目标
打通**从启动 → 渠道注入读取 → 解压 H5 → 加载 file:// → JS 与原生互调**的全链路,用最简单的 handler `vibrator` 作为验证标的。
打通**从启动 → 渠道注入读取 → 远程配置拉取 → 版本对比 → zip 升级(若需要)→ 加载 H5 → JS 与原生互调**的全链路,用最简单的 handler `vibrator` 作为验证标的。
> **Phase 1 范围扩展(ADR-008**:原 Plan 漏掉了"远程配置 + 版本对比 + zip 升级"这一关键环节——若不做,上线后用户永远看不到 H5 团队的最新版本,永远停留在 IPA 内打包那一刻的 H5 旧版。2026-06-22 调研 msext `NewRootVC.m` 发现完整流程后插入新子项 1.10-1.13,原 1.10-1.13 后移为 1.14-1.17。
#### 前置
- ✅ Phase 0 基线
-`docs/res/gamehall.zip`2023-12 旧版,足以验收 Phase 1;实施时拷贝到 `ylgamehall/Resources/gamehall.zip`
- ⏳ 项目方提供 11 个渠道注入目录名值(缺则用临时 demo 值)
- ⏳ 项目方提供 11 个渠道注入目录名值(缺则用临时 demo 值,含可访问的远端 `gameconfig`.txt
- 备选:若 zip 加载有问题,临时用一个最小 H5(`<html>` 含一个 button 调 `bridge.callHandler('vibrator')`)做工程内测排查
#### 任务清单
@@ -228,59 +230,89 @@ Contract Design Plan(本文档)
##### 1.A 资源层
- [x] **1.1** 创建 `ylgamehall/Resources/ChannelConfig.plist` 含 11 个渠道键值对(沿用 msext 现网 demo 作为母包默认值;ADR-007 决策)
- [ ] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift`
- [x] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift`
- `init(bundle: Bundle = .main)`:读 `bundle.url(forResource: "ChannelConfig", withExtension: "plist")`,用 `PropertyListSerialization` 反序列化为 `[String: String]`
- 公开 11 个只读属性:`qiniuDomain` / `gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig`
- 单测 fixture:建立 mock bundle 含 fixture plist,验证 11 个 key 全部能读出且缺失 key 返回空串
- [ ] **1.3** 实现 `ylgamehall/Source/Resource/SandboxPaths.swift`
- [x] **1.3** 实现 `ylgamehall/Source/Resource/SandboxPaths.swift`
- 常量:`caches` / `documents` / `bundle`
- `lobbyIndex() -> URL` 拼出 `{Caches}/{gamedir}/{gamestart}/index.html`
- [ ] **1.4** 加 ZIPFoundation SPM 依赖
- [ ] **1.5** 实现 `ylgamehall/Source/Resource/ResourceUnzipper.swift`actor
- [x] **1.4** 加 ZIPFoundation SPM 依赖
- [x] **1.5** 实现 `ylgamehall/Source/Resource/ResourceUnzipper.swift`actor
-`docs/res/gamehall.zip` 拷贝一份到 `ylgamehall/Resources/gamehall.zip`
- `ensureReady() async throws`:检测 `version.xml` 不存在则解压 Bundle 内 `gamehall.zip` 到 caches
- 单测:用 fixture zip 验证解压幂等性
##### 1.B 桥核心
- [ ] **1.6** 实现 `Source/Bridge/BridgeProtocol.swift`
- [x] **1.6** 实现 `Source/Bridge/BridgeProtocol.swift`
- `BridgeProtocol` / `BridgeData` / `BridgeHandler` / `BridgeCallback` 类型
- [ ] **1.7** 实现 `Source/Bridge/BridgeBus.swift`@MainActor
- [x] **1.7** 实现 `Source/Bridge/BridgeBus.swift`@MainActor
- `register(_:handler:)` / `call(_:data:callback:)` / `didReceive(_:)`
- 单测:mock WKWebView,验证 handler 注册 / 分发 / callback 配对
- [ ] **1.8** 引入 WVJB JS 端协议源码
- [x] **1.8** 引入 WVJB JS 端协议源码
- 从 [marcuswestin/WebViewJavascriptBridge](https://github.com/marcuswestin/WebViewJavascriptBridge) 取 `WebViewJavascriptBridge.js.txt`,作为 `Resources/JS/WebViewJavascriptBridge.js`
- 注入方式:`WKUserScript``atDocumentStart`
##### 1.C WebView 容器
##### 1.C WebView 容器(视图层 + 一个 handler
- [ ] **1.9** 实现 `Source/WebView/BridgedWebView.swift`
- [x] **1.9** 实现 `Source/WebView/BridgedWebView.swift`
- WKWebView 配置照 Contract §4.1`javaScriptCanOpenWindowsAutomatically=NO``minimumFontSize=10``bounces=NO``scrollEnabled=NO` 等)
- `WKProcessPool` 单例 `SharedProcessPool.shared`
- [ ] **1.10** 实现 `Source/WebView/WebContainerViewController.swift`(基类)
##### 1.D 远程配置 + 版本对比 + zip 升级(**Phase 1 关键缺口,ADR-008**
- [ ] **1.10** 实现 `ylgamehall/Source/Network/RemoteConfigClient.swift`actor
- URL 构造:`http://` + `BundleConfig.shared.gameConfig.replacingOccurrences("-", "/")` + `.txt`**注意**:不带 `SERVERNew` 前缀,原 `daoqi/NewRootVC.m:250` 就是这样构造)
- 拉远端 `.txt` 内容(实际是 JSON)→ `URLSession.async data(from:)`10 s 超时
- 指数退避重试(msext 用 4s 等间隔暴力 timer,新外壳用 1s / 2s / 4s 三次)
- 反 JSON → 强类型 `RemoteConfig` Codable 模型(嵌套:`agentlist[].channellist[].marketlist[]` + `agentlist[].gamelist[].channellist[].marketlist[]`
- 单测:mock URLSession + fixture JSON 验证嵌套解析正确
- [ ] **1.11** 实现 `ylgamehall/Source/Network/VersionResolver.swift`(纯函数 + 单测)
- 输入:`RemoteConfig + (agentId, channelId, marketId, gameId)` 四组 ID
- 输出:`ResolvedVersion { appVersion, appDownload, gameVersion, gameZip }`
- 算法:4 级覆盖合并 —— 顶层 → 当前 agent → 当前 channel → 当前 market(最深胜出);agent 子树和 game 子树分别合并,game 子树最终覆盖 agent 子树
- 参考 `daoqi/NewRootVC.m:1372-1492` 的散落 if/else,新外壳实现成纯 reduce
- 单测:覆盖所有边界(顶层有/无、深层缺失、agent vs game 优先级 4 组场景至少 12 用例)
- [ ] **1.12** 实现 `ylgamehall/Source/Resource/LocalVersionReader.swift`nonisolated namespace
- `localAppVersion: Int`:从 `BundleConfig.shared.appVersion` 读,转 Int
- `localGameVersion: Int`:读 `Library/Caches/{gamedir}/{gamestart}/version.xml``XMLParser` 解析 `/game/version@value` 转 Int;缺失返回 0
- 单测:fixture xml 验证解析;缺失 / 损坏 xml 返回 0
- [ ] **1.13** 实现 `ylgamehall/Source/Resource/LobbyZipUpgrader.swift`actor
- `upgradeIfNeeded(remote: ResolvedVersion) async throws -> UpgradeOutcome`
- 比较 `remote.gameVersion > LocalVersionReader.localGameVersion` → 触发下载,否则直接返回 `.noop`
- 下载链路:`URLSession.download(from:)` → 临时文件 `tmp/lobbyzip-{uuid}.zip` → ZIPFoundation 解压到 `tmp/lobbyzip-{uuid}/`**原子 rename**`lobbyRoot`(解压前清空旧 lobbyRoot;不学 msext "目录名 +1" hack
- 失败处理:超时 / 网络断 / hash 校验失败 / 解压失败 → throw 类型化错误;调用方决定是否回退用旧 H5
- 单测:mock URLSession + fixture zip 验证升级路径与 noop 路径
##### 1.E 集成 + 烟雾测试
- [ ] **1.14** 实现 `Source/WebView/WebContainerViewController.swift`(基类)
- 持有 `BridgedWebView` + `BridgeBus`
- 16:9 比例布局:屏幕比 < 16:9 用宽度撑满上下黑边;屏幕比 > 16:9 用高度撑满左右黑边
- `viewDidLoad` 内串:`ResourceUnzipper.ensureReady() → RemoteConfigClient.fetch → VersionResolver.resolve → LobbyZipUpgrader.upgradeIfNeeded → loadFileURL(lobbyIndex)`
- 无升级路径走快路(< 200 ms),有升级路径显示 progress UI
- 实现 `webViewWebContentProcessDidTerminate:` 退避 reloadDesign §3.6
- [ ] **1.11** 实现 `Source/Bridge/Handlers/VibratorHandler.swift`
- [ ] **1.15** 实现 `Source/Bridge/Handlers/VibratorHandler.swift`
- `register(_ bridge)` 注册 `vibrator` handler → `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)` + responseCallback `"vibrator"`
##### 1.D 集成
- [ ] **1.12** `SceneDelegate``LobbyViewController`(继承 `WebContainerViewController`
- viewDidLoad: `ResourceUnzipper.ensureReady()` await 完成后 → `BridgedWebView.load(SandboxPaths.lobbyIndex())`
- [ ] **1.13** 把渠道目录里临时填入的 demo 值(如 `gamedir/demo` / `gamestart/gamehall`+ 一个 demo H5 跑通
- [ ] **1.16** `SceneDelegate``WebContainerViewController` 作 rootViewController
- 替换 M0 占位 `RootViewController`
- [ ] **1.17** 把渠道目录里临时填入的 demo 值 + 一个 demo H5 跑通 / 真机验证完整 1.10-1.16 链路
#### 验收
- 真机启动后能看到 H5 demo 页
- 真机启动后能看到 H5 demo 页(首次会先经历"远程配置拉取"流程)
- H5 按钮调 `bridge.callHandler('vibrator')` 后真机震动
- H5 收到 `responseCallback("vibrator")`
- BuildProject 通过;契约测试 `VibratorContractTest` 通过
- 模拟 `game_version` 远端 +1,重启 App 后能看到 zip 重新下载 + 解压 + 加载新版本
- BuildProject 通过;契约测试 `VibratorContractTest` + 单测 `VersionResolverTests``RemoteConfigClientTests` 通过
#### 风险
- WVJB JS 端协议握手时序对加载顺序敏感,必须用 `WKUserScript` 在 documentStart 注入
- iPad letterbox 实现细节:用 `aspectRatio` constraint vs 手算 frame;推荐用 Auto Layout `aspectRatio=16/9` + centerX/centerY
- 远端 `.txt` 实际是 JSON 但被命名为 .txt(msext 历史包袱),解析时不要被 mime-type 误导
- 远端配置 4 级覆盖逻辑历史代码 `daoqi/NewRootVC.m:1372-1492` 有几处可疑分支(`if(gameconfig.game_download && gameconfig.game_download)` 重复条件 / `if(result)` 包住整段 unzip 在首次时不进入),新外壳重写时**不要照抄分支结构**,按 ADR-008 描述的 reduce 算法重新表达
---
@@ -739,20 +771,24 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- [ ] 0.5 CI 流水线
- [ ] 0.6 SPM 包目录决策(暂单 target
### Phase 1 最小垂直闭环
### Phase 1 最小垂直闭环(含远程配置 + zip 升级,ADR-008
- [x] 1.1 ChannelConfig.plist 母包默认值(ADR-007
- [ ] 1.2 BundleConfig
- [ ] 1.3 SandboxPaths
- [ ] 1.4 ZIPFoundation SPM
- [ ] 1.5 ResourceUnzipper
- [ ] 1.6 BridgeProtocol
- [ ] 1.7 BridgeBus
- [ ] 1.8 WVJB JS 协议
- [ ] 1.9 BridgedWebView
- [ ] 1.10 WebContainerViewController16:9 letterbox
- [ ] 1.11 VibratorHandler
- [ ] 1.12 SceneDelegate → LobbyViewController
- [ ] 1.13 Demo H5 联调
- [x] 1.2 BundleConfig
- [x] 1.3 SandboxPaths
- [x] 1.4 ZIPFoundation SPM
- [x] 1.5 ResourceUnzipper
- [x] 1.6 BridgeProtocol
- [x] 1.7 BridgeBus
- [x] 1.8 WVJB JS 协议
- [x] 1.9 BridgedWebView
- [ ] 1.10 RemoteConfigClientactorURLSession async + 重试
- [ ] 1.11 VersionResolver(纯函数 4 级覆盖 + 单测)
- [ ] 1.12 LocalVersionReaderversion.xml 解析)
- [ ] 1.13 LobbyZipUpgraderactor,原子 rename 升级)
- [ ] 1.14 WebContainerViewController16:9 letterbox + 串接整链路)
- [ ] 1.15 VibratorHandler
- [ ] 1.16 SceneDelegate → WebContainerViewController
- [ ] 1.17 Demo H5 联调 + 真机验证升级路径
### Phase 2 大厅简单 handler + 反向 callback
- [ ] 2.1 剪贴板
@@ -966,7 +1002,40 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- 不允许把渠道值硬编码到 Swift 源码(违背"母包模式"的初衷:一份二进制 N 个渠道)
- **回滚条件**:若未来 Xcode / iOS 改动让 plist 方案无法工作(极不可能)或后处理脚本失效,重新评估目录名方案或其它存储介质
### ADR-008Phase 1 补全远程配置 + 版本对比 + zip 升级(2026-06-22
- **背景**Plan 初版 Phase 1 任务清单(13 项)只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5"**漏掉了 msext 启动流程里最关键的一环**:从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置,做 agent → channel → market 4 级覆盖,对比本地版本,必要时下载并替换 H5 zip
- **触发事件**2026-06-22 调研 `daoqi/msext` 原项目(subagent 详细分析 `NewRootVC.m:1551-1621` viewWillAppear、`NewRootVC.m:1372-1492` chulishengji、`NewRootVC.m:1789-1869` downFileFromServer 三段核心代码),发现:
- 远端配置 URL 不是 `SERVERNew + gameconfig` 拼接,而是 `"http://" + gameconfig.replacingOccurrences("-", "/") + ".txt"`(当前 demo 值即 `http://tsgames.daoqi88.cn/config_test/update_jsonv2_test.txt`
- JSON 结构是 `agentlist[].channellist[].marketlist[]` 三级嵌套 + `agentlist[].gamelist[].channellist[].marketlist[]` 副路径,每层都可携带同名版本字段,深层覆盖浅层
- 两条独立升级链:① 原生 IPA 升级(`app_version > 本地 appversion 注入值` → 弹窗 + Safari 外链)② H5 zip 升级(`game_version > 本地 version.xml /game/version@value` → 下载 + 解压 + reload
- 不做这一步的代价:上线后用户永远停在 IPA 内打包瞬间的 H5 版本,H5 团队任何更新都到达不了用户
- **决策**:在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`,原 `1.10-1.13` 后移为 `1.14-1.17`
- **理由**
- **WebContainer 的 `loadFileURL` 在无升级链路前提下是有缺陷的**——只能加载 IPA 内静态 zip
- 推迟到 Phase 2 也行得通,但 Phase 1 验收"看到大厅 H5"的语义会被掩盖(看到的是 IPA 内的旧版,跟"上线后看到的"完全不同)
- 一次性补齐使 Phase 1 验收能模拟真实上线场景:模拟远端 `game_version +1` 重启 App 必须看到新版本下载 + 加载
- **关键设计点(与 msext 实现的差异)**:
| 维度 | msext 现状 | 新外壳决策 |
|------|----------|----------|
| 网络库 | `[NSData dataWithContentsOfURL:]` 主线程阻塞 / ASIHTTPRequest 下载 | `URLSession async data(from:)` + `download(from:)`actor 隔离 |
| 失败重试 | 4 秒等间隔暴力 `timer` | 指数退避 1/2/4 秒三次,可取消 |
| 4 级覆盖算法 | 散落 if/else 在 `getagentversion` / `getgameversion` / `chulishengji` 三方法手写 | 纯函数 reduce,单测覆盖所有边界 |
| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留残骸 | 解压到临时目录 + 原子 rename,无半残状态 |
| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`,靠系统清 Caches) | 同样的原子 rename 策略,旧目录直接覆盖 |
| 解析 JSON | SBJSON 三方库 | Codable + JSONDecoder |
| 版本号类型 | NSString → intValue(自动当 0 处理) | 强类型 Int,缺失 throw 类型化错误 |
- **守护**
- **H5 端契约不变**:H5 看到的仍是"启动一段时间后看到大厅",对 4 级覆盖 / 升级链路完全无感
- `gameconfig` 注入值是远端 URL 的唯一驱动,**不允许把远端 URL 硬编码在 Swift 源码里**
- `version.xml` 文件名 / xpath `/game/version@value` 不允许改名(H5 自更新时会写这个文件,新外壳读它,双方协议)
- 4 级覆盖的优先级(agent > 顶层、channel > agent、market > channel、game 子树最终覆盖 agent 子树)必须由单测固化,避免回归
- **回滚条件**:若远端 `.txt` 配置服后台被替换为 RESTful API(路径 / 字段名变化),重新评估并实现新协议;JSON 嵌套结构变化同理
- **影响 Phase 2 及之后**
- Phase 2 / 3 等不再需要重做远程配置;只在新加 handler 时调用现成的 `RemoteConfigClient.current`
- Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计(仅参数化目录路径)
- Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改(ADR-007 `plutil` 路径)
---
文档完成日期:2026-06-21
最后更新:2026-06-21ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)
最后更新:2026-06-22ADR-008 Phase 1 补远程配置 + zip 升级 + ADR-007 渠道注入改 ChannelConfig.plist + ADR-006 纯 SPM + Vendor + ADR-005 极光降级 + Resources/ 目录记录)
+193 -11
View File
@@ -1022,25 +1022,207 @@ final class SigningContractTests: XCTestCase {
}
```
### 6.3 配置 / Zip 下载层
### 6.3 远程配置 + 版本对比 + zip 升级流水线(**Phase 1 实施**ADR-008 详细记录)
完整链路由 4 个独立模块构成,全部 actor 隔离 + 单测覆盖:
```
RemoteConfigClient → VersionResolver → LocalVersionReader → LobbyZipUpgrader
(拉 .txt 配置) (4 级覆盖合并) (读 version.xml) (URLSession 下载 + 原子 rename)
↓ ↓ ↓ ↓
RemoteConfig ResolvedVersion (appVer, gameVer) UpgradeOutcome
{appVer, appDL, .noop / .upgraded
gameVer, gameZip}
```
#### 6.3.1 RemoteConfigClient
```swift
// ResourceKit/ConfigService.swift
public actor ConfigService {
private let client: HTTPClient
private let unzipper: ResourceUnzipper
// Source/Network/RemoteConfigClient.swift
public actor RemoteConfigClient {
private let session: URLSession
private let urlBuilder: () -> URL
public func syncIfNeeded() async throws {
let config = try await fetchRemoteConfig()
let local = try LocalVersion.read()
if local.gameVersion < config.gameVersion {
try await downloadAndUnzip(config.gameZipURL)
/// BundleConfig.shared.gameConfig
/// http:// + gameConfig.replacingOccurrences("-", "/") + ".txt"
/// msext SERVERNew NewRootVC.m:250
public init(
session: URLSession = .shared,
urlBuilder: @escaping () -> URL = Self.defaultURL
) { ... }
/// .txt JSON10s
/// 退 1/2/4 3 msext 4s timer
public func fetch() async throws -> RemoteConfig {
for attempt in 0..<3 {
do { return try await fetchOnce() }
catch where attempt < 2 {
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
}
}
// agent / channel / market / agentlist / gamelist
throw RemoteConfigError.allRetriesFailed
}
}
public struct RemoteConfig: Codable, Sendable {
public let showmessage: String?
public let agentlist: [Agent]?
}
public struct Agent: Codable, Sendable {
public let agentid: String
public let showmessage: String?
public let app_version: String?
public let app_download: String?
public let game_version: String?
public let game_zip: String?
public let channellist: [Channel]?
public let gamelist: [Game]?
}
public struct Channel: Codable, Sendable { ... }
public struct Game: Codable, Sendable { ... }
public struct Market: Codable, Sendable { ... }
```
#### 6.3.2 VersionResolver(纯函数)
```swift
// Source/Network/VersionResolver.swift
public enum VersionResolver {
public struct Resolved: Sendable {
public let appVersion: Int // 0
public let appDownload: String?
public let gameVersion: Int // 0
public let gameZip: String?
}
/// 4 agent channel market
/// agent game game msext 'chulishengji'
public static func resolve(
config: RemoteConfig,
agentId: String,
channelId: String,
marketId: String,
gameId: String
) -> Resolved {
// pure function: actor / global
// reduce over [top, agent, channel, market]
...
}
}
```
#### 6.3.3 LocalVersionReader
```swift
// Source/Resource/LocalVersionReader.swift
public enum LocalVersionReader {
/// IPA ChannelConfig.plist appversion BundleConfig
nonisolated public static var localAppVersion: Int {
Int(BundleConfig.shared.appVersion) ?? 0
}
/// H5 Library/Caches/{gamedir}/{gamestart}/version.xml
/// /game/version value XMLParser / 0
nonisolated public static var localGameVersion: Int { ... }
}
```
#### 6.3.4 LobbyZipUpgrader
```swift
// Source/Resource/LobbyZipUpgrader.swift
public actor LobbyZipUpgrader {
public enum UpgradeOutcome {
case noop //
case upgraded(from: Int, to: Int)
}
public func upgradeIfNeeded(
remoteGameVersion: Int,
remoteGameZip: String?
) async throws -> UpgradeOutcome {
let local = LocalVersionReader.localGameVersion
guard remoteGameVersion > local, let zipURL = remoteGameZip.flatMap(URL.init) else {
return .noop
}
// 1. tmp URLSessionDownloadTask
let (tmpZip, _) = try await session.download(from: zipURL)
// 2.
let stagingDir = SandboxPaths.caches.appendingPathComponent("staging-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true)
try FileManager.default.unzipItem(at: tmpZip, to: stagingDir)
// 3. rename lobbyRoot stagingDir
let lobbyRoot = SandboxPaths.lobbyRoot
try? FileManager.default.removeItem(at: lobbyRoot)
try FileManager.default.moveItem(at: stagingDir, to: lobbyRoot)
// 4. tmp
try? FileManager.default.removeItem(at: tmpZip)
return .upgraded(from: local, to: remoteGameVersion)
}
}
```
#### 6.3.5 WebContainer 调用串
```swift
// Source/WebView/WebContainerViewController.swiftPhase 1.14
override func viewDidLoad() {
super.viewDidLoad()
Task { @MainActor in
try await ResourceUnzipper.shared.ensureReady() // Bundle zip
let config = try await RemoteConfigClient.shared.fetch()
let resolved = VersionResolver.resolve(
config: config,
agentId: BundleConfig.shared.agent,
channelId: BundleConfig.shared.channel,
marketId: BundleConfig.shared.market,
gameId: BundleConfig.shared.gameId
)
if resolved.appVersion > LocalVersionReader.localAppVersion {
// + Safari resolved.appDownload
return showAppUpgradeAlert(resolved.appDownload)
}
_ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
remoteGameVersion: resolved.gameVersion,
remoteGameZip: resolved.gameZip
)
bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
}
}
```
#### 6.3.6 与 msext 历史实现的差异(不要照抄的部分)
| 维度 | msext 现状(不要照抄) | 新外壳决策 |
|------|--------------------|----------|
| 网络模型 | `[NSData dataWithContentsOfURL:]` 主线程同步阻塞 + ASIHTTPRequest 后台下载 | URLSession `async data(from:)` / `download(from:)`actor 隔离 |
| 失败重试 | `viewDidLoad` 起 4s 等间隔 `timer`,至无穷直到成功 | 指数退避 1/2/4 秒最多 3 次,超出 throw 由 UI 决定回退路径 |
| 4 级覆盖 | `getagentversion` / `getgameversion` / `chulishengji` 三段散落 if/else | 纯函数 `VersionResolver.resolve`,单测覆盖所有边界 12+ 用例 |
| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`,半途崩溃留半残 | 解压到 `staging-{uuid}/` 临时目录 + 原子 `moveItem` rename,半途崩溃只留 tmp(下次启动可清) |
| 子游戏目录冲突 | 命名 `+1` 累积(`XXX → XXX1 → XXX11`) | 同款原子 rename,旧目录直接覆盖 |
| 配置解析 | SBJSON 三方库 | Codable + JSONDecoder |
| 版本号 | NSString → intValue(自动 0 | 强类型 Int,缺失明示 |
#### 6.3.7 子游戏升级复用
Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownloadurl`)的升级逻辑**复用** `LobbyZipUpgrader` 的设计,差异仅在:
- 目标路径不同(`SandboxPaths.subGameRoot(dir)` 而非 `lobbyRoot`
- 版本号 source 不同(子游戏 `version.xml` 而非大厅)
- 不重新拉远端配置(继承大厅的 `RemoteConfig`
具体接口扩展到 Phase 6 设计时定。
---
## 7. 资源 & 渠道注入