Files
youle_app_ios_v2/ylgamehall/Source/WebView/AppDataWriter.swift
T
joywayerandClaude Opus 4.7 0a313afe8e app_appversion 业务语义纠正:审核切换标志 0/1(非版本号)
调研 daoqi/msext NewRootVC.m:1190-1208 initJSdata 发现 app_appversion
真实业务语义是审核切换标志,**不是 App 的版本号**:

  if (远端 ResolvedVersion.appVersion >= 本地 BundleConfig.appVersion) {
      app_gameconfig = BundleConfig.gameConfig;  // 正常业务接口
      app_appversion = '0';                       // 标志:正常
  } else {  // 远端 < 本地(罕见,审核期 / IPA 已升远端未跟上)
      app_gameconfig = BundleConfig.appleConfig; // 苹果审核期接口
      app_appversion = '1';                       // 标志:审核期
  }

H5 端 if (app_appversion === '1') 切苹果审核期分支;99% 时间下都是 '0'
+ gameconfig。

当前代码两处错误:
   app_appversion 写成 "43"(版本号字符串)
   app_gameconfig 永远是 BundleConfig.gameConfig 硬编码

不符合契约(H5 拿到错误标志会走错分支)。按 CLAUDE.md 原则 A 第一准则
必须 1:1 等价 msext。

修复(Source/WebView/AppDataWriter.swift writeAppData):
  - 加 result 计算:localAppVer = Int(bc.appVersion), remoteAppVer =
    resolvedVersion?.appVersion ?? localAppVer;result = remoteAppVer >=
    localAppVer ? 0 : 1
  - 加 gameConfigStr 切换:result == 0 ? bc.gameConfig : bc.appleConfig
  - app_appversion 写字面 '\(result)' 单引号包裹(与 msext "var
    app_appversion='%d';" 等价)
  - Logger 输出加 result 说明 + local/remote 版本号便于调试

Contract §4.2:新增详细业务语义说明段(含 msext 原 ObjC 代码 + result
0/1 含义解读 + app_gameconfig 动态切换说明),修订记录加一行。

Design §7.5.1 表格:#2 app_gameconfig 数据源改为"动态切换",#6
app_appversion 数据源改为"审核切换标志"。

Plan §6.4 里程碑加一行。

BuildProject 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 18:28:08 +08:00

185 lines
8.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
}
// ⚠️ app_appversion 不是 "App 的版本号",而是审核切换标志(参 msext NewRootVC.m:1190-1208):
// 远端 ResolvedVersion.appVersion >= 本地 BundleConfig.appVersion
// → result = 0app_gameconfig 取 BundleConfig.gameConfig(正常业务)
// 否则(远端 < 本地,罕见,可能审核期 / IPA 已升远端未更新)
// → result = 1app_gameconfig 取 BundleConfig.appleConfig(审核期接口)
// app_appversion 写入字面 '0' / '1'(单引号包裹数字,与 msext "var app_appversion='%d';" 等价)
let localAppVer = Int(bc.appVersion) ?? 0
let remoteAppVer = resolvedVersion?.appVersion ?? localAppVer
let result: Int = remoteAppVer >= localAppVer ? 0 : 1
let gameConfigStr: String = result == 0 ? bc.gameConfig : bc.appleConfig
// ⚠️ 严格保持字面(参 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 "var app_appversion='%d';"H5 拿到的是字符串 '0' / '1'
// - 大小写不能改:app_Launchtype L 大写、app_getwifisignalLevel wifi 小写 + signal/Level 区分
//
// 声明顺序:5 个关键字段(gameconfig / market / agent / channel / Launchtype
// 提到最前,便于 H5 console 调试时一眼看到核心值;JS var 提升机制下顺序对最终读取
// 值无影响,与 msext 行为等价。
let lines =
"var app_gameconfig='\(escape(gameConfigStr))';" +
"var app_market='\(escape(bc.market))';" +
"var app_agent='\(escape(bc.agent))';" +
"var app_channel='\(escape(bc.channel))';" +
"var app_Launchtype=\(launchtype);" +
"var app_version=1;" +
"var app_gamedir='\(escape(gameDir))';" +
"var app_gamestart='\(escape(gameStart))';" +
"var app_appversion='\(result)';" +
"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_gameconfig = '\(gameConfigStr, privacy: .public)' (result=\(result, privacy: .public))
app_market = '\(bc.market, privacy: .public)'
app_agent = '\(bc.agent, privacy: .public)'
app_channel = '\(bc.channel, privacy: .public)'
app_Launchtype = \(launchtype, privacy: .public)
app_version = 1
app_gamedir = '\(gameDir, privacy: .public)'
app_gamestart = '\(gameStart, privacy: .public)'
app_appversion = '\(result, privacy: .public)' [审核切换标志,非版本号;local=\(localAppVer, privacy: .public) remote=\(remoteAppVer, 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")
}
}