Commit Graph
11 Commits
Author SHA1 Message Date
joywayerandClaude Opus 4.7 fd9976c4b9 下载进度回调改 session-level delegate(ad-hoc 不触发 didWriteData)
LobbyZipUpgrader 和 SubGameDownloader 之前用 `session.download(from:delegate:)`
的 iOS 15+ async API,把 DownloadProgressDelegate 作为 ad-hoc delegate 传入。
该 API 的 delegate 参数实际只收 URLSessionTaskDelegate 回调,**不会**触发
URLSessionDownloadDelegate.didWriteData,所以 0…1 进度从未上报,splash 进度条
一直停在 0,只有末尾兜底的 onProgress(1.0) 跑一下,看起来"一出现就 100%"。

改为每次下载临时新建一个 URLSession,把 progressDelegate 在 init 时绑到
session 级别(带独立 OperationQueue),下载结束 defer invalidateAndCancel
释放。delegate 现在能收到 didWriteData,真实字节比例上报,进度条 0→1 平滑。

副作用:删除 actor 字段 `session` 与可注入的 `init(session:)`,改用静态
`defaultConfig: URLSessionConfiguration` 复用超时配置(无外部调用方使用过自定义
session init)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 21:34:38 +08:00
joywayerandClaude Opus 4.7 ded6b5d86b Phase 8.4:PhoneStateMonitor → phonestate 反向 callback
契约 §3.1 [14]phonestate 落地。msext 用已废弃的 CTCallCenter(iOS 10+
deprecated);新外壳改用 CallKit CXCallObserver,对 H5 输出字符串一致:
- "2" = 来电响铃中(hasConnected=false / isOutgoing=false / hasEnded=false)
       或已连接(hasConnected=true)
- "0" = 挂断(hasEnded=true)
- 其它(拨出未连接、保持)不发

新增:
- Source/Resource/PhoneStateMonitor.swift(@MainActor singleton,与 BatteryMonitor /
  NetworkMonitor 同款 pattern;onChange closure + start/stop 生命周期)

接入:
- SubGameViewController.setupExternalSubscriptions:start + onChange
- SubGameViewController.teardownExternalSubscriptions:onChange=nil + stop
- 大厅 WebContainerViewController 不订阅(msext gameController 同款边界,
  契约表注「已注释」表示大厅不发;H5 子游戏才依赖此事件做哑麦/暂停)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 02:01:29 +08:00
joywayerandClaude Opus 4.7 a9eaf86c63 Phase 6 commit B:SubGameDownloader zip 下载 + 解压
- 新增 Source/Resource/SubGameDownloader.swift(actor):URLSession + ZIPFoundation,
  缓存命中直接复用(看 {Caches}/{gameDir}/{gameStart}/index.html 是否存在),
  未命中下载 → staging 解压 → 原子 rename(与 LobbyZipUpgrader 同思路;
  msext gameController.m:1340-1401 等价,以 staging 替代 msext 直接覆盖避免半残留)
- SubGameViewController.runBootPipelineSteps:接入 ensureReady,splash 实时更新下载进度,
  移除 commit A 占位的 SubGameBootError
- DownloadProgressDelegate 与 LobbyZipUpgrader 同款就地复制(两处用不抽公共,
  三处出现时再合并)
- Plan §5.6.7 / §8 进度已勾选

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 21:01:53 +08:00
joywayerandClaude Opus 4.7 23b3a1fdad Phase 2.B:3 项反向 callback 落地(getBattery / getnetwork / appservice)
完成大厅业务期实时状态推送给 H5 的事件驱动链路。每次变化时同时(1)
重写对应 app_*.js 文件供下次 reload,(2)bridge.call 反向推送给当前
页 H5 — 双轨与原 msext 行为等价。

新增 Source/Resource/BatteryMonitor.swift:
  - @MainActor public final class BatteryMonitor,shared 单例 + 幂等 start()
  - 监听 UIDevice.batteryLevelDidChangeNotification + 启动期读首次值
  - currentLevel 同步快照(max(level, 0) 兜底模拟器 -1)+ onChange 回调
  - notification block 内 MainActor.assumeIsolated 安全跨到 main actor

