§7.5 路径回退到 writeToFile:撤销 evaluateJavaScript 路径

CLAUDE.md 原则 A 升级为第一准则(H5 端零修改不可妥协)后,重新评估
上一轮 commit 246f215 引入的 evaluateJavaScript 路径:

  - WKUserScript(.atDocumentStart) 注入会被 H5 自带 var app_xxx 声明
    覆盖回退
  - WKUserScript(.atDocumentEnd) 又太晚,H5 业务顶层 <script> 已经
    读过默认值
  - 没有"在 <script src> 之后、业务 <script> 之前"的精确时机
  - 任何"让 H5 改一行 / 改一个文件"的妥协方案违反原则 A 第一准则
  - 详细分析见 CLAUDE.md「典型案例 3」

撤销方案:回到原 msext writeToFile 路径,原生在 loadFileURL 之前写
4 个 app_*.js 文件到沙盒,H5 启动时 <script src> 同步引入即可读到
实际值,时序 100% 等价 msext。

Design §7.5 全面回退(-285 / +126 行):
  - §7.5 头部:标题 / 简介改回写文件路径;中间走过两次弯路的完整决策
    链记录在头部(commit f68b0db246f215 → 本次 commit)
  - §7.5.2 注入时机表 → 4 个文件写入时机表,附"为什么不要照搬
    WKUserScript 注入"警告段
  - §7.5.3 删除 AppDataInjector + .legacy 标注;AppDataWriter 升回主路径
    (保留 logger debug 输出 / msext 字面对齐细节 / 转义 / 大小写)
  - §7.5.4 接入点:writeInitial + writeBattery + writeNetwork 三阶段,
    addObserver 持续监听
  - §7.5.5 大厅 / 子游戏 / 弹层差异表回到 "app_data.js / app_gamesname.js
    / app_battery.js / app_network.js" 维度
  - §7.5.6 与 msext 差异表回到"writeToFile 等价化"对比
  - §7.5.7 与 §3.4.1 polyfill 关系:§7.5 文件预写为主路径;
    WKUserScript + evaluateJavaScript "探索后撤回"
  - §7.5.8 联调路径恢复 cat 文件路径,加 4 种现象诊断表

Contract §4.2 同步:
  - 头部说明改回 "原生 writeToFile 写文件" + 撤销中间 evaluateJavaScript
    路径的复盘段
  - 子节标题回到 "写到 {gamedir}/{gamestart}/app_*.js"
  - 子节"更新时机"段回到 loadFileURL 前 / addObserver / 前台切换
  - 删除"H5 团队 zip 自带 + 默认占位"约定(不再适用,原生重新负责写入)
  - 修订记录新增一行"路径再次调整(最终)"

Contract §10 验收清单同步:
  - "H5 zip 自带 4 个 .js" → "沙盒下 4 个 .js 存在(xcrun simctl + cat 验证)"
  - 删"原生 WKUserScript 覆盖生效"项 → 加"值是实际渠道值而非占位"
  - "阶段 B 动态覆盖" → "battery/network 变化重写文件"

