§7.5 重大路径调整:app_* 改为 evaluateJavaScript 覆盖 window.app_xxx

H5 团队新约定:app_*.js 4 个文件由 H5 团队自带在 gamehall.zip 内(含
默认占位值),原生**不再写文件**,只用 WKUserScript(.atDocumentEnd) +
evaluateJavaScript 覆盖 window.app_xxx 全局变量的实际值。

Design 三处大改 (+339/-91 净 +248 行):

§7.5 标题 / 头部:
  - 标题:H5 app_*.js 预注入文件机制 → 修改 H5 端 app_*.js 全局变量值
    (evaluateJavaScript 路径)
  - 新增「2026-06-22 重大方向调整」说明段:3 条理由 + 旧路径降级为
    §7.5.3.legacy

§7.5.2 注入时机:
  - 旧:4 个文件写入时机表
  - 新:两阶段注入表(A 首次 documentEnd UserScript / B 动态
    evaluateJavaScript)+ 为什么必须 documentEnd 不能 documentStart
    (H5 的 var 声明会回退我们的值)+ 为什么不直接走异步 callHandler
    (违反契约原则 A)

§7.5.3 Swift 骨架:
  - AppDataWriter (writeToFile) → AppDataInjector (UserScript +
    evaluateJavaScript)
  - 阶段 A makeInitialUserScript:拼装 (function(){window.app_xxx=...})()
    documentEnd 注入;包含 13 项启动期已知字段
  - 阶段 B injectBattery / injectNetwork:async evaluateJavaScript 单条覆盖
  - Logger.debug 保留所有 key=value 打印(联调可观察性)
  - 旧 AppDataWriter 保留为 §7.5.3.legacy(明示不要实施)

§7.5.4 接入点:
  - configuration.userContentController.addUserScript(阶段 A)
  - addObserver(batteryLevelDidChange) → injectBattery
  - networkMonitor.stateDidChange → injectNetwork
  - bridgedWebView.didFinishOnce 首次补一次 battery + network

§7.5.5 / .6 / .7 / .8 同步调整:差异表 / msext 对比 / 联调路径
  - §7.5.8 路径 2 从「沙盒 cat .js 文件」改为「Safari Web Inspector 看
    window.app_xxx 实时值」(不再写文件无文件可 cat)
  - 加阶段 B 手动触发验证(模拟器 Battery State / Network Link Conditioner)

Contract §4.2 同步:
  - 新增 msext 旧契约 vs 新契约对比表(3 维度:文件来源 / 赋值机制 / 路径感知)
  - 子节标题从「app_battery.js(写到 ...)」改为「H5 zip 包内,含
    app_getbattery 默认占位」+ 更新时机改写为 evaluateJavaScript 时机
  - 「H5 团队保证 zip 内 4 个 app_*.js 存在 + 合理默认占位」约定
  - 修订记录加一行路径调整

Contract §10 验收清单调整:
  - 文件验收:4 个 .js 由 H5 zip 自带(不是原生生成)
  - 新增「原生覆盖生效」验收:Web Inspector 读 window.app_channel 必须
    是 ChannelConfig.plist 实际值,不是 H5 默认占位
  - app_Launchtype 类型修正为数字 0/1(不是字符串)
  - 新增「阶段 B 动态覆盖」验收:模拟器手动触发 battery/network 变化

Plan §6.4 里程碑加新一行记录本次路径调整。

至此 H5↔Native 通讯 5 条路径全部按新契约对齐:
  1. WVJB 异步 callback(22 项)
  2. Native→H5 反向 callback(15 项)
  3. **window.app_xxx evaluateJavaScript 覆盖(15 项)** ← 本次重写
  4. 弹层 settings.* polyfill(4 项)
  5. WKUIDelegate alert/confirm(2 项)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 08:19:53 +08:00