新增 Source/Resource/AppLifecycleObserver.swift:
  - @MainActor public final class AppLifecycleObserver,shared 单例 + 幂等 start()
  - 监听 didEnterBackgroundNotification + willEnterForegroundNotification
  - onBackground / onForeground 双钩子;assumeIsolated 同上

WebContainerViewController.writeAppDataFiles 接入(替换 inline addObserver):
  - 3 个 monitor 启动统一聚合(NetworkMonitor + BatteryMonitor + AppLifecycle)
  - BatteryMonitor.onChange:writeBattery + bridge.call("getBattery", "%.2f")
  - NetworkMonitor.onChange:writeNetwork + bridge.call("getnetwork", "1"/"2"/"3")
  - AppLifecycle.onBackground/Foreground:bridge.call("appservice", "1"/"2")
  - 首次值改读 BatteryMonitor.currentLevel 而非 UIDevice 原始值

至此 Phase 2.B 全部完成(5 项反向 callback:getphoneinfo / getBattery /
getnetwork / appservice / shakeEnd 全部接好)。

剩 Phase 2.C ExternalSubscriptions 生命周期管理(防双发,栈深 ≥ 2 时
下层不发桥事件)。

BuildProject 通过

Plan §5 Phase 2.B 全部勾选

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 18:45:03 +08:00
joywayerandClaude Opus 4.7 829cc8f825 代码层落地 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>
2026-06-22 12:44:45 +08:00
joywayerandClaude Opus 4.7 f3c66b670d Phase 1.14.c:LobbyZipUpgrader 加 0…1 progress 回调
- upgradeIfNeeded 新增 onProgress: @escaping @Sendable (Double) -> Void 参数,
  默认 no-op 兼容仅做版本对比的烟雾测试
- 新增文件内私有 DownloadProgressDelegate(URLSessionDownloadDelegate
  + @unchecked Sendable),在 didWriteData 回调里把 totalBytesWritten /
  totalBytesExpectedToWrite 换算成 0…1 上报,clamp 到 [0,1]
- 通过 session.download(from:delegate:) 注入;下载完成后兜底报 1.0
  (部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%)
- RootViewController 烟雾测试用 ProgressTicker 把回调节流到 10% 阶梯打印,
  避免几百行刷屏(NSLock 保护单 Int 状态,@unchecked Sendable)
- BuildProject 通过

Plan 进度已勾选(§5 Phase 1.14.c + §8)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 02:46:11 +08:00
joywayer 0887088bb8 Phase 1.13:LobbyZipUpgrader 完成 H5 zip 升级端到端链路
Phase 1 远程配置链路(1.10-1.13)最后一环:把 VersionResolver 解出的
gameZip URL 下载 + 解压 + 原子 rename 到 lobbyRoot,触发条件是远端
gameVersion > 本地 version.xml /game/version@value。

- 新增 ylgamehall/Source/Resource/LobbyZipUpgrader.swift
  - public actor,单例 LobbyZipUpgrader.shared
  - upgradeIfNeeded(resolved:) async throws -> Outcome
    - .noop:远端版本 ≤ 本地,或 gameZip nil / 非法 URL,直接返回
    - .upgraded(from:to:):完成升级,附前后版本号
  - URLSession.download(from:) 异步下载(60s 请求 / 300s 资源超时),
    HTTP 状态码非 2xx 抛 .httpStatus
  - 解压到 caches/lobby-staging-{UUID}/ 隔离目录(不直接覆盖 lobbyRoot,
    msext "removeItem + ZipArchive overWrite:YES" 半途崩溃会留半残;
    我们 staging 完整解压成功后才 rename)
  - 原子 rename:fm.removeItem(lobbyRoot) → fm.moveItem(staging,
    to: lobbyRoot);任一步失败抛 .stagingMoveFailed,调用方决定回退
  - 类型化 UpgradeError 5 种:badGameZipURL / downloadFailed /
    httpStatus / unzipFailed / stagingMoveFailed
  - 实现照搬 msext NewRootVC.m:1789-1869 downFileFromServer + unzip
    + unzipDone 的语义,但用现代 async + 原子 rename 替代 ASIHTTPRequest
    + detachNewThreadSelector + ZipArchive overWrite:YES
