Design §7.5:补 H5 app_*.js 预注入文件机制(15 个变量),§3.4.1 polyfill 撤销

按 CLAUDE.md「先看原项目」规则深度审查 daoqi/msext NewRootVC.initJSdata /
gameController.initJSdata / AppDelegate 写入逻辑后发现:

**关键纠正**:H5 在 iOS 9+ 路径下的真实读值机制是 §4.2「写 app_*.js 文件
+ H5 <script src> 同步引入」,不是早期 commit 82bad8a 实现的
§3.4.1 window.settings.getXxx() polyfill(那是 §附录 A 旧桥 JSExport
路径,契约明示「新外壳如最低系统 ≥ iOS 14 可不实现」,本项目最低
iOS 15.6 → 无需实现)。

§3.4.1 处理:标题改为「⚠️ 撤销」+ 完整说明撤销原因;旧 polyfill 代码
骨架保留为 §3.4.1.legacy 作为数据源映射参考(不删除以保 git log 整洁,
也方便 Phase 2 实施者了解 9 项 getter 的等价数据来源)。

§7.5 新章节覆盖 15 个变量(用户列 14 + audit 发现 app_invitationcode):
  - §7.5.1 完整映射表:变量名 / 大厅值 / 子游戏值 / 数据源 / 文件
  - §7.5.2 4 个文件写入时机(一次性 vs 事件驱动)+ 为什么写文件不
    evaluateJavaScript(保证 <script src> 同步引入时机)
  - §7.5.3 AppDataWriter struct Swift 骨架(escape 4 种特殊字符避免
    渠道字段含引号导致 H5 JS 解析错)
  - §7.5.4 WebContainerViewController 接入点:loadFileURL 前 writeInitial
    + writeBattery + writeNetwork;持续监听 batteryLevelDidChange /
    NWPathMonitor 触发增量重写
  - §7.5.5 大厅 / 子游戏 / 弹层 4 维度差异(弹层不写 app_*)
  - §7.5.6 与 msext 6 维度差异表(单一真相 / 转义 / 大小写严格)
  - §7.5.7 §7.5 vs §3.4.1 vs §3.4 OverlayBridge vs §3.2 callback 四条
    路径关系澄清

大小写严格契约:app_Launchtype(L 大写)/ app_getwifisignalLevel(wifi
小写、signal/Level 区分大小写)— 任何拼写错误会让 H5 业务读到 undefined。

至此 H5 端 JS 与原生通讯的完整 5 条路径全部覆盖:
  1. WebViewJavascriptBridge 异步 callback(§5/§8/§3.1,22 项 handler)
  2. Native→H5 反向 callback(§3.2,15 项)
  3. app_*.js 文件预注入全局变量(§7.5,15 项)
  4. 弹层 window.settings 同步 polyfill(§3.4,3 项 + §3.4.2 OpenurlTitleData)
  5. WKUIDelegate alert / confirm(§3.7,2 项)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
