代码层落地 AppDataWriter + NetworkMonitor + WebContainer 接入
按 Design §7.5 蓝图落地,让 H5 启动时能从沙盒读到实际渠道/启动/设备
值,闭合 Phase 1.17 联调"app_gameconfig 不正确"现象。
新增 Source/WebView/AppDataWriter.swift:
- public struct AppDataWriter(@MainActor)
- ContainerRole enum:.lobby / .subGame(name:, dir:)
- writeInitial():写 app_data.js(12 项)+ app_gamesname.js(暂只写当前
gameStart 一项,Phase 6 子游戏完整时扩为扫描已装列表)
- writeBattery(level:):写 app_battery.js(var app_getbattery=N;)
- writeNetwork(code:):写 app_network.js(var app_getnetwork=N;)
- 字面严格对齐 msext:
* 字符串单引号包裹 'value'
* 数值无引号(version=1 / Launchtype=0 / getwifisignalLevel=1)
* app_gamesname 用 "var app_gamesname=new Array(...)"(var 后两空格)
* 大小写硬约束:Launchtype L 大、getwifisignalLevel wifi 小 + signal/Level 区分
- escape 转义反斜杠 / 单引号 / 换行(避免渠道字段含单引号导致 H5 JS 解析错)
- Logger debug 输出文件路径 + 每个 key=value 一行,便于 §7.5.8 联调对账
新增 Source/Resource/NetworkMonitor.swift:
- @MainActor public final class NetworkMonitor
- NWPathMonitor 最简 wrap:currentCode 同步快照(默认 2 WiFi,启动后被
首次 path 更新)+ onChange MainActor 回调
- shared 单例 + 幂等 start()
WebContainer.runBootPipelineSteps 接入:
- switch outcome 前提取 let resolved: ResolvedVersion,避免变量作用域
限制 case 内
- 新增 writeAppDataFiles(resolved:):启动 NetworkMonitor + 开 battery
监控 + 写 4 个文件首次值 + 挂 addObserver(battery) + onChange(network)
- 调用位置:step 5 LobbyZipUpgrader 之后、step 6 loadFileURL 之前
(与 msext NewRootVC.initJSdata 等价时序)
BuildProject 通过
Plan §6.4 里程碑加一行。
Phase 1.17 联调验证:启动后 H5 console 应读到实际 app_channel /
app_gameconfig 等值,不再是 H5 zip 包内默认占位。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
13cc24b463
commit
829cc8f825
@@ -768,7 +768,8 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
|
||||
| 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` 全局变量」 | `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 |
|
||||
| 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 全面同步回退 | `13cc24b` |
|
||||
| 2026-06-22 | **AppDataWriter 代码落地**:新增 `Source/WebView/AppDataWriter.swift` + `Source/Resource/NetworkMonitor.swift`;WebContainer.runBootPipelineSteps 在 step 5 后、step 6 loadFileURL 前调 `writeAppDataFiles(resolved:)` 写 4 个 app_*.js 文件 + 挂 battery/network 变化重写监听。BuildProject 通过 | 本次 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「典型案例」段。
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// NetworkMonitor.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// NWPathMonitor 的最简 wrap:提供 currentCode 同步快照 + onChange 事件回调。
|
||||
// 用于 AppDataWriter.writeNetwork(_:) 拿首次值 + 变化重写 app_network.js。
|
||||
// 详见 docs/H5-Native-Implementation-Design.md §7.5 / §8.4。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// `app_getnetwork` 的语义码(与 msext 沿用):
|
||||
/// 1 = 无网,2 = WiFi,3 = 蜂窝
|
||||
@MainActor
|
||||
public final class NetworkMonitor {
|
||||
|
||||
public static let shared = NetworkMonitor()
|
||||
|
||||
public private(set) var currentCode: Int = 2
|
||||
|
||||
/// 网络变化回调。每次状态变化时主线程触发。
|
||||
public var onChange: (@MainActor (Int) -> Void)?
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "ylgamehall.networkmonitor", qos: .utility)
|
||||
private var started = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let code: Int = {
|
||||
if path.status != .satisfied { return 1 }
|
||||
if path.usesInterfaceType(.wifi) { return 2 }
|
||||
return 3
|
||||
}()
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.currentCode = code
|
||||
self.onChange?(code)
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// AppDataWriter.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// H5 app_*.js 预注入文件机制 — 在 loadFileURL 之前写 4 个 .js 文件到沙盒,
|
||||
// H5 业务通过 <script src="app_*.js"> 同步引入即可读到实际渠道/启动/设备值。
|
||||
// 与原 msext NewRootVC.initJSdata / changebattery / changenetstate 1:1 等价。
|
||||
//
|
||||
// 详见 docs/H5-Native-Implementation-Design.md §7.5。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
@MainActor
|
||||
public struct AppDataWriter {
|
||||
|
||||
public enum ContainerRole: CustomStringConvertible, Sendable {
|
||||
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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bundleConfig: BundleConfig
|
||||
let resolvedVersion: ResolvedVersion?
|
||||
let containerRole: ContainerRole
|
||||
|
||||
private static let log = Logger(subsystem: "ylgamehall", category: "AppDataWriter")
|
||||
|
||||
public init(
|
||||
bundleConfig: BundleConfig,
|
||||
resolvedVersion: ResolvedVersion?,
|
||||
containerRole: ContainerRole
|
||||
) {
|
||||
self.bundleConfig = bundleConfig
|
||||
self.resolvedVersion = resolvedVersion
|
||||
self.containerRole = containerRole
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// 一次性写 app_data.js(含 #1-#12 共 12 个 var)+ app_gamesname.js(#13)。
|
||||
/// loadFileURL 前调一次。
|
||||
public func writeInitial() throws {
|
||||
try writeAppData()
|
||||
try writeGamesName()
|
||||
}
|
||||
|
||||
/// 写 #14 app_battery.js。loadFileURL 前补一次 + battery 变化重写。
|
||||
public func writeBattery(_ level: Float) throws {
|
||||
// 模拟器 batteryLevel 返回 -1(无电池硬件),用 0.0 占位避免负数
|
||||
let normalized = max(level, 0)
|
||||
let value = String(format: "%.2f", normalized)
|
||||
let line = "var app_getbattery=\(value);\n"
|
||||
let url = filePath("app_battery.js")
|
||||
try line.write(to: url, atomically: true, encoding: .utf8)
|
||||
Self.log.debug("[\(containerRole.description, privacy: .public)] write app_battery.js: app_getbattery=\(value, privacy: .public) → \(url.path, privacy: .public)")
|
||||
}
|
||||
|
||||
/// 写 #15 app_network.js。loadFileURL 前补一次 + NWPathMonitor 变化重写。
|
||||
public func writeNetwork(_ code: Int) throws {
|
||||
let line = "var app_getnetwork=\(code);\n"
|
||||
let url = filePath("app_network.js")
|
||||
try line.write(to: url, atomically: true, encoding: .utf8)
|
||||
Self.log.debug("[\(containerRole.description, privacy: .public)] write app_network.js: app_getnetwork=\(code, privacy: .public) → \(url.path, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: - Private writers
|
||||
|
||||
private func writeAppData() throws {
|
||||
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
|
||||
|
||||
// ⚠️ 严格保持字面(参 docs/H5-Native-Contract.md §4.2 + msext NewRootVC.m:1204):
|
||||
// - 字符串字段单引号包裹(msext "var x='%@';")
|
||||
// - 数值字段无引号(app_version=1 / app_Launchtype=0 / app_getwifisignalLevel=1)
|
||||
// - app_appversion msext 是 %d 形式但传入是 NSString,实际行为同字符串—— 这里转字面串与 msext 等价
|
||||
// - 大小写不能改:app_Launchtype L 大写、app_getwifisignalLevel wifi 小写 + signal/Level 区分
|
||||
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))';"
|
||||
|
||||
let url = filePath("app_data.js")
|
||||
try lines.write(to: url, atomically: true, encoding: .utf8)
|
||||
|
||||
Self.log.debug("""
|
||||
[\(containerRole.description, privacy: .public)] write app_data.js → \(url.path, privacy: .public)
|
||||
app_version = 1
|
||||
app_gameconfig = '\(bc.gameConfig, privacy: .public)'
|
||||
app_gamedir = '\(gameDir, privacy: .public)'
|
||||
app_gamestart = '\(gameStart, privacy: .public)'
|
||||
app_agent = '\(bc.agent, privacy: .public)'
|
||||
app_appversion = '\(appVersion, privacy: .public)'
|
||||
app_market = '\(bc.market, privacy: .public)'
|
||||
app_channel = '\(bc.channel, privacy: .public)'
|
||||
app_Launchtype = \(launchtype, privacy: .public)
|
||||
app_getwifisignalLevel = 1
|
||||
app_gamename = '\(gameName, privacy: .public)'
|
||||
app_invitationcode = '\(bc.other, privacy: .public)'
|
||||
""")
|
||||
}
|
||||
|
||||
private func writeGamesName() throws {
|
||||
// Phase 1: 暂时只写当前 gameStart 一项(与 msext AppDelegate.m:259 首次启动行为一致)。
|
||||
// Phase 6 子游戏完整时改为扫描 SandboxPaths.installedSubGames + 增量追加。
|
||||
let names = [bundleConfig.gameStart]
|
||||
let escaped = names.map { "'\(escape($0))'" }.joined(separator: ",")
|
||||
// ⚠️ msext 字面:var 后两个空格 + new Array(...),沿用
|
||||
let line = "var app_gamesname=new Array(\(escaped));\n"
|
||||
let url = filePath("app_gamesname.js")
|
||||
try line.write(to: url, atomically: true, encoding: .utf8)
|
||||
Self.log.debug("[\(containerRole.description, privacy: .public)] write app_gamesname.js: \(names.count, privacy: .public) games → \(url.path, privacy: .public)\n games = \(names, privacy: .public)")
|
||||
}
|
||||
|
||||
private func filePath(_ name: String) -> URL {
|
||||
// 与 msext NewRootVC 一致:{gamedir}/{gamestart}/app_*.js
|
||||
let base: URL
|
||||
switch containerRole {
|
||||
case .lobby:
|
||||
// {Caches}/{gameDir}/{gameStart}/ = SandboxPaths.lobbyIndex 的父目录
|
||||
base = SandboxPaths.lobbyIndex.deletingLastPathComponent()
|
||||
case .subGame(let name, let dir):
|
||||
// {Caches}/{dir}/{name}/ = SandboxPaths.subGameIndex(dir,name) 的父目录
|
||||
base = SandboxPaths.subGameIndex(dir, name).deletingLastPathComponent()
|
||||
}
|
||||
return base.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
/// JS string 转义:反斜杠 / 单引号 / 换行(msext 用单引号包裹字符串,
|
||||
/// 转义对象是单引号)。
|
||||
private func escape(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "\\'")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: "\r", with: "\\r")
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,7 @@ public final class WebContainerViewController: UIViewController {
|
||||
splash.update(text: "拉取配置中...", progress: nil)
|
||||
let outcome = try await RemoteConfigClient.shared.fetch()
|
||||
|
||||
let resolved: ResolvedVersion
|
||||
switch outcome {
|
||||
case .shortText(let msg):
|
||||
// 运营杀手锏 #1:运营把整段服务端响应替换为短文本,前端弹窗永停
|
||||
@@ -136,7 +137,7 @@ public final class WebContainerViewController: UIViewController {
|
||||
|
||||
case .parsed(let cfg):
|
||||
let bc = BundleConfig.shared
|
||||
let resolved = VersionResolver.resolve(
|
||||
let r = VersionResolver.resolve(
|
||||
config: cfg,
|
||||
agentId: bc.agent,
|
||||
channelId: bc.channel,
|
||||
@@ -145,13 +146,13 @@ public final class WebContainerViewController: UIViewController {
|
||||
)
|
||||
|
||||
// 3. 运营杀手锏 #2:showmessage 非空 → 弹窗永停
|
||||
if let msg = resolved.showmessage, !msg.isEmpty {
|
||||
if let msg = r.showmessage, !msg.isEmpty {
|
||||
throw BootError.operationalMessage(msg)
|
||||
}
|
||||
|
||||
// 4. IPA 升级(远端 appVersion > 本地):弹窗 + Safari 外链永停
|
||||
if resolved.appVersion > LocalVersionReader.localAppVersion,
|
||||
let dl = resolved.appDownload {
|
||||
if r.appVersion > LocalVersionReader.localAppVersion,
|
||||
let dl = r.appDownload {
|
||||
throw BootError.ipaUpgradeRequired(downloadURL: dl)
|
||||
}
|
||||
|
||||
@@ -159,7 +160,7 @@ public final class WebContainerViewController: UIViewController {
|
||||
// LobbyZipUpgrader 的 onProgress 由 URLSession delegate 在后台队列调度,
|
||||
// 必须显式 hop 到 MainActor 才能动 splash UI。
|
||||
_ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
|
||||
resolved: resolved,
|
||||
resolved: r,
|
||||
onProgress: { [weak self] p in
|
||||
Task { @MainActor in
|
||||
self?.splash.update(
|
||||
@@ -169,9 +170,14 @@ public final class WebContainerViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
)
|
||||
resolved = r
|
||||
}
|
||||
|
||||
// 6. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源)
|
||||
// 6. 在 loadFileURL 之前写 4 个 app_*.js(与原 msext NewRootVC.initJSdata 等价时序)
|
||||
// 详见 docs/H5-Native-Implementation-Design.md §7.5
|
||||
try writeAppDataFiles(resolved: resolved)
|
||||
|
||||
// 7. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源)
|
||||
splash.update(text: "加载大厅...", progress: nil)
|
||||
bridgedWebView.webView.loadFileURL(
|
||||
SandboxPaths.lobbyIndex,
|
||||
@@ -179,6 +185,40 @@ public final class WebContainerViewController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
/// 写 4 个 app_*.js 文件,并挂钩 battery / network 变化的重写监听。
|
||||
/// 与原 msext NewRootVC.initJSdata + changebattery + changenetstate 等价。
|
||||
private func writeAppDataFiles(resolved: ResolvedVersion) throws {
|
||||
let writer = AppDataWriter(
|
||||
bundleConfig: .shared,
|
||||
resolvedVersion: resolved,
|
||||
containerRole: .lobby
|
||||
)
|
||||
|
||||
// 启动 NetworkMonitor(幂等:内部 started 标记)
|
||||
NetworkMonitor.shared.start()
|
||||
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||
|
||||
// 首次写入:4 个文件全部落盘
|
||||
try writer.writeInitial()
|
||||
try writer.writeBattery(UIDevice.current.batteryLevel)
|
||||
try writer.writeNetwork(NetworkMonitor.shared.currentCode)
|
||||
|
||||
// 持续监听:battery / network 变化时重写对应文件。
|
||||
// 注意:H5 此次加载已读到首次写入的值,下次 reload 才会读到新值;
|
||||
// 业务期间的实时变化由 §3.2 [2][3]反向 callback 推送(Phase 2 实现)。
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIDevice.batteryLevelDidChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
try? writer.writeBattery(UIDevice.current.batteryLevel)
|
||||
}
|
||||
|
||||
NetworkMonitor.shared.onChange = { code in
|
||||
try? writer.writeNetwork(code)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Alerts (terminal / retry states)
|
||||
|
||||
private var appDisplayName: String {
|
||||
|
||||
Reference in New Issue
Block a user