- RootViewController 烟雾测试串接 LobbyZipUpgrader:
  调 upgradeIfNeeded → switch outcome → 升级后重新读 version.xml 验证
- 真机实测端到端链路:
  第一次:本地 260 vs 远端 261 → 升级完成 261,耗时 2.007s
    (下载 11.5 MB HTTP zip + ZIPFoundation 解压 + 原子 rename)
    重新读 version.xml = 261 ✓ lobbyIndex 仍存在 ✓
  第二次:本地 261 vs 远端 261 → .noop,耗时 0.001s(幂等验证)

Phase 1 远程配置链路(1.10-1.13)完整闭环。下一步 Phase 1.14
WebContainer 把这条链路插到 viewDidLoad 之前 + 16:9 letterbox 加载
lobbyIndex,第一次让真实大厅 H5 渲染出来。
2026-06-22 02:27:48 +08:00
joywayer 9e50c38323 Phase 1.12:LocalVersionReader 读 version.xml + ATS 全局放行
新增 LocalVersionReader 读两个本地版本号 + Info.plist 加
NSAllowsArbitraryLoads 全局放行 HTTP(为 Phase 1.13 zip 下载链路准备)。

- 新增 ylgamehall/Source/Resource/LocalVersionReader.swift
  - public enum LocalVersionReader,nonisolated static 命名空间
  - localAppVersion: Int 从 BundleConfig.shared.appVersion 转 Int
    (沿用 ChannelConfig.plist 注入),解析失败返回 0
  - localGameVersion: Int 用 Foundation XMLParser SAX 风格解析
    SandboxPaths.lobbyVersionXML 的 /game/version@value 节点,
    缺失 / 损坏返回 0(msext NewRootVC.m:664-682 `[nil intValue]=0`
    兜底语义,是首装后首次升级的关键机制)
  - subGameVersion(dir:start:) 子游戏版本号读取,Phase 6 用
  - 内部 VersionXMLHandler 标 nonisolated(Swift 6 严格并发下
    XMLParserDelegate 跨边界),SAX 风格只在 didStartElement 抓
    version 节点的 value 属性
- Info.plist 加 NSAppTransportSecurity / NSAllowsArbitraryLoads = true
  - 企业签 / 超签分发场景,后端域名易变(zip CDN / 配置服 /
    七牛 / 错误日志上报等),白名单维护成本高于安全收益
  - 全局放行的副作用主要影响第三方 SDK 流量;本项目无意外引入未审计
    SDK,且第三方 SDK 自身一般也走 HTTPS,不受影响
  - 详见 ADR-008 决策上下文与 CLAUDE.md「不上架 App Store,企业签 /
    超签 / TF 分发」契约
- RootViewController 烟雾测试串接 LocalVersionReader:打印本地 vs 远端
  对比 + IPA / zip 升级判定
- 真机实测(现网 .txt 本地 + 当前 IPA 内 gamehall.zip 解压物):
  - 本地 appVersion = 43, 远端 = 43 → 不升级
  - 本地 gameVersion = 260(IPA 内 2023-12 zip 的 version.xml),
    远端 = 261 → 需要 zip 升级。Phase 1.13 将实现下载 +
    解压 + 替换链路
2026-06-22 02:24:22 +08:00
joywayer 463f0fc6fe Phase 1.5:ResourceUnzipper 解压 gamehall.zip 到 Caches 全链路打通
启动期完成「ChannelConfig.plist 读 → SandboxPaths 算路径 →
gamehall.zip 解压」三步,lobbyIndex 真实存在于沙盒 Caches,
为 Phase 1.10 WebContainer file:// 加载就绪。