joywayer
2026-06-22 07:51:13 +08:00
co-authored by Claude Opus 4.7
parent 33574a1cc2
commit f68b0dba93
+230 -1
View File
@@ -489,7 +489,19 @@ public enum OverlayBridge {
3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。 3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。
#### 3.4.1 大厅 / 子游戏的 window.settings 同步 getter polyfill(契约 §附录 A 9 项 getter #### 3.4.1 ⚠️ 撤销:大厅 / 子游戏的 `window.settings.getXxx()` polyfill(无需实现
> 本子节早期版本(commit `82bad8a`)曾设计 9 项同步 getter polyfill`getchannelName` / `getmarketname` / `getothername` / `getbattery` / `getnetwork` / `getcompareCode` / `getGameinstall` / `getGameplay` / `getOther`),把 §附录 A 旧桥 JSExport 方法重新实现为 `window.settings.getXxx()` JS 函数。**经原项目深度审查后撤销**,理由:
>
> 1. **契约自身已明示可不实现**Contract §3.3 标题"关于 iOS<9 旧桥的精确签名(**附录用,新外壳可不实现**)",§附录 A 同样声明"这是 iOS<9 路径使用的旧协议,新外壳如最低系统 ≥ iOS 14 可不实现"。本项目最低 iOS 15.6 → 不需要
> 2. **H5 端真实路径是 §4.2 的 `app_*.js` 文件预注入**H5 业务代码通过 `<script src="app_data.js">` 同步引入预生成的 15 个 `app_xxx` 全局变量直接读值,**不调** `window.settings.getXxx()`。详见 **§7.5 H5 `app_*.js` 预注入文件机制**
> 3. **过度设计**:原 polyfill 把数据快照塞进 `window.__nativeSnapshot` 全局对象 + 写一遍 getter 函数,实际工作量比直接写 `var app_xxx = ...` 文件大;同时引入了"两条路径都存在但 H5 只读其中一条"的混淆
>
> **保留的部分**:§3.4 头部的 `OverlayBridge.polyfill`3 项 `backgameData` / `browser` / `finishweb`)是**弹层 threeView 内**的契约硬约束(Contract §3.4 明示"与系统版本无关,iOS 9+ 也走这条"),与本子节撤销的旧桥 getter polyfill 不是同一回事,继续保留。
>
> Phase 2+ 实施时直接按 §7.5 落地 `app_*.js` 文件预写,本子节内容**不要照搬**。
#### 3.4.1.legacy 9 项 getter 的等价数据来源(仅供 §7.5 映射参考)
**背景**:契约 §附录 A 列出 28 项旧桥 JSExport 方法(iOS<9 路径),与 §3.1 异步 callback 主表去重后多出 **9 项 H5 同步只读 getter** **背景**:契约 §附录 A 列出 28 项旧桥 JSExport 方法(iOS<9 路径),与 §3.1 异步 callback 主表去重后多出 **9 项 H5 同步只读 getter**
@@ -1956,6 +1968,223 @@ for channel in xianliao 360 qq tencent; do
done done
``` ```
### 7.5 H5 `app_*.js` 预注入文件机制(契约 §4.2 + 原项目全面审查)
**用途**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 已撤销)。
#### 7.5.1 15 个 `app_*` 变量完整映射(按 daoqi/msext `NewRootVC.initJSdata`、`gameController.initJSdata`、`AppDelegate.applicationDidFinishLaunching` 审查得出)
| # | 变量名 | 大厅值(NewRootVC| 子游戏值(gameController)| 数据源(新外壳) | 写入文件 |
|---|---|---|---|---|---|
| 1 | `app_version` | `"1"` 硬编码 | `"1"` 硬编码 | 硬编码 string `"1"`(沿用历史,H5 不会读出不同值) | `app_data.js` |
| 2 | `app_gameconfig` | `self.gameconfig` | `self.gameconfig` | `BundleConfig.shared.gameConfig` | `app_data.js` |
| 3 | `app_gamedir` | `self.gamedir` | `self.gamefilepath` | 大厅 `BundleConfig.shared.gameDir` / 子游戏 `subGameDir` | `app_data.js` |
| 4 | `app_gamestart` | `self.gamestart` | `self.game_name` | 大厅 `BundleConfig.shared.gameStart` / 子游戏 `subGameName` | `app_data.js` |
| 5 | `app_agent` | `self.agentinfo` | `self.agentinfo` | `BundleConfig.shared.agent` | `app_data.js` |
| 6 | `app_appversion` | 远端 `result` 解析后的 appVersion | `self.result_state` | `ResolvedVersion.appVersion``BundleConfig.shared.appVersion`(启动早期 fallback| `app_data.js` |
| 7 | `app_market` | `self.market` | `self.market` | `BundleConfig.shared.market` | `app_data.js` |
| 8 | `app_channel` | `self.channel_id` | `self.channel_id` | `BundleConfig.shared.channel` | `app_data.js` |
| 9 | `app_Launchtype` | `"0"` 硬编码 | `"1"` 硬编码 | 大厅 `"0"` / 子游戏 `"1"`(⚠️ L 大写) | `app_data.js` |
| 10 | `app_getwifisignalLevel` | `"1"` 硬编码 | `"1"` 硬编码 | 硬编码 string `"1"`(⚠️ wifi 小写、signal/Level 区分大小写) | `app_data.js` |
| 11 | `app_gamename` | `self.gamestart` | `self.game_name` | 大厅 = `gameStart` / 子游戏 = 子游戏名(与 `app_gamestart` 同值) | `app_data.js` |
| 12 | `app_invitationcode` | `self.tuiguang_id` | `self.tuiguang_id` | `BundleConfig.shared.other`(推广码 / 邀请码,沿用 other 字段;如未来独立字段再改)| `app_data.js` |
| 13 | `app_gamesname` | 已安装子游戏 JS Array | 同上 + 新增本子游戏 | `SandboxPaths.installedSubGameNames()` JS Array literal | `app_gamesname.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` |
#### 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 每次 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 注入"。
> 注:`WKUserScript(.atDocumentStart)` 也是可行的现代化方式(不依赖磁盘 IO),与"写文件"语义等价。两条路径选一即可。本 Design 推荐**写文件**路径,与 msext 一致,减少 H5 端任何假设差异;如 Phase 2 实施时发现写文件 IO 太慢(实测应该 < 5ms 不会成为瓶颈),再切 WKUserScript。
#### 7.5.3 Swift 骨架
```swift
// Source/WebView/AppDataWriter.swift
@MainActor
public struct AppDataWriter {
let bundleConfig: BundleConfig
let resolvedVersion: ResolvedVersion? // fallback nil
let containerRole: ContainerRole // .lobby / .subGame(name:)
public enum ContainerRole {
case lobby
case subGame(name: String, dir: String)
}
/// #1-#12 app_data.jsloadFileURL
/// app_gamesname.js#13
public func writeInitial() throws {
try writeAppData()
try writeGamesName()
}
/// #14 app_battery.js / viewWillAppear
public func writeBattery(_ level: Float) throws {
let line = "var app_getbattery = \"\(String(format: "%.2f", level))\";\n"
try line.write(to: filePath("app_battery.js"),
atomically: true, encoding: .utf8)
}
/// #15 app_network.jsNWPath / viewWillAppear
public func writeNetwork(_ code: Int) throws {
let line = "var app_getnetwork = \"\(code)\";\n"
try line.write(to: filePath("app_network.js"),
atomically: true, encoding: .utf8)
}
private func writeAppData() throws {
let bc = bundleConfig
let launchtype: String
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
// var string JS ==
// msext 沿 string
let lines = """
var app_version = "1";
var app_gameconfig = "\(escape(bc.gameConfig))";
var app_gamedir = "\(escape(gameDir))";
var app_gamestart = "\(escape(gameStart))";
var app_agent = "\(escape(bc.agent))";
var app_appversion = "\(escape(appVersion))";
var app_market = "\(escape(bc.market))";
var app_channel = "\(escape(bc.channel))";
var app_Launchtype = "\(launchtype)";
var app_getwifisignalLevel= "1";
var app_gamename = "\(escape(gameName))";
var app_invitationcode = "\(escape(bc.other))";
"""
try lines.write(to: filePath("app_data.js"),
atomically: true, encoding: .utf8)
}
private func writeGamesName() throws {
// JS Array literal: ["game1","game2",...]
let installed = SandboxPaths.installedSubGameNames()
let escaped = installed.map { "\"\(escape($0))\"" }.joined(separator: ",")
let line = "var app_gamesname = [\(escaped)];\n"
try line.write(to: filePath("app_gamesname.js"),
atomically: true, encoding: .utf8)
}
private func filePath(_ name: String) -> URL {
// msext NewRootVC {gamedir}/{gamestart}/app_*.js
let base: URL
switch containerRole {
case .lobby:
base = SandboxPaths.lobbyAssets // {Caches}/{gamedir}/{gamestart}/
case .subGame(_, let dir):
base = SandboxPaths.subGameAssets(dir: dir)
}
return base.appendingPathComponent(name)
}
/// JS string
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.4 WebContainerViewController 接入点
```swift
private func runBootPipelineSteps() async throws {
// ... ensureReady / fetch / resolve / upgrade ...
// 6. loadFileURL 4 app_*.js msext 沿
let writer = AppDataWriter(
bundleConfig: .shared,
resolvedVersion: resolved,
containerRole: .lobby
)
try writer.writeInitial()
try writer.writeBattery(UIDevice.current.batteryLevel)
try writer.writeNetwork(networkMonitor.currentCode)
splash.update(text: "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
// battery / network
UIDevice.current.isBatteryMonitoringEnabled = true
NotificationCenter.default.addObserver(
forName: UIDevice.batteryLevelDidChangeNotification,
object: nil, queue: .main
) { _ in
try? writer.writeBattery(UIDevice.current.batteryLevel)
}
networkMonitor.stateDidChange = { code in
try? writer.writeNetwork(code)
}
}
```
> ⚠️ 持续重写文件**不会** trigger H5 端 `<script src>` 重新加载(浏览器只在页面 load 时引入一次)。这条路径只服务于"刷新 → reload → 读到最新值"的场景,与 §3.2 2][3]的 `bridge.call("getBattery"|"getnetwork", ...)` 事件 callback **互为补充**
> - 事件 callback = 业务期 H5 主动收到变化推送
> - 文件重写 = 下次 reload 时 H5 读到的初始值是最新的
>
> 两条路径并存,与原 msext 行为等价。
#### 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_* 全局变量 |
#### 7.5.6 与原 msext 的差异
| 维度 | msext 现状 | 新外壳决策 |
|------|---|---|
| 写入主体 | `NewRootVC.initJSdata` / `gameController.initJSdata` 散落在 VC 内 | 抽象为 `AppDataWriter` struct,三个容器复用同一份代码 |
| 数据源 | `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 解析错) |
| 大小写 | `app_Launchtype` / `app_getwifisignalLevel` 严格保持 | 同款,注释提示 |
#### 7.5.7 与 §3.4.1 撤销 polyfill 的关系
| 路径 | 状态 | 用途 |
|---|---|---|
| §7.5 `app_*.js` 文件预写 | ✓ **主路径** | iOS 9+ 全部 H5 业务读 `app_xxx` 全局变量 |
| §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 文件重写互为补充 |
--- ---
## 8. 能力模块详细设计 ## 8. 能力模块详细设计