Plan §6.4 里程碑加两行:CLAUDE.md A 升级 + §7.5 最终回退。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 12:40:07 +08:00
co-authored by Claude Opus 4.7
parent 3039daf903
commit 13cc24b463
3 changed files with 127 additions and 286 deletions
+3 -1
View File
@@ -766,7 +766,9 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
| 2026-06-22 | Design 蓝图按 Contract 全 51 项接口补完实现骨架(H5↔Native 5 条通讯路径):22 项 §3.1 异步 handler / 15 项 §3.2 反向 callback / 3 项 §3.4 弹层 polyfill / 15 项 §7.5 `app_*.js` 预注入全局变量 / 2 项 §3.7 WKUIDelegate alert/confirm | `92298b6` / `c6221d3` / `d48ecc0` / `a06a18c` / `5f5f6a9` / `33574a1` / `f68b0db` | | 2026-06-22 | Design 蓝图按 Contract 全 51 项接口补完实现骨架(H5↔Native 5 条通讯路径):22 项 §3.1 异步 handler / 15 项 §3.2 反向 callback / 3 项 §3.4 弹层 polyfill / 15 项 §7.5 `app_*.js` 预注入全局变量 / 2 项 §3.7 WKUIDelegate alert/confirm | `92298b6` / `c6221d3` / `d48ecc0` / `a06a18c` / `5f5f6a9` / `33574a1` / `f68b0db` |
| 2026-06-22 | Contract §4.2 按 daoqi 原项目代码(grep `var app_` 字面字符串)全面修订:删 `app_gameid` / `app_compareCode`(原项目不写)/ 修正 `app_battery``app_getbattery``app_network``app_getnetwork`(文件名不带 get、变量名带 get)/ 补 6 项遗漏(version / Launchtype / getwifisignalLevel / gamename / invitationcode / gamesname | `47a89aa` | | 2026-06-22 | Contract §4.2 按 daoqi 原项目代码(grep `var app_` 字面字符串)全面修订:删 `app_gameid` / `app_compareCode`(原项目不写)/ 修正 `app_battery``app_getbattery``app_network``app_getnetwork`(文件名不带 get、变量名带 get)/ 补 6 项遗漏(version / Launchtype / getwifisignalLevel / gamename / invitationcode / gamesname | `47a89aa` |
| 2026-06-22 | CLAUDE.md 新增第 2 个典型案例「H5 与原生通讯接口的真实路径」记录"凭语义猜测会一字之差犯错、必须 grep 字面字符串验证"的教训 | `fae7b3d` | | 2026-06-22 | CLAUDE.md 新增第 2 个典型案例「H5 与原生通讯接口的真实路径」记录"凭语义猜测会一字之差犯错、必须 grep 字面字符串验证"的教训 | `fae7b3d` |
| 2026-06-22 | **§7.5 重大路径调整**:从「原生 `writeToFile` 写 4 个 `app_*.js` 文件」改为「H5 团队 zip 自带 + 原生 `WKUserScript(.atDocumentEnd)` / `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量」。Design §7.5 全面重写(`AppDataWriter``AppDataInjector`),Contract §4.2 同步新契约,§10 验收清单调整 | 本次 commit | | 2026-06-22 | **§7.5 重大路径调整**:从「原生 `writeToFile` 写 4 个 `app_*.js` 文件」改为「H5 团队 zip 自带 + 原生 `WKUserScript(.atDocumentEnd)` / `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量」 | `246f215` |
| 2026-06-22 | **CLAUDE.md 原则 A 升级为第一准则**:H5 端零修改不可妥协,禁止任何"H5 改一行 / 改一个文件 / 加 polyfill / 调时机"的妥协方案。加典型案例 3 复盘 `app_*` 注入时序问题 | `3039daf` |
| 2026-06-22 | **§7.5 路径再次回退(最终)**:撤销 `246f215`,回到 msext 原写文件路径 `AppDataWriter`。原因:WKUserScript 时序(documentStart 被 H5 自带 var 覆盖、documentEnd 又太晚)无法 1:1 等价 msext,任何要求 H5 配合改动的方案违反原则 A 第一准则。Design §7.5 + Contract §4.2 + §10 全面同步回退 | 本次 commit |
> **§3.4.1 撤销说明**:早期 commit `82bad8a` 实现的 `window.settings.getXxx()` polyfill 已撤销 — Contract §附录 A 自身明示「iOS<9 路径,新外壳如最低系统 ≥ iOS 14 可不实现」,本项目最低 iOS 15.6 → polyfill 路径未启用,H5 用 §7.5 的 `app_*.js` 文件路径作为唯一主路径。详细缘由见 Design §3.4.1(撤销说明)和 CLAUDE.md「典型案例」段。 > **§3.4.1 撤销说明**:早期 commit `82bad8a` 实现的 `window.settings.getXxx()` polyfill 已撤销 — Contract §附录 A 自身明示「iOS<9 路径,新外壳如最低系统 ≥ iOS 14 可不实现」,本项目最低 iOS 15.6 → polyfill 路径未启用,H5 用 §7.5 的 `app_*.js` 文件路径作为唯一主路径。详细缘由见 Design §3.4.1(撤销说明)和 CLAUDE.md「典型案例」段。
+24 -32
View File
@@ -690,22 +690,16 @@ _webView.scrollView.showsHorizontalScrollIndicator = NO;
- LocalStorage 由 H5 自管(WKWebView 默认走 WebsiteDataStore - LocalStorage 由 H5 自管(WKWebView 默认走 WebsiteDataStore
- 加载方式:**`loadFileURL:fileURL allowingReadAccessToURL:dirURL`**file://),不走 HTTP - 加载方式:**`loadFileURL:fileURL allowingReadAccessToURL:dirURL`**file://),不走 HTTP
### 4.2 JS 注入(**2026-06-22 重大路径调整:原生不写文件,只覆盖 `window.app_xxx` 全局变量** ### 4.2 JS 注入(最终路径:原生 `writeToFile` 写文件,与 msext 1:1 等价
H5 业务通过 `<script src="app_*.js">` 同步引入 4 个 `.js` 文件,文件内是 `var app_xxx = "<默认占位>"` 形式的全局变量声明。 H5 业务通过 `<script src="app_*.js">` 同步引入 4 个 `.js` 文件,文件内是 `var app_xxx = "..."` 形式的全局变量声明。**原生在 `loadFileURL` 之前用 `writeToFile`/ `String.write(to:)` 把这 4 个文件写到 `{gamedir}/{gamestart}/`H5 启动时 `<script src>` 同步引入直接读到实际值**。
**契约调整**(与原 msext 行为差异): > ⚠️ **2026-06-22 路径决策最终落定**(中间曾尝试改为"H5 自带 + 原生 `evaluateJavaScript` 覆盖 `window.app_xxx`"路径,commit `246f215`,已撤销)。撤销原因:`WKUserScript(.atDocumentStart)` 注入会被 H5 自带 `var app_xxx` 声明覆盖回退;`.atDocumentEnd` 又太晚,H5 业务顶层 `<script>` 已经读过默认值。任何"H5 配合改动"的妥协方案违反 CLAUDE.md 原则 A 第一准则。正确解法:回到 msext 老路写文件,时序 100% 等价。详细分析见 CLAUDE.md「典型案例 3」。
| 阶段 | msext 旧契约 | 新契约 | 原生侧的工作(与原 msext `NewRootVC.initJSdata` 等价):
|---|---|---| 1. **`loadFileURL` 之前**用 `String.write(to:)` 写 4 个 `.js` 文件到 `{gamedir}/{gamestart}/` 沙盒目录
| `app_*.js` 文件来源 | 原生 `NewRootVC.initJSdata``writeToFile:` 每次启动时写入沙盒 | **H5 团队随 `gamehall.zip` 一起打包**(带默认占位值),原生不写 | 2. battery / network 变化时**重写**对应 `.js` 文件(H5 下次 reload 读到新值;业务期间的实时变化由 §3.2 [2][3]反向 callback 推送)
| `app_xxx` 全局变量赋值 | H5 引入文件后由文件内的 `var` 声明赋值 | H5 引入文件后默认占位生效;原生用 `WKUserScript(.atDocumentEnd)` + `evaluateJavaScript` 覆盖 `window.app_xxx` 为实际值 | 3. 文件内容用单引号包裹字符串(与 msext 字面对齐),数值字段无引号
| 路径变量 (`{gamedir}/{gamestart}/`) | 原生需知道写入路径 | 由 H5 zip 包结构自带,原生不感知 |
新契约下原生侧的工作:
1.`WKWebViewConfiguration` 装配阶段 addUserScript 一段 `(function(){ window.app_version=1; window.app_gameconfig='...'; ... })()`injectionTime `.atDocumentEnd`
2. battery / network 变化时用 `webView.evaluateJavaScript("window.app_getbattery=...")` 单条覆盖
3. 不再 `writeToFile`,不再关心文件路径
**⚠️ 命名约束:文件名 ≠ 变量名**(沿用 msext 历史) **⚠️ 命名约束:文件名 ≠ 变量名**(沿用 msext 历史)
@@ -740,42 +734,39 @@ var app_invitationcode = "<tuiguang_id>"; // 推广码 / 邀请码
> - `app_gamedir`:大厅 = `self.gamedir`(即 BundleConfig.gameDir/ 子游戏 = `self.gamefilepath`(子游戏目录) > - `app_gamedir`:大厅 = `self.gamedir`(即 BundleConfig.gameDir/ 子游戏 = `self.gamefilepath`(子游戏目录)
> - `app_gamestart` + `app_gamename`:大厅 = `self.gamestart` / 子游戏 = `self.game_name`(子游戏名) > - `app_gamestart` + `app_gamename`:大厅 = `self.gamestart` / 子游戏 = `self.game_name`(子游戏名)
#### `app_battery.js`H5 zip 包内,含 `app_getbattery` 默认占位 #### `app_battery.js`写到 `{gamedir}/{gamestart}/app_battery.js`
H5 端默认形式:
```js ```js
var app_getbattery = 0.85; // ⚠️ 变量名带 getmsext "var app_getbattery=%lf" double 字面 var app_getbattery = 0.85; // ⚠️ 变量名带 getmsext "var app_getbattery=%lf" double 字面
``` ```
原生覆盖时机(每次写一行 `window.app_getbattery = N;` 更新时机
- 首次 `webView didFinish` 补一次(保证页面就绪即可读到当前电量) - `loadFileURL` 之前补一次(保证 H5 启动就能读到当前电量)
- `UIDevice.batteryLevelDidChangeNotification` 触发 - `UIDevice.batteryLevelDidChangeNotification` 触发重写
- `UIApplication.willEnterForegroundNotification`(前台切换补 retroact - `UIApplication.willEnterForegroundNotification`(前台切换补 retroact
#### `app_network.js`H5 zip 包内,含 `app_getnetwork` 默认占位 #### `app_network.js`写到 `{gamedir}/{gamestart}/app_network.js`
H5 端默认形式:
```js ```js
var app_getnetwork = 2; // ⚠️ 变量名带 getmsext "var app_getnetwork=%d" int 字面:1 无网 / 2 WiFi / 3 蜂窝 var app_getnetwork = 2; // ⚠️ 变量名带 getmsext "var app_getnetwork=%d" int 字面:1 无网 / 2 WiFi / 3 蜂窝
``` ```
原生覆盖时机: 更新时机:
- 首次 `webView didFinish` 补一次 - `loadFileURL` 之前补一次
- `NWPathMonitor` 状态变化(msext 用 AFNetworkReachability,新外壳用 NWPathMonitor - `NWPathMonitor` 状态变化(msext 用 AFNetworkReachability,新外壳用 NWPathMonitor
- 前台切换 - 前台切换
#### `app_gamesname.js`H5 zip 包内,含 `app_gamesname` 默认占位 #### `app_gamesname.js`写到 `{gamedir}/{gamestart}/app_gamesname.js`
H5 端默认形式:
```js ```js
var app_gamesname = new Array('game1','game2',...); // ⚠️ var 后面两个空格,沿用 msext AppDelegate.m:259 字面 var app_gamesname = new Array('game1','game2',...); // ⚠️ var 后面两个空格,沿用 msext AppDelegate.m:259 字面
``` ```
原生覆盖时机(合并进首次 documentEnd UserScript,即阶段 A 更新时机
- 首次启动覆盖 - `AppDelegate.applicationDidFinishLaunching`首次启动写入)
- 新装一个子游戏后下次进入大厅时覆盖(msext `gameController.m:197` 历史上注释掉了实际写入,仅 `AppDelegate` 首次有效;新外壳每次进入大厅都覆盖一次,更稳 - 子游戏 `gameController.viewInit`(新装一个子游戏后追加;msext `gameController.m:197` 历史上注释掉了实际写入逻辑,仅 `AppDelegate` 首次写入是活的
> ⚠️ **新外壳约定**H5 团队保证 `gamehall.zip` 内 4 个 `app_*.js` 都存在,并提供合理默认占位(即使原生注入失败,H5 至少能读到默认值不崩)。原生不再保证"loadFileURL 之前文件已存在"——这是 H5 端打包责任 > H5 模板里 `index.html` 通过 `<script src="app_data.js">` 等同步引入这 4 个文件。新外壳必须保证这些文件**在 loadFileURL 之前**就写好,否则 H5 启动时变量未定义直接崩
#### 修订记录 #### 修订记录
@@ -784,7 +775,8 @@ var app_gamesname = new Array('game1','game2',...); // ⚠️ var 后面两
| 2026-06-22 | 删 `app_gameid` / `app_compareCode`:原项目 grep `var app_``NewRootVC.m:1204` / `gameController.m:2160` 字面字符串里没有这两项 | daoqi 原项目代码全面 grep 验证 | | 2026-06-22 | 删 `app_gameid` / `app_compareCode`:原项目 grep `var app_``NewRootVC.m:1204` / `gameController.m:2160` 字面字符串里没有这两项 | daoqi 原项目代码全面 grep 验证 |
| 2026-06-22 | `app_battery` 改为 `app_getbattery``app_network` 改为 `app_getnetwork`:变量名带 `get` 前缀,与文件名(不带 get)不一致 | daoqi `gameController.m:1191/1198``NewRootVC.m:1706/1714` | | 2026-06-22 | `app_battery` 改为 `app_getbattery``app_network` 改为 `app_getnetwork`:变量名带 `get` 前缀,与文件名(不带 get)不一致 | daoqi `gameController.m:1191/1198``NewRootVC.m:1706/1714` |
| 2026-06-22 | 新增 `app_version` / `app_Launchtype` / `app_getwifisignalLevel` / `app_gamename` / `app_invitationcode` / `app_gamesname` 共 6 项 | daoqi `NewRootVC.m:1204``gameController.m:2160``AppDelegate.m:259` | | 2026-06-22 | 新增 `app_version` / `app_Launchtype` / `app_getwifisignalLevel` / `app_gamename` / `app_invitationcode` / `app_gamesname` 共 6 项 | daoqi `NewRootVC.m:1204``gameController.m:2160``AppDelegate.m:259` |
| 2026-06-22 | **路径调整**:从"原生 `writeToFile` 写 4 个 `.js` 文件"改为"H5 团队 zip 包自带 + 原生 `WKUserScript(.atDocumentEnd)` / `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量值"。原生不再感知文件路径与字符串包裹符 | H5 团队 / 项目方约定 | | 2026-06-22 | **路径调整**:从"原生 `writeToFile` 写 4 个 `.js` 文件"改为"H5 团队 zip 包自带 + 原生 `WKUserScript(.atDocumentEnd)` / `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量值" | H5 团队 / 项目方约定 |
| 2026-06-22 | **路径再次调整(最终)**:撤销上一行的 evaluateJavaScript 路径,回到原 msext "原生 `writeToFile` 写文件"路径。原因:WKUserScript 时序无法 1:1 等价 msext`.atDocumentStart` 被 H5 自带 var 覆盖,`.atDocumentEnd` 太晚 H5 业务已读默认值),任何要求 H5 配合改动的方案违反 CLAUDE.md 原则 A 第一准则。Design §7.5 + 本节同步回退 | CLAUDE.md 原则 A 升级为第一准则后的重新评估 |
### 4.3 WKUIDelegateH5 alert / confirm ### 4.3 WKUIDelegateH5 alert / confirm
@@ -1004,12 +996,12 @@ Library/Caches/
- [ ] 重装 + 热启动:< 1 秒进入大厅 - [ ] 重装 + 热启动:< 1 秒进入大厅
- [ ] 渠道注入:把 `msext/channel/xxx` 目录名改成 `yyy`,重打包后大厅显示的渠道 ID 变为 `yyy` - [ ] 渠道注入:把 `msext/channel/xxx` 目录名改成 `yyy`,重打包后大厅显示的渠道 ID 变为 `yyy`
- [ ] iOS 18 / 19 / 26 上启动不卡死 - [ ] iOS 18 / 19 / 26 上启动不卡死
- [ ] **`app_*.js` 4 个文件由 H5 zip 包内自带**:解压 `gamehall.zip` `app_data.js` / `app_battery.js` / `app_network.js` / `app_gamesname.js` 全部存在H5 团队打包责任,原生不写 - [ ] **`app_*.js` 4 个文件在沙盒**`{gamedir}/{gamestart}/` `app_data.js` / `app_battery.js` / `app_network.js` / `app_gamesname.js` 全部存在、loadFileURL 前已落盘(`xcrun simctl get_app_container booted <bundle>` + `cat` 验证
- [ ] **原生 `WKUserScript(.atDocumentEnd)` 覆盖生效**:在 Safari Web Inspector 读 `window.app_channel` 等 13 项启动期变量,必须是**实际渠道值**(来自 `ChannelConfig.plist`),**不是** H5 zip 包里 `app_data.js` 的默认占位值
- [ ] **`app_*` 12 + 1 + 1 + 1 = 15 个全局变量命名 100% 正确**(参 §4.2):H5 console 检查 `app_version` / `app_gameconfig` / `app_gamedir` / `app_gamestart` / `app_agent` / `app_appversion` / `app_market` / `app_channel` / `app_Launchtype` / `app_getwifisignalLevel` / `app_gamename` / `app_invitationcode` / `app_getbattery` / `app_getnetwork` / `app_gamesname` 全部 `!== undefined`;尤其大小写 `app_Launchtype`L 大写)/ `app_getwifisignalLevel`wifi 小写 + signal/Level 区分)/ `app_getbattery``app_getnetwork`(带 get 前缀)必须精确 - [ ] **`app_*` 12 + 1 + 1 + 1 = 15 个全局变量命名 100% 正确**(参 §4.2):H5 console 检查 `app_version` / `app_gameconfig` / `app_gamedir` / `app_gamestart` / `app_agent` / `app_appversion` / `app_market` / `app_channel` / `app_Launchtype` / `app_getwifisignalLevel` / `app_gamename` / `app_invitationcode` / `app_getbattery` / `app_getnetwork` / `app_gamesname` 全部 `!== undefined`;尤其大小写 `app_Launchtype`L 大写)/ `app_getwifisignalLevel`wifi 小写 + signal/Level 区分)/ `app_getbattery``app_getnetwork`(带 get 前缀)必须精确
- [ ] **值是实际渠道值而非占位**H5 console 读 `app_channel` 应为 `ChannelConfig.plist` 的 channel 值(不是 H5 团队 zip 内可能预留的占位字符串)
- [ ] **大厅 vs 子游戏字面差异**:大厅 H5 内 `app_Launchtype === 0`(数字 0,非字符串 `"0"`)、子游戏 H5 内 `app_Launchtype === 1` - [ ] **大厅 vs 子游戏字面差异**:大厅 H5 内 `app_Launchtype === 0`(数字 0,非字符串 `"0"`)、子游戏 H5 内 `app_Launchtype === 1`
- [ ] **不要存在已撤销的变量**H5 console 检查 `app_gameid` / `app_compareCode` 应为 `undefined`(早期 Contract §4.2 误列,原项目代码字面字符串里不写) - [ ] **不要存在已撤销的变量**H5 console 检查 `app_gameid` / `app_compareCode` 应为 `undefined`(早期 Contract §4.2 误列,原项目代码字面字符串里不写)
- [ ] **阶段 B 动态覆盖**:模拟器 Features → Battery State 切换电量;Web Inspector 读 `window.app_getbattery`实时跟随;同样切换 Network Link Conditioner 验证 `window.app_getnetwork` - [ ] **battery / network 变化重写**:模拟器 Features → Battery State 切换电量;reload 后 H5 读 `app_getbattery` 应跟随;同样切换 Network Link Conditioner 验证 `app_getnetwork`(业务期间实时变化由 §3.2 [2][3]反向 callback 推送,不依赖文件重写)
### B. 桥接 ### B. 桥接
- [ ] H5 调 `accreditlogin` → 微信弹授权页 → 同意后 H5 收到 `sharelogin` 回调,7 字段完整 - [ ] H5 调 `accreditlogin` → 微信弹授权页 → 同意后 H5 收到 `sharelogin` 回调,7 字段完整
+100 -253
View File
@@ -1968,19 +1968,23 @@ for channel in xianliao 360 qq tencent; do
done done
``` ```
### 7.5 修改 H5 端 `app_*.js` 全局变量值(evaluateJavaScript 路径 ### 7.5 H5 `app_*.js` 预注入文件机制(**最终路径:原生 `writeToFile` 写文件**
**用途**H5 业务代码通过 `<script src="app_data.js">` / `<script src="app_battery.js">` / `<script src="app_network.js">` / `<script src="app_gamesname.js">` 等同步 `<script>` 引入 4 个 `.js` 文件。**这 4 个文件由 H5 团队随 `gamehall.zip` 一起打包提供**,文件内是 `var app_xxx = "<默认占位>"` 形式的变量声明 — 原生**不需要生成这些文件**,只需要在 H5 加载完成后用 `WKUserScript(.atDocumentEnd)` + `evaluateJavaScript` 把 15 个 `window.app_xxx` 全局变量的值改为实际的渠道 / 设备 / 启动信息 **用途**H5 业务代码通过 `<script src="app_data.js">` / `<script src="app_battery.js">` / `<script src="app_network.js">` / `<script src="app_gamesname.js">` 等同步 `<script>` 引入 4 个 `.js` 文件。**原生在 `loadFileURL` 之前用 `String.write(to:)` 把这 4 个文件写到 `{gamedir}/{gamestart}/`**,文件内是 `var app_xxx = "<实际渠道/启动/设备值>"` 形式的变量声明H5 启动时 `<script src>` 同步引入直接读到正确值。**这是 H5 在 iOS 9+ 路径下读渠道 / 设备 / 启动信息的唯一真实路径**(§3.4.1 旧桥同步 getter 已撤销)
**这是 H5 在 iOS 9+ 路径下读渠道 / 设备 / 启动信息的唯一真实路径**(§3.4.1 旧桥同步 getter 已撤销)。 > ⚠️ **2026-06-22 路径决策最终落定(中间走过两次弯路,复盘见 CLAUDE.md 典型案例 3**
> ⚠️ **2026-06-22 重大方向调整**:本节早期版本(commit `f68b0db` 起)设计的是"原生用 `String.write(to:)` 把 4 个 `.js` 文件写到沙盒、H5 通过 `<script src>` 引入"路径,与原 msext `NewRootVC.initJSdata` 写法一致。**实际新外壳约定改为**:H5 端预先把 `app_*.js` 打包在 zip 包里(带默认占位值),原生只负责修改 `window.app_xxx` 全局变量的值,**不再写文件**。理由:
> >
> 1. **H5 静态资源由 H5 团队管理**,加上版本号 / 缓存策略后比"原生每次启动都重写"更稳;写文件路径在弱 IO 设备上偶发 `<script src>` 文件写完前就 fetch 到旧内容 > 1. **早期 commit `f68b0db`**:设计为"原生 `writeToFile` 写 4 个 `.js` 文件"路径(与原 msext `NewRootVC.initJSdata` 一致)→ **正确方向**
> 2. **覆盖全局变量更简单**:原生只关心 15 个 `window.app_xxx = "value"`,不关心文件结构 / 字符串包裹符 / 转义规则(H5 的默认占位文件自己处理 var/string/number 形式) > 2. **中间 commit `246f215`**:误以为"H5 自带 `app_*.js`,原生用 `WKUserScript(.atDocumentEnd)` + `evaluateJavaScript` 覆盖 `window.app_xxx`"是更现代化的做法 → **错误调整**,撤销
> 3. **与 §3.2 反向 callback 的事件驱动模型一致**`getBattery / getnetwork / appservice` 已经在用 `bridge.call(name, data)` 推 H5;现在 `app_*` 也走 `evaluateJavaScript` 路径,两条本质都是"运行时 push 给 H5" > 3. **本次回退**:再次回到"原生 `writeToFile` 写文件"路径,与原 msext 行为 1:1 等价
> >
> 旧"写文件"骨架保留在 §7.5.3.legacy 作为对照参考,**不要实施**。 > **为什么 `evaluateJavaScript` 路径不可行**CLAUDE.md 典型案例 3 详记):
> - `WKUserScript(.atDocumentStart)` 注入会被 H5 自带 `app_*.js` 内的 `var app_xxx = "默认值"` 声明覆盖回退
> - `WKUserScript(.atDocumentEnd)` 又太晚(H5 业务顶层 `<script>` 在 documentEnd 之前已跑过 `var x = app_xxx`,读到的是默认占位)
> - WKUserScript 没有"在 `<script src="app_data.js">` 之后、业务 `<script>` 之前"的精确时机
> - 任何"让 H5 改一行 / 改一个文件"的妥协方案都违反 CLAUDE.md **原则 A 第一准则**(H5 端零修改不可妥协),立即否决
>
> **正确解法**:回到 msext 老路 — 原生在 `loadFileURL` **之前**写 `app_*.js` 文件,H5 启动时 `<script src>` 同步引入即可读到实际值,时序 100% 等价 msext。哪怕这是"现代 WKWebView 不推荐"的写法,契约优先于现代化(CLAUDE.md 原则 A.2)。
#### 7.5.1 15 个 `app_*` 变量完整映射(按 daoqi/msext `NewRootVC.initJSdata`、`gameController.initJSdata`、`AppDelegate.applicationDidFinishLaunching` 审查得出) #### 7.5.1 15 个 `app_*` 变量完整映射(按 daoqi/msext `NewRootVC.initJSdata`、`gameController.initJSdata`、`AppDelegate.applicationDidFinishLaunching` 审查得出)
@@ -2002,175 +2006,30 @@ done
| 14 | `app_getbattery` | `String(format: "%.2f", UIDevice.batteryLevel)` | 同 | `UIDevice.current.batteryLevel`,每次 viewWillAppear + battery 变化通知 | `app_battery.js` | | 14 | `app_getbattery` | `String(format: "%.2f", UIDevice.batteryLevel)` | 同 | `UIDevice.current.batteryLevel`,每次 viewWillAppear + battery 变化通知 | `app_battery.js` |
| 15 | `app_getnetwork` | `"1"`/`"2"`/`"3"` | 同 | `NWPathMonitor` 状态映射(1 无网 / 2 WiFi / 3 蜂窝),每次 viewWillAppear + network 变化通知 | `app_network.js` | | 15 | `app_getnetwork` | `"1"`/`"2"`/`"3"` | 同 | `NWPathMonitor` 状态映射(1 无网 / 2 WiFi / 3 蜂窝),每次 viewWillAppear + network 变化通知 | `app_network.js` |
#### 7.5.2 入时机与机制 #### 7.5.2 4 个文件的写入时机与内容
**两阶段注入** ```
{gamedir}/{gamestart}/app_data.js 一次性,loadFileURL 前(含 #1-#12 共 12 个 var
| 阶段 | 时机 | 机制 | 覆盖的变量 | {gamedir}/{gamestart}/app_gamesname.js 一次性 + 新装子游戏后增量重写(#13)
|------|------|------|---------| {gamedir}/{gamestart}/app_battery.js loadFileURL 前一次 + viewWillAppear + 前台 + batteryLevelDidChange#14
| **A. 首次覆盖** | `WKWebViewConfiguration` 装配阶段,`loadFileURL` 之前 | `WKUserScript(injectionTime: .atDocumentEnd, forMainFrameOnly: true)` | #1-#13 共 13 项(启动期已知的渠道 + 启动信息 + 已安装子游戏) | {gamedir}/{gamestart}/app_network.js loadFileURL 前一次 + viewWillAppear + NWPathMonitor 变化(#15
| **B. 动态覆盖** | battery / network 变化时 / 进入 viewWillAppear | `webView.evaluateJavaScript("window.app_xxx = ...")` | #14 / #15 各自变化时单条 |
**为什么必须用 `.atDocumentEnd` 而不是 `.atDocumentStart`**H5 的 `<script src="app_data.js">``documentStart` 之后被 fetch 执行,文件内的 `var app_xxx = "<default>"` 声明会**覆盖** documentStart 时我们注入的值。只有等 H5 自带的 `app_*.js` 都执行完(即 `documentEnd`)后再注入 `window.app_xxx = "实际值"` 才能稳定生效。
> JS 内 `var x = ...` 在执行时只对当前作用域里**已经声明**的 `var` 做赋值;用 `window.x = ...` 直接修改全局对象的属性,与 `var` 声明语义等价但不会再被后续 `var` 重新声明 — 这是覆盖型注入的关键。
**为什么不直接让 H5 用 `bridge.callHandler('getChannelConfig')` 拉**:原 H5 业务代码是 `var ch = app_channel`(同步表达式),用 callHandler 会变 async**违反契约原则 A**(H5 端零修改)。`evaluateJavaScript` 注入 `window.app_xxx` 后 H5 同步表达式不变 ⇒ 契约不破。
#### 7.5.3 Swift 骨架(`AppDataInjector`evaluateJavaScript 路径)
设计带可观察性:每次注入都在 Debug 模式下把"注入了哪些 key=value + 用哪个机制(UserScript / evaluateJavaScript"打到控制台,便于联调时一眼对账 H5 端读到的值。Release 自动去 `.debug` 级别避免性能开销 + 防泄漏。
```swift
// Source/WebView/AppDataInjector.swift
import WebKit
import os.log
@MainActor
public struct AppDataInjector {
let bundleConfig: BundleConfig
let resolvedVersion: ResolvedVersion? // fallback nil
let containerRole: ContainerRole
public enum ContainerRole: CustomStringConvertible {
case lobby
case subGame(name: String, dir: String)
public var description: String {
switch self {
case .lobby: return "lobby"
case .subGame(let n, _): return "subGame(\(n))"
}
}
}
private static let log = Logger(subsystem: "ylgamehall", category: "AppDataInjector")
// MARK: - AdocumentEnd UserScript
//
// WKWebViewConfiguration addUserScriptH5 app_*.js
// window.app_xxx var 退
public func makeInitialUserScript() -> WKUserScript {
let source = makeInitialJSSource()
return WKUserScript(source: source,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true)
}
// MARK: - BevaluateJavaScript
//
// battery / network window.app_getbattery / app_getnetwork
public func injectBattery(_ level: Float, into webView: WKWebView) async {
let value = String(format: "%.2f", level)
let js = "window.app_getbattery=\(value);"
await Self.evaluate(js, on: webView)
Self.log.debug("[\(containerRole.description, privacy: .public)] inject battery: app_getbattery=\(value, privacy: .public)")
}
public func injectNetwork(_ code: Int, into webView: WKWebView) async {
let js = "window.app_getnetwork=\(code);"
await Self.evaluate(js, on: webView)
Self.log.debug("[\(containerRole.description, privacy: .public)] inject network: app_getnetwork=\(code, privacy: .public)")
}
// MARK: - JS
private func makeInitialJSSource() -> String {
let bc = bundleConfig
let launchtype: Int
let gameName: String
let gameDir: String
let gameStart: String
switch containerRole {
case .lobby:
launchtype = 0
gameName = bc.gameStart
gameDir = bc.gameDir
gameStart = bc.gameStart
case .subGame(let name, let dir):
launchtype = 1
gameName = name
gameDir = dir
gameStart = name
}
let appVersion = resolvedVersion?.appVersion.description ?? bc.appVersion
let installed = SandboxPaths.installedSubGameNames()
let gamesnameJSArray = "new Array(\(installed.map { "'\(escape($0))'" }.joined(separator: ",")))"
// H5 app_*.js
// - version / Launchtype / getwifisignalLevel / appversion / getbattery / getnetwork
// - msext
// window.app_xxx = ... var var 退
let source = """
;(function(){
window.app_version=1;
window.app_gameconfig='\(escape(bc.gameConfig))';
window.app_gamedir='\(escape(gameDir))';
window.app_gamestart='\(escape(gameStart))';
window.app_agent='\(escape(bc.agent))';
window.app_appversion='\(escape(appVersion))';
window.app_market='\(escape(bc.market))';
window.app_channel='\(escape(bc.channel))';
window.app_Launchtype=\(launchtype);
window.app_getwifisignalLevel=1;
window.app_gamename='\(escape(gameName))';
window.app_invitationcode='\(escape(bc.other))';
window.app_gamesname=\(gamesnameJSArray);
})();
"""
// key=value H5 console.log(app_xxx)
Self.log.debug("""
[\(containerRole.description, privacy: .public)] makeInitialUserScript (\(installed.count, privacy: .public) games installed):
window.app_version = 1
window.app_gameconfig = '\(bc.gameConfig, privacy: .public)'
window.app_gamedir = '\(gameDir, privacy: .public)'
window.app_gamestart = '\(gameStart, privacy: .public)'
window.app_agent = '\(bc.agent, privacy: .public)'
window.app_appversion = '\(appVersion, privacy: .public)'
window.app_market = '\(bc.market, privacy: .public)'
window.app_channel = '\(bc.channel, privacy: .public)'
window.app_Launchtype = \(launchtype, privacy: .public)
window.app_getwifisignalLevel = 1
window.app_gamename = '\(gameName, privacy: .public)'
window.app_invitationcode = '\(bc.other, privacy: .public)'
window.app_gamesname = \(installed, privacy: .public)
""")
return source
}
private static func evaluate(_ js: String, on webView: WKWebView) async {
do {
_ = try await webView.evaluateJavaScript(js)
} catch {
log.warning("evaluateJavaScript failed: \(error.localizedDescription, privacy: .public) for js: \(js, privacy: .public)")
}
}
/// JS string / / H5 app_*.js
private func escape(_ s: String) -> String {
s.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
}
}
``` ```
#### 7.5.3.legacy 早期 `AppDataWriter`(写文件路径,**不要实施**) **为什么是写文件**(保留与 msext 等价的时序):H5 业务页面用 `<script src="app_data.js">` **同步**引入,必须在 `loadFileURL` 之前文件已存在于沙盒;H5 启动时 `<script>` 直接读到正确值,H5 业务代码顶层 `var x = app_xxx` 也能直接拿到实际值。
> 早期 §7.5.3 设计是"原生用 `String.write(to:)` 把 4 个 `.js` 文件写到沙盒、H5 通过 `<script src>` 引入"路径(与原 msext 一致)。**已撤销**,理由见 §7.5 顶部「重大方向调整」说明。 > **不要照搬 `WKUserScript` 注入路径**(曾在 commit `246f215` 尝试过,已撤回):
> > - `.atDocumentStart` 注入会被 H5 自带 `app_*.js` 内的 `var app_xxx = "默认值"` 声明覆盖
> 完整骨架代码在 git history 里:`git show 9f4ccb7 -- docs/H5-Native-Implementation-Design.md` 可查到 `AppDataWriter` struct。Phase 2 实施按 §7.5.3 `AppDataInjector` 落地,**不要照搬** AppDataWriter。 > - `.atDocumentEnd` 又太晚,H5 业务顶层 `<script>` 在 documentEnd 之前已读过 `app_xxx`(默认值)
> - 没有"在 `<script src>` 之后、业务 `<script>` 之前"的精确时机
> - 任何要求 H5 配合改动的方案都违反 CLAUDE.md 原则 A 第一准则
> - 详细分析见 CLAUDE.md「典型案例:`app_*` 注入时序问题」
#### 7.5.3 Swift 骨架(`AppDataWriter`,写文件路径)
设计带可观察性:每次写文件都在 Debug 模式下把"写了哪个文件 + 内容预览 + 路径"打到控制台,便于联调时一眼对账 H5 端读到的值。Release 自动去 `.debug` 级别避免性能开销 + 防泄漏。
```swift ```swift
// Source/WebView/AppDataWriter.swift // Source/WebView/AppDataWriter.swift
import os.log import os.log
@MainActor @MainActor
@@ -2318,17 +2177,16 @@ public struct AppDataWriter {
private func runBootPipelineSteps() async throws { private func runBootPipelineSteps() async throws {
// ... ensureReady / fetch / resolve / upgrade ... // ... ensureReady / fetch / resolve / upgrade ...
let injector = AppDataInjector( let writer = AppDataWriter(
bundleConfig: .shared, bundleConfig: .shared,
resolvedVersion: resolved, resolvedVersion: resolved,
containerRole: .lobby containerRole: .lobby
) )
// A loadFileURL UserScript contentController // 1. loadFileURL 4 app_*.js H5 zip
// H5 app_*.js var documentEnd try writer.writeInitial() // app_data.js + app_gamesname.js
// #1-#13 13 try writer.writeBattery(UIDevice.current.batteryLevel) // app_battery.js
let controller = bridgedWebView.webView.configuration.userContentController try writer.writeNetwork(networkMonitor.currentCode) // app_network.js
controller.addUserScript(injector.makeInitialUserScript())
splash.update(text: "加载大厅...", progress: nil) splash.update(text: "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL( bridgedWebView.webView.loadFileURL(
@@ -2336,35 +2194,18 @@ private func runBootPipelineSteps() async throws {
allowingReadAccessTo: SandboxPaths.lobbyRoot allowingReadAccessTo: SandboxPaths.lobbyRoot
) )
// BH5 battery / network // 2. battery / network .js
// evaluateJavaScript window.app_getbattery / app_getnetwork // H5 reload
// §3.2 23 callback
UIDevice.current.isBatteryMonitoringEnabled = true UIDevice.current.isBatteryMonitoringEnabled = true
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
forName: UIDevice.batteryLevelDidChangeNotification, forName: UIDevice.batteryLevelDidChangeNotification,
object: nil, queue: .main object: nil, queue: .main
) { [weak self] _ in ) { _ in
guard let self else { return } try? writer.writeBattery(UIDevice.current.batteryLevel)
Task { @MainActor in
await injector.injectBattery(UIDevice.current.batteryLevel,
into: self.bridgedWebView.webView)
}
}
networkMonitor.stateDidChange = { [weak self] code in
guard let self else { return }
Task { @MainActor in
await injector.injectNetwork(code, into: self.bridgedWebView.webView)
}
}
// didFinish H5 battery / network
bridgedWebView.didFinishOnce = { [weak self] in
guard let self else { return }
Task { @MainActor in
await injector.injectBattery(UIDevice.current.batteryLevel,
into: self.bridgedWebView.webView)
await injector.injectNetwork(self.networkMonitor.currentCode,
into: self.bridgedWebView.webView)
} }
networkMonitor.stateDidChange = { code in
try? writer.writeNetwork(code)
} }
} }
``` ```
@@ -2377,93 +2218,99 @@ private func runBootPipelineSteps() async throws {
#### 7.5.5 大厅 / 子游戏 / 弹层差异 #### 7.5.5 大厅 / 子游戏 / 弹层差异
| 容器 | 阶段 A UserScript 覆盖(13 项) | 阶段 B evaluateJavaScript 动态(2 项) | 备注 | | 容器 | `app_data.js` 写入 | `app_gamesname.js` | `app_battery.js` | `app_network.js` | 备注 |
|------|---|---|---| |------|---|---|---|---|---|
| 大厅(WebContainer.lobby | `app_Launchtype=0` + `gamedir = BundleConfig.gameDir` + `gamename = gameStart` + 已安装子游戏列表 | `app_getbattery` / `app_getnetwork` 实时变化 | `app_appversion` 用远端 ResolvedVersion | | 大厅(WebContainer.lobby | `app_Launchtype=0` + `gamedir = BundleConfig.gameDir` + `gamename = gameStart` | 写当前已安装子游戏列表 | ✓ | ✓ | `app_appversion` 用远端 ResolvedVersion |
| 子游戏(WebContainer.subGame | `app_Launchtype=1` + `gamedir = 子游戏目录` + `gamename = 子游戏名` +份子游戏列表 | 同上 | 子游戏专属 `result_state` 字段,新外壳暂用同 appVersion | | 子游戏(WebContainer.subGame | `app_Launchtype=1` + `gamedir = 子游戏目录` + `gamename = 子游戏名` |样写(与大厅同份内容) | ✓ | ✓ | 子游戏专属 `result_state` 字段,新外壳暂用同 appVersion |
| 弹层(OverlayViewController / threeView | ✗ 不注入 | ✗ | 弹层 H5 走 `window.settings.{backgameData/browser/finishweb}`(§3.4),不读 `app_*` 全局变量 | | 弹层(OverlayViewController / threeView | ✗ 不写 | ✗ | ✗ | ✗ | 弹层 H5 走 `window.settings.{backgameData/browser/finishweb}`(§3.4),不读 `app_*` 全局变量 |
#### 7.5.6 与原 msext 的差异 #### 7.5.6 与原 msext 的差异
| 维度 | msext 现状 | 新外壳决策 | | 维度 | msext 现状 | 新外壳决策 |
|------|---|---| |------|---|---|
| 入主体 | `NewRootVC.initJSdata` / `gameController.initJSdata` 散落在 VC 内**写文件** | 抽象为 `AppDataInjector` struct,三个容器复用同一份代码**evaluateJavaScript** | | 入主体 | `NewRootVC.initJSdata` / `gameController.initJSdata` 散落在 VC 内 | 抽象为 `AppDataWriter` struct,三个容器复用同一份代码 |
| 入机制 | `[NSString writeToFile:atomically:YES encoding:UTF8 error:nil]``app_*.js` 到沙盒H5 通过 `<script src>` 同步引入 | H5 端 `app_*.js` 由 H5 团队自带(默认占位值),原生 `WKUserScript(.atDocumentEnd)` + `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量 | | 入机制 | `[NSString writeToFile:atomically:YES encoding:UTF8 error:nil]``app_*.js` 到沙盒 | `String.write(to:atomically:encoding:)`(等价) |
| 数据源 | `self.gameconfig` 等实例属性,跨多处赋值 | 统一从 `BundleConfig.shared` + `ResolvedVersion` 拉,单一真相 | | 数据源 | `self.gameconfig` 等实例属性,跨多处赋值 | 统一从 `BundleConfig.shared` + `ResolvedVersion` 拉,单一真相 |
| battery / network 时机 | `viewWillAppear` + 前台通知双重保险 + 重写文件 | `addObserver` 注册一次 + `evaluateJavaScript` 单条覆盖 + 首次 didFinish 补一次 | | battery / network 时机 | `viewWillAppear` + 前台通知双重保险 | `addObserver` 注册一次 + `loadFileURL` 前补一次首次值(避免双重写入) |
| 字符串包裹符 | `var x='%@'` 单引号,沿用 msext 字面 | `window.x='...'` 单引号一致 | | 字符串包裹符 | `var x='%@'` 单引号 | `var x='...'` 单引号一致(与 msext 字面对齐) |
| 转义处理 | 直接 `stringWithFormat:@"var x='%@'"` 不转义 | 完整转义 4 种特殊字符(避免渠道字段含单引号导致 H5 JS 解析错) | | 转义处理 | 直接 `stringWithFormat:@"var x='%@'"` 不转义 | 完整转义 4 种特殊字符(避免渠道字段含单引号导致 H5 JS 解析错) |
| 大小写 | `app_Launchtype` / `app_getwifisignalLevel` 严格保持 | 同款,注释提示 | | 大小写 | `app_Launchtype` / `app_getwifisignalLevel` 严格保持 | 同款,注释提示 |
| 文件位置 | `{gamedir}/{gamestart}/app_*.js`(沙盒 Caches)| 同款(统一走 `SandboxPaths.lobbyAssets` / `subGameAssets`|
#### 7.5.7 与 §3.4.1 撤销 polyfill 的关系 #### 7.5.7 与 §3.4.1 撤销 polyfill 的关系
| 路径 | 状态 | 用途 | | 路径 | 状态 | 用途 |
|---|---|---| |---|---|---|
| §7.5 `app_*.js` evaluateJavaScript 修改全局变量 | ✓ **主路径** | iOS 9+ 全部 H5 业务读 `app_xxx` 全局变量;H5 自带 `app_*.js` 默认占位,原生覆盖为实际值 | | §7.5 `app_*.js` 文件预写 | ✓ **主路径** | iOS 9+ 全部 H5 业务读 `app_xxx` 全局变量;原生 `writeToFile``loadFileURL` 前写入沙盒,H5 `<script src>` 同步引入即可(与 msext 1:1 等价)|
| §3.4.1 polyfill | ✗ **已撤销** | 仅 iOS<9 旧桥 `window.settings.getXxx()`,本项目最低 iOS 15.6 不需要 | | §3.4.1 polyfill | ✗ **已撤销** | 仅 iOS<9 旧桥 `window.settings.getXxx()`,本项目最低 iOS 15.6 不需要 |
| §3.4 OverlayBridge polyfill | ✓ **保留** | 弹层 `window.settings.{backgameData/browser/finishweb}`,与系统版本无关 | | §3.4 OverlayBridge polyfill | ✓ **保留** | 弹层 `window.settings.{backgameData/browser/finishweb}`,与系统版本无关 |
| §3.2 事件 callback `getBattery` / `getnetwork` / `appservice` | ✓ **保留** | 业务期 H5 主动收到的变化推送,与 §7.5 `evaluateJavaScript` 覆盖互为补充(推送 + 状态双轨) | | §3.2 事件 callback `getBattery` / `getnetwork` / `appservice` | ✓ **保留** | 业务期 H5 主动收到的变化推送,与 §7.5 文件重写互为补充 |
| msext 原写文件路径 | ✗ **不实施** | §7.5.3.legacy 保留作对照参考 | | `WKUserScript` + `evaluateJavaScript` 覆盖 `window.app_xxx` | ✗ **探索后撤回** | 时序无法 1:1 等价 msext,详细分析见 §7.5 头部 + CLAUDE.md「典型案例 3」|
#### 7.5.8 联调时的可观察性(Phase 1.17 调试手段) #### 7.5.8 联调时的可观察性(Phase 1.17 调试手段)
H5 端读到错误值时,第一步是确认"原生注入了什么、H5 端最终读到什么"。**核心对账**:原生注入的 JS 字符串与 H5 端 `window.app_xxx` 的实时值。 H5 端读到错误值时,第一步是确认"原生了什么、H5 读到什么"。四条对账路径:
**1. Xcode 控制台 — 原生入 log(§7.5.3 已埋点)** **1. Xcode 控制台 — 原生入 log(§7.5.3 已埋点)**
启动后 Xcode console 应出现: 启动后 Xcode console 应出现:
``` ```
[lobby] makeInitialUserScript (0 games installed): [lobby] write app_data.js → /Users/.../Caches/<gamedir>/<gamestart>/app_data.js
window.app_version = 1 app_version = 1
window.app_gameconfig = 'tsgames.daoqi88.cn-config_test-update_jsonv2_test' app_gameconfig = 'tsgames.daoqi88.cn-config_test-update_jsonv2_test'
window.app_gamedir = 'FtJf07...' app_gamedir = 'FtJf07...'
window.app_gamestart = 'gamehall' app_gamestart = 'gamehall'
window.app_agent = 'veRa0qrBf0df...' app_agent = 'veRa0qrBf0df...'
window.app_appversion = '43' app_appversion = '43'
window.app_market = '2' app_market = '2'
window.app_channel = 'FtJf07...' app_channel = 'FtJf07...'
window.app_Launchtype = 0 app_Launchtype = 0
window.app_getwifisignalLevel = 1 app_getwifisignalLevel = 1
window.app_gamename = 'gamehall' app_gamename = 'gamehall'
window.app_invitationcode = '' app_invitationcode = ''
window.app_gamesname = [] [lobby] write app_gamesname.js: 0 games → ...
[lobby] inject battery: app_getbattery=0.85 [lobby] write app_battery.js: app_getbattery=0.85 → ...
[lobby] inject network: app_getnetwork=2 [lobby] write app_network.js: app_getnetwork=2 → ...
``` ```
若没出现 → AppDataInjector 没接入 / log subsystem 过滤掉了,先按 §7.5.4 接入点检查。 若没出现 → AppDataWriter 没接入 / log subsystem 过滤掉了,先按 §7.5.4 接入点检查。
**2. H5 console 对账 — Safari Web Inspector(核心手段** **2. 沙盒文件直接读 — `cat`(确认文件落盘正确**
模拟器跑时 macOS Safari → 开发菜单 → Simulator → 当前 H5 页面 → Web Inspector → Console,逐项 console.log 读 `window.app_xxx` ```bash
xcrun simctl get_app_container booted com.skyapp.ylgamehall data
# 拿到容器路径后
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_data.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_battery.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_network.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_gamesname.js
```
文件应包含 `var app_xxx=...;` 字面字符串。若文件不存在 / 为空 → AppDataWriter 没跑到 / 路径错;若文件正确但 H5 读 undefined → H5 端 `<script src>` 路径不对或加载时序问题。
**3. H5 console 对账 — Safari Web Inspector**
模拟器跑时 macOS Safari → 开发菜单 → Simulator → 当前 H5 页面 → Web Inspector → Console
```js ```js
// 阶段 A13 项启动期覆盖 console.log("app_version =", app_version); // 期望 1
console.log("app_version =", window.app_version); // 期望 1 console.log("app_channel =", app_channel); // 期望 ChannelConfig.plist channel 值
console.log("app_channel =", window.app_channel); // 期望 ChannelConfig.plist channel 值 console.log("app_Launchtype =", app_Launchtype); // 大厅期望 0,⚠️ L 大写
console.log("app_Launchtype =", window.app_Launchtype); // 大厅期望 0,⚠️ L 大 console.log("app_getwifisignalLevel =", app_getwifisignalLevel); // ⚠️ wifi 小
console.log("app_getwifisignalLevel =", window.app_getwifisignalLevel); // ⚠️ wifi 小写 console.log("app_getbattery =", app_getbattery); // ⚠️ 带 get 前缀
console.log("app_invitationcode =", window.app_invitationcode); console.log("app_getnetwork =", app_getnetwork);
console.log("app_gamesname =", window.app_gamesname); // 期望 Array console.log("app_gamesname =", app_gamesname); // 期望 Array
// 阶段 B2 项动态覆盖
console.log("app_getbattery =", window.app_getbattery); // ⚠️ 带 get 前缀
console.log("app_getnetwork =", window.app_getnetwork);
``` ```
| 现象 | 可能原因 | | 现象 | 可能原因 |
|---|---| |---|---|
| 全部为 H5 默认占位(不是实际渠道值)| AppDataInjector.makeInitialUserScript 没 addUserScript / injectionTime 错(不是 .atDocumentEnd/ H5 自带 `app_*.js` 的 var 声明在我们之后又跑了一次 | | 全部为 H5 zip 包内默认占位(不是实际渠道值)| AppDataWriter 没在 loadFileURL 前调到 / 写入路径与 H5 `<script src>` 引入路径不一致 / writeAppData 抛错被吞 |
| 某项为 `undefined` | 拼写错误(`app_launchtype` 小写 l / `app_battery` 少 get / `app_getNetwork` 驼峰错) | | 某项为 `undefined` | 拼写错误(`app_launchtype` 小写 l / `app_battery` 少 get / `app_getNetwork` 驼峰错) |
| `app_getbattery` / `app_getnetwork` 是默认值不变 | 阶段 B 钩子没接入 / 模拟器 batteryLevel = -1(模拟器无电池硬件)/ NWPathMonitor 未启动 | | `app_getbattery` = 默认值不变 | BatteryMonitor 没启动 / 模拟器 batteryLevel = -1(模拟器无电池硬件) |
| `app_gamesname` 是空数组但实际已装子游戏 | `SandboxPaths.installedSubGameNames()` 实现错 / 子游戏目录路径不对 | | `app_gamesname` 是空数组但实际已装子游戏 | `SandboxPaths.installedSubGameNames()` 实现错 / 子游戏目录路径不对 |
**3. 真机调试**iPhone 上 Safari → 设置 → 高级 → 网页检查器开启,然后 macOS Safari 同样路径。 **4. 真机调试**iPhone 上 Safari → 设置 → 高级 → 网页检查器开启,然后 macOS Safari 同样路径。
**4. 阶段 B 动态覆盖手动触发验证**
- battery:模拟器 Features → Battery State → 切换 Charging / Unplugged / 调节电量百分比
- network:模拟器 Features → Network Link Conditioner → 切换 100% Loss / Wi-Fi / 3G
切换后 Xcode console 应出现 `inject battery: ...` / `inject network: ...` 日志,同时 H5 端 console.log 再读应反映新值。 切换后 Xcode console 应出现 `inject battery: ...` / `inject network: ...` 日志,同时 H5 端 console.log 再读应反映新值。