- 新增 ylgamehall/Source/Resource/ResourceUnzipper.swift
  - public actor,全局单例 ResourceUnzipper.shared
  - ensureReady() async throws:检查 lobbyVersionXML 不存在则调
    FileManager.unzipItem (ZIPFoundation 扩展) 解压 Bundle 内
    gamehall.zip 到 SandboxPaths.lobbyRoot
  - UnzipError 类型化错误(bundleZipNotFound / unzipFailed)
  - 幂等:已就绪直接 return,并发调用安全(actor 串行)
- 新增 ylgamehall/Resources/gamehall.zip:从 docs/res 拷一份入
  Bundle(11.5 MB,2023-12 旧版;上线前由 H5 团队替换最新版,
  ADR-002 已记录)
- Swift 6 严格并发适配:
  - SandboxPaths:所有 static 成员标 nonisolated(无状态命名空间,
    从 actor / 非 MainActor 上下文都能直接读 caches/documents/bundle
    及 lobby 路径计算)
  - BundleConfig:class 整体 nonisolated(Sendable + all-let 属性,
    跨 actor 边界安全),允许 ResourceUnzipper actor 读 channel 配置
- RootViewController 补 ResourceUnzipper 烟雾测试 Task:
  await ensureReady() 后打印耗时 + lobbyIndex 是否真实存在
- 真机验证(iOS Simulator):首次 1.266s 解压成功,二次启动 0s 跳过
2026-06-22 00:01:08 +08:00
joywayer 730574a2e2 Phase 1.3:SandboxPaths 统一沙盒 / Bundle / H5 路径入口
集中管理 NSSearchPathFor... 调用,避免散落,方便后续 ResourceUnzipper
(Phase 1.5)与 WebView 加载(Phase 1.10)复用。

- 新增 ylgamehall/Source/Resource/SandboxPaths.swift
  - 基础:caches / documents / bundle
  - 大厅 H5:lobbyRoot / lobbyIndex / lobbyVersionXML(基于 BundleConfig
    当前渠道值动态拼接)
  - 子游戏 H5:subGameRoot(_:) / subGameIndex(_:_:)(Phase 6 调用,
    入参传 SwitchOverGameData 解出的 dir/start)
  - 全部 enum + static,无状态,Swift 6 严格并发零负担
- RootViewController.viewDidLoad 补 SandboxPaths 烟雾打印 6 项路径,
  便于首次启动 console 校验:
  - lobbyIndex 应为 {Caches}/FtJf073.../gamehall/index.html
  - lobbyVersionXML 应为 {Caches}/FtJf073.../gamehall/version.xml
- BuildProject 通过,synchronized group 自动收编新文件
2026-06-21 23:52:23 +08:00
joywayer dcc12bb76d Phase 1.2:BundleConfig 读 ChannelConfig.plist 完成 11 项渠道注入加载
- 新增 ylgamehall/Source/Resource/BundleConfig.swift
  - 11 个 public let 属性(qiniuDomain / gameId / channel / gameDir
    / gameStart / gameConfig / market / agent / appVersion / other
    / appleConfig)
  - public init(bundle: Bundle = .main) —— 默认走 main bundle,单测
    可注入 mock bundle 验证不同 plist fixture
  - PropertyListSerialization 反序列化 ChannelConfig.plist
  - final class + Sendable(all-let,String 是 Sendable,Swift 6
    严格并发下编译器静态校验通过)
  - 单例 BundleConfig.shared,业务统一通过此访问
- RootViewController.viewDidLoad 加烟雾测试 print 11 项值,首次运行
  真机/模拟器即可在 console 验证读取正确
- BuildProject 通过,synchronized group 自动收编 BundleConfig.swift
  为 source(.swift 文件类型走 SwiftCompile,不再卡 plist 那样的
  resource 扁平化问题)
- 单元测试待 Phase 0.2 单测 target 建立后补齐
2026-06-21 23:47:23 +08:00