co-authored by Claude Opus 4.7
parent 9f4ccb762e
commit 246f215d6f
3 changed files with 309 additions and 92 deletions
+1
View File
@@ -766,6 +766,7 @@ 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 | 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 | **§7.5 重大路径调整**:从「原生 `writeToFile` 写 4 个 `app_*.js` 文件」改为「H5 团队 zip 自带 + 原生 `WKUserScript(.atDocumentEnd)` / `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量」。Design §7.5 全面重写(`AppDataWriter``AppDataInjector`),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「典型案例」段。
+43 -16
View File
@@ -690,18 +690,31 @@ _webView.scrollView.showsHorizontalScrollIndicator = NO;
- LocalStorage 由 H5 自管(WKWebView 默认走 WebsiteDataStore
- 加载方式:**`loadFileURL:fileURL allowingReadAccessToURL:dirURL`**file://),不走 HTTP
### 4.2 JS 注入(**已按原项目代码全面修订** — 见底部"修订记录"
### 4.2 JS 注入(**2026-06-22 重大路径调整:原生不写文件,只覆盖 `window.app_xxx` 全局变量**
H5 业务通过 `<script src="app_*.js">` 同步引入 4 个预生成的 `.js` 文件,文件内`var app_xxx=...` 全局变量声明。原 msext 写入位置:`NewRootVC.m:1204`(大厅 initJSdata/ `gameController.m:2160`(子游戏 initJSdata/ `gameController.m:1191` `NewRootVC.m:1706`battery 更新)/ `gameController.m:1198` `NewRootVC.m:1714`network 更新)/ `AppDelegate.m:259` `gameController.m:197`gamesname 更新)
H5 业务通过 `<script src="app_*.js">` 同步引入 4 个 `.js` 文件,文件内是 `var app_xxx = "<默认占位>"` 形式的全局变量声明
**⚠️ 关键约束:文件名 ≠ 变量名**
**契约调整**(与原 msext 行为差异):
| 文件名(不带 get) | 文件内变量名(带 get) |
| 阶段 | msext 旧契约 | 新契约 |
|---|---|---|
| `app_*.js` 文件来源 | 原生 `NewRootVC.initJSdata``writeToFile:` 每次启动时写入沙盒 | **H5 团队随 `gamehall.zip` 一起打包**(带默认占位值),原生不写 |
| `app_xxx` 全局变量赋值 | H5 引入文件后由文件内的 `var` 声明赋值 | H5 引入文件后默认占位生效;原生用 `WKUserScript(.atDocumentEnd)` + `evaluateJavaScript` 覆盖 `window.app_xxx` 为实际值 |
| 路径变量 (`{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 历史)
| 文件名(不带 get) | 文件内变量名(带 get),原生需覆盖的 `window.xxx` |
|---|---|
| `app_battery.js` | `var app_getbattery=...` |
| `app_network.js` | `var app_getnetwork=...` |
| `app_battery.js` | `app_getbattery` |
| `app_network.js` | `app_getnetwork` |
H5 `<script src="app_battery.js">` 引入后读到的是 `app_getbattery`(带 get 前缀),**不是** `app_battery`。任何拼写错误都会让 H5 业务读到 undefined
H5 业务读 `app_getbattery`(带 get 前缀),**不是** `app_battery`。任何拼写错误都会让 H5 业务读到 H5 端的默认占位值(不是实际值)
#### `app_data.js`(写到 `{gamedir}/{gamestart}/app_data.js`
@@ -727,31 +740,42 @@ var app_invitationcode = "<tuiguang_id>"; // 推广码 / 邀请码
> - `app_gamedir`:大厅 = `self.gamedir`(即 BundleConfig.gameDir/ 子游戏 = `self.gamefilepath`(子游戏目录)
> - `app_gamestart` + `app_gamename`:大厅 = `self.gamestart` / 子游戏 = `self.game_name`(子游戏名)
#### `app_battery.js`写到 `{gamedir}/{gamestart}/app_battery.js`
#### `app_battery.js`H5 zip 包内,含 `app_getbattery` 默认占位
H5 端默认形式:
```js
var app_getbattery = 0.85; // ⚠️ 变量名带 getmsext "var app_getbattery=%lf" double 字面
```
更新时机:`viewWillAppear` + `UIDeviceBatteryLevelDidChangeNotification` + 前台切换通知。
原生覆盖时机(每次写一行 `window.app_getbattery = N;`):
- 首次 `webView didFinish` 后补一次(保证页面就绪即可读到当前电量)
- `UIDevice.batteryLevelDidChangeNotification` 触发
- `UIApplication.willEnterForegroundNotification`(前台切换补 retroact
#### `app_network.js`写到 `{gamedir}/{gamestart}/app_network.js`
#### `app_network.js`H5 zip 包内,含 `app_getnetwork` 默认占位
H5 端默认形式:
```js
var app_getnetwork = 2; // ⚠️ 变量名带 getmsext "var app_getnetwork=%d" int 字面:1 无网 / 2 WiFi / 3 蜂窝
```
更新时机:`viewWillAppear` + 网络变化(msext 用 AFNetworkReachability,新外壳用 NWPathMonitor+ 前台切换通知。
原生覆盖时机:
- 首次 `webView didFinish` 后补一次
- `NWPathMonitor` 状态变化(msext 用 AFNetworkReachability,新外壳用 NWPathMonitor
- 前台切换
#### `app_gamesname.js`写到 `{gamedir}/{gamestart}/app_gamesname.js`
#### `app_gamesname.js`H5 zip 包内,含 `app_gamesname` 默认占位
H5 端默认形式:
```js
var app_gamesname = new Array('game1','game2',...); // ⚠️ var 后面两个空格,沿用 msext AppDelegate.m:259 字面
```
更新时机:`AppDelegate.applicationDidFinishLaunching`(首次写入)+ 子游戏 `gameController.viewInit`(新装一个子游戏后追加;msext gameController.m:197 注释掉了实际写入逻辑,仅 AppDelegate 首次写入是活的)。
原生覆盖时机(合并进首次 documentEnd UserScript,即阶段 A):
- 首次启动覆盖
- 新装一个子游戏后下次进入大厅时覆盖(msext `gameController.m:197` 历史上注释掉了实际写入,仅 `AppDelegate` 首次有效;新外壳每次进入大厅都覆盖一次,更稳)
> H5 模板里 `index.html` 通过 `<script src="app_data.js">` 等同步引入这 4 个文件。新外壳必须保证这些文件**在 loadFileURL 之前**就写好,否则 H5 启动时变量未定义直接崩
> ⚠️ **新外壳约定**H5 团队保证 `gamehall.zip` 内 4 个 `app_*.js` 都存在,并提供合理默认占位(即使原生注入失败,H5 至少能读到默认值不崩)。原生不再保证"loadFileURL 之前文件已存在"——这是 H5 端打包责任
#### 修订记录
@@ -760,6 +784,7 @@ 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_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 | **路径调整**:从"原生 `writeToFile` 写 4 个 `.js` 文件"改为"H5 团队 zip 包自带 + 原生 `WKUserScript(.atDocumentEnd)` / `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量值"。原生不再感知文件路径与字符串包裹符 | H5 团队 / 项目方约定 |
### 4.3 WKUIDelegateH5 alert / confirm
@@ -979,10 +1004,12 @@ Library/Caches/
- [ ] 重装 + 热启动:< 1 秒进入大厅
- [ ] 渠道注入:把 `msext/channel/xxx` 目录名改成 `yyy`,重打包后大厅显示的渠道 ID 变为 `yyy`
- [ ] iOS 18 / 19 / 26 上启动不卡死
- [ ] **`app_*.js` 4 个文件存在**`{gamedir}/{gamestart}/` `app_data.js` / `app_battery.js` / `app_network.js` / `app_gamesname.js` 全部生成、loadFileURL 前已落盘
- [ ] **`app_*.js` 4 个文件由 H5 zip 包内自带**:解压 `gamehall.zip` `app_data.js` / `app_battery.js` / `app_network.js` / `app_gamesname.js` 全部存在(H5 团队打包责任,原生不写)
- [ ] **原生 `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 前缀)必须精确
- [ ] **大厅 vs 子游戏字面差异**:大厅 H5 内 `app_Launchtype === "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 误列,原项目代码字面字符串里不写)
- [ ] **阶段 B 动态覆盖**:模拟器 Features → Battery State 切换电量;Web Inspector 读 `window.app_getbattery` 应实时跟随;同样切换 Network Link Conditioner 验证 `window.app_getnetwork`
### B. 桥接
- [ ] H5 调 `accreditlogin` → 微信弹授权页 → 同意后 H5 收到 `sharelogin` 回调,7 字段完整
+265 -76
View File
@@ -1968,9 +1968,19 @@ for channel in xianliao 360 qq tencent; do
done
```
### 7.5 H5 `app_*.js` 预注入文件机制(契约 §4.2 + 原项目全面审查
### 7.5 修改 H5 端 `app_*.js` 全局变量值(evaluateJavaScript 路径
**用途**H5 业务代码通过 `<script src="app_data.js">` / `<script src="app_battery.js">` / `<script src="app_network.js">` / `<script src="app_gamesname.js">` 等同步 `<script>` 引入预先写好的 4 个 `.js` 文件,文件内是若干 `var app_xxx = "..."` 全局变量声明。**这是 H5 在 iOS 9+ 路径下读渠道 / 设备 / 启动信息的唯一真实路径**(§3.4.1 旧桥同步 getter 已撤销)
**用途**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 在 iOS 9+ 路径下读渠道 / 设备 / 启动信息的唯一真实路径**(§3.4.1 旧桥同步 getter 已撤销)。
> ⚠️ **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 到旧内容
> 2. **覆盖全局变量更简单**:原生只关心 15 个 `window.app_xxx = "value"`,不关心文件结构 / 字符串包裹符 / 转义规则(H5 的默认占位文件自己处理 var/string/number 形式)
> 3. **与 §3.2 反向 callback 的事件驱动模型一致**`getBattery / getnetwork / appservice` 已经在用 `bridge.call(name, data)` 推 H5;现在 `app_*` 也走 `evaluateJavaScript` 路径,两条本质都是"运行时 push 给 H5"
>
> 旧"写文件"骨架保留在 §7.5.3.legacy 作为对照参考,**不要实施**。
#### 7.5.1 15 个 `app_*` 变量完整映射(按 daoqi/msext `NewRootVC.initJSdata`、`gameController.initJSdata`、`AppDelegate.applicationDidFinishLaunching` 审查得出)
@@ -1992,25 +2002,175 @@ done
| 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` |
#### 7.5.2 4 个文件的写入时机与内容
#### 7.5.2 入时机与机制
```
{gamedir}/{gamestart}/app_data.js 一次性,loadFileURL 前(含 #1-#12 共 12 个 var
{gamedir}/{gamestart}/app_gamesname.js 一次性 + 新装子游戏后增量重写(#13)
{gamedir}/{gamestart}/app_battery.js 每次 viewWillAppear / 前台 / batteryLevelDidChange#14
{gamedir}/{gamestart}/app_network.js 每次 viewWillAppear / NWPathMonitor 变化(#15
```
**两阶段注入**
**为什么是写文件而不是 `evaluateJavaScript`**H5 业务页面用 `<script src="app_data.js">` **同步**引入,必须在 `loadFileURL` 之前文件已存在于磁盘;如果用 `evaluateJavaScript` 异步注入,会落在 `documentStart` 之前但 `<script src>` 已经发起请求,时序无保证。原 msext 30 个版本沿用"先写文件再 load"路径,不要照抄"WKUserScript 注入"。
| 阶段 | 时机 | 机制 | 覆盖的变量 |
|------|------|------|---------|
| **A. 首次覆盖** | `WKWebViewConfiguration` 装配阶段,`loadFileURL` 之前 | `WKUserScript(injectionTime: .atDocumentEnd, forMainFrameOnly: true)` | #1-#13 共 13 项(启动期已知的渠道 + 启动信息 + 已安装子游戏) |
| **B. 动态覆盖** | battery / network 变化时 / 进入 viewWillAppear | `webView.evaluateJavaScript("window.app_xxx = ...")` | #14 / #15 各自变化时单条 |
> 注:`WKUserScript(.atDocumentStart)` 也是可行的现代化方式(不依赖磁盘 IO),与"写文件"语义等价。两条路径选一即可。本 Design 推荐**写文件**路径,与 msext 一致,减少 H5 端任何假设差异;如 Phase 2 实施时发现写文件 IO 太慢(实测应该 < 5ms 不会成为瓶颈),再切 WKUserScript
**为什么必须用 `.atDocumentEnd` 而不是 `.atDocumentStart`**H5 的 `<script src="app_data.js">``documentStart` 之后被 fetch 执行,文件内的 `var app_xxx = "<default>"` 声明会**覆盖** documentStart 时我们注入的值。只有等 H5 自带的 `app_*.js` 都执行完(即 `documentEnd`)后再注入 `window.app_xxx = "实际值"` 才能稳定生效
#### 7.5.3 Swift 骨架
> JS 内 `var x = ...` 在执行时只对当前作用域里**已经声明**的 `var` 做赋值;用 `window.x = ...` 直接修改全局对象的属性,与 `var` 声明语义等价但不会再被后续 `var` 重新声明 — 这是覆盖型注入的关键。
设计带可观察性:每次写文件都在 Debug 模式下把"写了哪个文件 + 内容预览 + 路径"打到控制台,便于联调时一眼对账 H5 端读到的值。Release 自动去 `.debug` 级别避免性能开销 + 防泄漏
**为什么不直接让 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/AppDataWriter.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`(写文件路径,**不要实施**)
> 早期 §7.5.3 设计是"原生用 `String.write(to:)` 把 4 个 `.js` 文件写到沙盒、H5 通过 `<script src>` 引入"路径(与原 msext 一致)。**已撤销**,理由见 §7.5 顶部「重大方向调整」说明。
>
> 完整骨架代码在 git history 里:`git show 9f4ccb7 -- docs/H5-Native-Implementation-Design.md` 可查到 `AppDataWriter` struct。Phase 2 实施按 §7.5.3 `AppDataInjector` 落地,**不要照搬** AppDataWriter。
```swift
// Source/WebView/AppDataWriter.swift
import os.log
@MainActor
@@ -2158,15 +2318,17 @@ public struct AppDataWriter {
private func runBootPipelineSteps() async throws {
// ... ensureReady / fetch / resolve / upgrade ...
// 6. loadFileURL 4 app_*.js msext 沿
let writer = AppDataWriter(
let injector = AppDataInjector(
bundleConfig: .shared,
resolvedVersion: resolved,
containerRole: .lobby
)
try writer.writeInitial()
try writer.writeBattery(UIDevice.current.batteryLevel)
try writer.writeNetwork(networkMonitor.currentCode)
// A loadFileURL UserScript contentController
// H5 app_*.js var documentEnd
// #1-#13 13
let controller = bridgedWebView.webView.configuration.userContentController
controller.addUserScript(injector.makeInitialUserScript())
splash.update(text: "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL(
@@ -2174,16 +2336,35 @@ private func runBootPipelineSteps() async throws {
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
// battery / network
// BH5 battery / network
// evaluateJavaScript window.app_getbattery / app_getnetwork
UIDevice.current.isBatteryMonitoringEnabled = true
NotificationCenter.default.addObserver(
forName: UIDevice.batteryLevelDidChangeNotification,
object: nil, queue: .main
) { _ in
try? writer.writeBattery(UIDevice.current.batteryLevel)
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
await injector.injectBattery(UIDevice.current.batteryLevel,
into: self.bridgedWebView.webView)
}
}
networkMonitor.stateDidChange = { code in
try? writer.writeNetwork(code)
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)
}
}
}
```
@@ -2196,89 +2377,97 @@ private func runBootPipelineSteps() async throws {
#### 7.5.5 大厅 / 子游戏 / 弹层差异
| 容器 | app_data.js 写入 | app_gamesname.js 写入 | app_battery.js | app_network.js | 备注 |
|------|---|---|---|---|---|
| 大厅(WebContainer.lobby | `app_Launchtype="0"` + `gamedir = BundleConfig.gameDir` + `gamename = gameStart` | 写当前已安装子游戏列表 | ✓ | ✓ | `app_appversion` 用远端 ResolvedVersion |
| 子游戏(WebContainer.subGame | `app_Launchtype="1"` + `gamedir = 子游戏目录` + `gamename = 子游戏名` |样写(与大厅同份内容) | ✓ | | 子游戏专属 `result_state` 字段,新外壳暂用同 appVersion |
| 弹层(OverlayViewController / threeView | ✗ 不写 | ✗ | ✗ | ✗ | 弹层 H5 走 `window.settings.{backgameData/browser/finishweb}`(§3.4),不读 app_* 全局变量 |
| 容器 | 阶段 A UserScript 覆盖(13 项) | 阶段 B evaluateJavaScript 动态(2 项) | 备注 |
|------|---|---|---|
| 大厅(WebContainer.lobby | `app_Launchtype=0` + `gamedir = BundleConfig.gameDir` + `gamename = gameStart` + 已安装子游戏列表 | `app_getbattery` / `app_getnetwork` 实时变化 | `app_appversion` 用远端 ResolvedVersion |
| 子游戏(WebContainer.subGame | `app_Launchtype=1` + `gamedir = 子游戏目录` + `gamename = 子游戏名` +份子游戏列表 | 同上 | 子游戏专属 `result_state` 字段,新外壳暂用同 appVersion |
| 弹层(OverlayViewController / threeView | ✗ 不注入 | ✗ | 弹层 H5 走 `window.settings.{backgameData/browser/finishweb}`(§3.4),不读 `app_*` 全局变量 |
#### 7.5.6 与原 msext 的差异
| 维度 | msext 现状 | 新外壳决策 |
|------|---|---|
| 入主体 | `NewRootVC.initJSdata` / `gameController.initJSdata` 散落在 VC 内 | 抽象为 `AppDataWriter` struct,三个容器复用同一份代码 |
| 入主体 | `NewRootVC.initJSdata` / `gameController.initJSdata` 散落在 VC 内**写文件** | 抽象为 `AppDataInjector` struct,三个容器复用同一份代码**evaluateJavaScript** |
| 注入机制 | `[NSString writeToFile:atomically:YES encoding:UTF8 error:nil]``app_*.js` 到沙盒,H5 通过 `<script src>` 同步引入 | H5 端 `app_*.js` 由 H5 团队自带(默认占位值),原生 `WKUserScript(.atDocumentEnd)` + `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量 |
| 数据源 | `self.gameconfig` 等实例属性,跨多处赋值 | 统一从 `BundleConfig.shared` + `ResolvedVersion` 拉,单一真相 |
| 文件写入方式 | `[NSString writeToFile:atomically:YES encoding:UTF8 error:nil]` | `String.write(to:atomically:encoding:)`(等价) |
| battery / network 时机 | `viewWillAppear` + 前台通知双重保险 | 同一份机制 + `addObserver` 注册一次(避免双重写入) |
| 转义处理 | 直接 `stringWithFormat:@"var x=\"%@\""` 不转义 | 完整转义 4 种特殊字符(避免渠道字段含引号导致 H5 JS 解析错) |
| battery / network 时机 | `viewWillAppear` + 前台通知双重保险 + 重写文件 | `addObserver` 注册一次 + `evaluateJavaScript` 单条覆盖 + 首次 didFinish 补一次 |
| 字符串包裹符 | `var x='%@'` 单引号,沿用 msext 字面 | `window.x='...'` 单引号一致 |
| 转义处理 | 直接 `stringWithFormat:@"var x='%@'"` 不转义 | 完整转义 4 种特殊字符(避免渠道字段含引号导致 H5 JS 解析错) |
| 大小写 | `app_Launchtype` / `app_getwifisignalLevel` 严格保持 | 同款,注释提示 |
#### 7.5.7 与 §3.4.1 撤销 polyfill 的关系
| 路径 | 状态 | 用途 |
|---|---|---|
| §7.5 `app_*.js` 文件预写 | ✓ **主路径** | iOS 9+ 全部 H5 业务读 `app_xxx` 全局变量 |
| §7.5 `app_*.js` evaluateJavaScript 修改全局变量 | ✓ **主路径** | iOS 9+ 全部 H5 业务读 `app_xxx` 全局变量H5 自带 `app_*.js` 默认占位,原生覆盖为实际值 |
| §3.4.1 polyfill | ✗ **已撤销** | 仅 iOS<9 旧桥 `window.settings.getXxx()`,本项目最低 iOS 15.6 不需要 |
| §3.4 OverlayBridge polyfill | ✓ **保留** | 弹层 `window.settings.{backgameData/browser/finishweb}`,与系统版本无关 |
| §3.2 事件 callback `getBattery` / `getnetwork` / `appservice` | ✓ **保留** | 业务期 H5 主动收到的变化推送,与 §7.5 文件重写互为补充 |
| §3.2 事件 callback `getBattery` / `getnetwork` / `appservice` | ✓ **保留** | 业务期 H5 主动收到的变化推送,与 §7.5 `evaluateJavaScript` 覆盖互为补充(推送 + 状态双轨) |
| msext 原写文件路径 | ✗ **不实施** | §7.5.3.legacy 保留作对照参考 |
#### 7.5.8 联调时的可观察性(Phase 1.17 调试手段)
H5 端读到 undefined / 值不对时,第一步是确认"原生了什么、H5 读到什么"。三条对账路径:
H5 端读到错误值时,第一步是确认"原生注入了什么、H5 端最终读到什么"。**核心对账**:原生注入的 JS 字符串与 H5 端 `window.app_xxx` 的实时值。
**1. Xcode 控制台 — 原生入 log(§7.5.3 已埋点)**
**1. Xcode 控制台 — 原生入 log(§7.5.3 已埋点)**
启动后 Xcode console 应出现:
```
[lobby] write app_data.js → /Users/.../Caches/<gamedir>/<gamestart>/app_data.js
app_version = 1
app_gameconfig = 'tsgames.daoqi88.cn-config_test-update_jsonv2_test'
app_gamedir = 'FtJf07...'
app_gamestart = 'gamehall'
...
app_Launchtype = 0
app_getwifisignalLevel = 1
app_gamename = 'gamehall'
app_invitationcode = ''
[lobby] write app_gamesname.js: 0 games → /Users/.../Caches/<gamedir>/<gamestart>/app_gamesname.js
games = []
[lobby] write app_battery.js: app_getbattery=0.85 → ...
[lobby] write app_network.js: app_getnetwork=2 → ...
[lobby] makeInitialUserScript (0 games installed):
window.app_version = 1
window.app_gameconfig = 'tsgames.daoqi88.cn-config_test-update_jsonv2_test'
window.app_gamedir = 'FtJf07...'
window.app_gamestart = 'gamehall'
window.app_agent = 'veRa0qrBf0df...'
window.app_appversion = '43'
window.app_market = '2'
window.app_channel = 'FtJf07...'
window.app_Launchtype = 0
window.app_getwifisignalLevel = 1
window.app_gamename = 'gamehall'
window.app_invitationcode = ''
window.app_gamesname = []
[lobby] inject battery: app_getbattery=0.85
[lobby] inject network: app_getnetwork=2
```
**2. 沙盒文件直接读 — 等价 `cat`**
若没出现 → AppDataInjector 没接入 / log subsystem 过滤掉了,先按 §7.5.4 接入点检查。
如果控制台 log 没问题但 H5 端仍读 undefined,去沙盒拿实际文件验证写入和"H5 看到的"是否一致:
**2. H5 console 对账 — Safari Web Inspector(核心手段)**
```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,逐项 console.log
模拟器跑时 macOS Safari → 开发菜单 → Simulator → 当前 H5 页面 → Web Inspector → Console,逐项 console.log 读 `window.app_xxx`
```js
console.log("app_version =", app_version); // 期望 1
console.log("app_channel =", app_channel); // 期望 ChannelConfig.plist channel 值
console.log("app_Launchtype =", app_Launchtype); // 大厅期望 0,⚠️ L 大写
console.log("app_getbattery =", app_getbattery); // ⚠️ 带 get 前缀
console.log("app_getnetwork =", app_getnetwork);
console.log("app_gamesname =", app_gamesname); // 期望 Array
// 阶段 A13 项启动期覆盖
console.log("app_version =", window.app_version); // 期望 1
console.log("app_channel =", window.app_channel); // 期望 ChannelConfig.plist channel 值
console.log("app_Launchtype =", window.app_Launchtype); // 大厅期望 0,⚠️ L 大写
console.log("app_getwifisignalLevel =", window.app_getwifisignalLevel); // ⚠️ wifi 小写
console.log("app_invitationcode =", window.app_invitationcode);
console.log("app_gamesname =", window.app_gamesname); // 期望 Array
// 阶段 B2 项动态覆盖
console.log("app_getbattery =", window.app_getbattery); // ⚠️ 带 get 前缀
console.log("app_getnetwork =", window.app_getnetwork);
```
任一项为 `undefined` → 拼写错误(大小写 / 前缀)或文件没加载到。常见错误:写成 `app_launchtype`(小写 l/ `app_battery`(少 get/ `app_getNetwork`(驼峰错)。
| 现象 | 可能原因 |
|---|---|
| 全部为 H5 端默认占位(不是实际渠道值)| AppDataInjector.makeInitialUserScript 没 addUserScript / injectionTime 错(不是 .atDocumentEnd/ H5 自带 `app_*.js` 的 var 声明在我们之后又跑了一次 |
| 某项为 `undefined` | 拼写错误(`app_launchtype` 小写 l / `app_battery` 少 get / `app_getNetwork` 驼峰错) |
| `app_getbattery` / `app_getnetwork` 是默认值不变 | 阶段 B 钩子没接入 / 模拟器 batteryLevel = -1(模拟器无电池硬件)/ NWPathMonitor 未启动 |
| `app_gamesname` 是空数组但实际已装子游戏 | `SandboxPaths.installedSubGameNames()` 实现错 / 子游戏目录路径不对 |
**4. 真机调试**iPhone 上 Safari → 设置 → 高级 → 网页检查器开启,然后 macOS Safari 同样路径。
**3. 真机调试**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 再读应反映新值。
> ⚠️ 上面 H5 控制台和原生 log 调试**仅在 Debug 配置生效**。Release 版本 os.log `.debug` 级别会被系统过滤掉,避免性能开销和敏感字段(agent / channel)外泄。Phase 9 Release 监控走 Sentry breadcrumb(§11.3),不依赖 console。
> ⚠️ 上面 H5 控制台和文件路径调试**仅在 Debug 配置生效**。Release 版本 os.log `.debug` 级别会被系统过滤掉,避免性能开销和敏感字段(agent / channel)外泄。Phase 9 Release 监控走 Sentry breadcrumb(§11.3),不依赖 console。