按 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>
172 lines
7.3 KiB
Swift
172 lines
7.3 KiB
Swift
//
|
||
// 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")
|
||
}
|
||
}
|