# 进贤聚友棋牌 iOS 新外壳开发计划

> 配套文档：
> - `docs/H5-Native-Implementation-Design.md` — 架构蓝图（**WHAT to build / HOW to design**）
> - `docs/H5-Native-Contract.md` — H5↔原生契约（**外部可观察行为，验收以此为准**）
>
> 本文档：执行图（**WHEN / IN WHAT ORDER / ACCEPTANCE CRITERIA**），随实施持续更新。
>
> 起点：M0 部分完成（项目基础配置就绪）；终点：通过契约 §10 全部 26 项验收清单后灰度上线。

---

## 1. 文档三角关系

```
Contract       Design          Plan（本文档）
  ↓              ↓                ↓
契约边界       架构蓝图           执行路线
"必须长这样"   "推荐怎么做"      "按这个顺序做"

不可越界       可自由重构        随进度更新
```

- **Contract 与 Design 是稳定文档**，除非契约或架构本身要变更，否则不动
- **Plan 是活文档**，每完成一个 Phase 就在 §8 进度追踪里勾掉、把后续 Phase 调整为新现实
- **冲突时优先级**：Contract > Design > Plan（不能为了赶进度违背契约）

---

## 2. 起点：当前项目状态快照（2026-06-21）

### 2.1 已完成

- [x] Xcode 工程骨架（File System Synchronized Group，objectVersion 77，Xcode 26.5）
- [x] iOS Deployment Target 15.6
- [x] Swift 6.0 + `SWIFT_APPROACHABLE_CONCURRENCY=YES` + `SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor`
- [x] iPhone + iPad 仅横屏，`UIRequiresFullScreen=true`
- [x] 删除默认 Storyboard，改用 `SceneDelegate` 代码启动 → `RootViewController`（M0 占位）
- [x] Info.plist 契约前置项：`UIStatusBarHidden=NO` / `UIViewControllerBasedStatusBarAppearance=YES`
- [x] `AppDelegate` 设置 `applicationSupportsShakeToEdit=true`
- [x] `.gitignore`（标准 iOS/Swift 模板，含签名 / 敏感配置 / xcuserdata）
- [x] CLAUDE.md 加入"及时提交"规则
- [x] BuildProject 验证通过

### 2.2 已就位的原始素材

项目维护者的私人素材池 `docs/res/` 已包含以下可按需取用的素材（详见 CLAUDE.md「docs/res/ 与项目资源的关系」与 Design §7.0）：

- `docs/res/gamehall.zip`（2023-12 旧版，11.5 MB；用时拷贝到 `ylgamehall/Resources/`）
- `docs/res/Images.xcassets/`（AppIcon / LaunchImage / 分享平台图标；用时迁移 / 拷贝条目到 `ylgamehall/Assets.xcassets/`）
- `docs/res/Res/`（`sharelogo.png` / `shake_sound_male.mp3` 等散落原生资源；用时拷贝到 `ylgamehall/Resources/Sounds/` 等子目录）

> `docs/res/` 不进 Bundle 也不被工程引用。Phase 1 / 3 / 4 等实施时按需从中拷贝到 `ylgamehall/Resources/` 或 `ylgamehall/Assets.xcassets/`。

→ Phase 1 不再阻塞于 H5 团队（gamehall.zip 旧版可用于打通管道），可立即展开。

### 2.3 待项目方协调的外部资源（阻塞项）

| 资源 | 用途 | 阻塞起始 Phase | 协调对象 |
|------|------|--------------|---------|
| `gamehall.zip` **最新版**（上线前替换） | 真实 H5 业务代码 | Phase 10 灰度前 | H5/前端团队 |
| 11 个渠道注入目录的目录名值 | 渠道 / 游戏 ID / 七牛域名 / market 等 | Phase 1 | 项目方/运营 |
| 微信 OpenSDK `.framework` | 登录 / 分享 | Phase 4 | 项目方（可从 msext/Vendor 拷贝） |
| 微信 AppID（`wx586a9b321e56efb7`）+ Universal Links 配置 | 微信回调 | Phase 4 | 项目方 |
| ~~QQ OpenSDK `.framework`~~ | QQ 分享 | **不需要 SDK** — msext `QQShareManager.m:14` `__has_include` fallback 路径已有完整 URL Scheme 实现（`mqqapi://share/to_fri?...`），新外壳直接照搬即可。Info.plist 仅需加 `LSApplicationQueriesSchemes`（`mqq` / `mqqapi` / `mqqopensdkfriend` 等） | — |
| ~~抖音 OpenSDK `.framework`~~ | 抖音分享 | **不需要 SDK** — msext `DouyinShareManager.m` 全程 URL Scheme（`snssdk1128://share/video` / `snssdk1128://camera`）+ Photos 框架（保存图片到相册让用户在抖音里选）。Info.plist 仅需加 `LSApplicationQueriesSchemes`（`snssdk1128`）+ `NSPhotoLibraryAddUsageDescription` 写入相册权限文案 | — |
| 后台 `/wechat/login` 中转接口 | secret 不入 IPA 的前提 | Phase 4 | 后台团队 |
| 高德地图 `APIKey` + SDK XCFramework | 定位 | Phase 5 | 已有 key `b0d4a8e3fcbbcc0dd96283b7df6a4494`（但绑死 msext Bundle ID，需用新 Bundle ID 重新申请）；SDK 走 Vendor 手动接入（ADR-006），下载页 https://lbs.amap.com/api/ios-location-sdk/download |
| 七牛上传 token 颁发接口 | 录音上传 | Phase 3 | 后台团队 |
| ~~极光 AppKey~~ | ~~用户统计~~ | **已决策暂不集成（ADR-005），未来启用时再协调** | — |
| Sentry DSN | 崩溃监控 | Phase 9 | 项目方（需开通账号） |
| Agora AppID（视频房间） | 子游戏视频 | Phase 8（默认 stub，可延后） | 项目方 |
| 闲聊 SDK | 闲聊分享 | Phase 9（默认 stub） | 项目方 |
| 开发 / 企业签名证书 + 描述文件 | 真机调试 / 分发 | Phase 1 起持续 | 项目方/iOS 账号管理员 |

> **协调原则**：当某 Phase 阻塞在外部资源时，**先做不依赖该资源的 Phase**（并行机会见 §3 依赖图）。

### 2.4 已确定的项目约束

| 项 | 值 |
|---|---|
| H5 设计分辨率 | **1280 × 720（16:9）** |
| WebView 适配策略 | 保持 16:9 比例（设备比例不匹配时上下/左右 letterbox） |
| Bundle ID | `com.skyapp.ylgamehall`（与 msext 分离） |
| Team ID | NX5W3B4QP3 |
| 设备 | iPhone + iPad（不去 iPad） |
| 支持方向 | 仅横屏（LandscapeLeft / LandscapeRight） |
| Swift 版本 | 6.0 |
| 最低 iOS | 15.6 |

---

## 3. 全局依赖图

```
┌─────────────────────────────────────────────────────────────┐
│ Phase 0  工程化基线（剩余项：SwiftLint / 测试 target / CI） │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 1  资源层 + 桥核心 + 第一个 handler                    │
│   ResourceKit / BundleConfig / BridgeCore / BridgedWebView   │
│   验收：H5 加载 + 一个简单 handler 双向通                    │
└─────────────────────────────────────────────────────────────┘
                            │
              ┌─────────────┼─────────────┐
              ▼             ▼             ▼
        ┌──────────┐  ┌──────────┐  ┌──────────┐
        │ Phase 2  │  │ Phase 3  │  │ Phase 5  │
        │ 简单     │  │ 音频体系 │  │ 定位      │
        │ handler  │  │          │  │          │
        │ + 反向cb │  │          │  │          │
        └────┬─────┘  └────┬─────┘  └────┬─────┘
             │             │             │
             └─────────────┴─────────────┘
                           │  并行可行
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 4  分享 + 登录（前置：项目方提供 SDK / 后台接口）        │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 6  子游戏容器（SwitchOverGameData / backgameData）      │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 7  弹层（Overlay + window.settings polyfill）          │
│   完成后契约 §10 A/B/C/E 节可全跑通                          │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 8  视频房间（默认 NoopVideoRoom；Agora 启用是开关）     │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 9  SDK 真实化（Sentry / 极光）+ Stub 替换为真实 SDK     │
└─────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 10  多渠道打包 + 真机回归 + 灰度切流                    │
└─────────────────────────────────────────────────────────────┘
```

**关键并行机会**：Phase 2 / 3 / 5 互不依赖，可按当下手头的资源到位情况自由调度。

---

## 4. Phase 总览

| Phase | 目标 | 关键产出 | 验收来源 | 相对成本 |
|-------|------|---------|---------|---------|
| **P0** | 工程化基线 | SwiftLint、单测 target、CI 流水线 | 本文档 §5.0 | 小 |
| **P1** | 最小垂直闭环 | `BundleConfig` / `ResourceUnzipper` / `BridgeCore` / `BridgedWebView` + `vibrator` handler | 契约 §10 中 `vibrator` 项；H5 file:// 加载成功 | 大 |
| **P2** | 大厅简单 handler + 反向 callback | `DeviceKit` / `Pasteboard` / 振动 / 摇一摇 / 网络 / 电池 / 前后台 | 契约 §10 B 节 + C 节 | 中 |
| **P3** | 音频体系 | `AudioKit`（播放 / 录音 / AMR↔WAV / 七牛上传） | 契约 §10 中 `srcIsloop` / `mediaTypeAudio` / `prepareaudio` 项 | 大 |
| **P4** | 分享 + 登录 | `LoginKit`（WeChat OAuth）/ `ShareKit`（WeChat + Noop 闲聊） | 契约 §10 B 节 `accreditlogin` / `friendsShare...` 项 | 大 |
| **P5** | 定位 | `LocationKit`（高德 SDK 封装） | 契约 §10 B 节 `startlocation` 项 | 中 |
| **P6** | 子游戏容器 | `SubGameViewController` + `Coordinator` + `SwitchOverGameData` / `backgameData` | 契约 §10 中 `SwitchOverGameData` 项 | 中 |
| **P7** | 弹层 | `OverlayViewController` + `window.settings` polyfill + 节流 | 契约 §10 中 `OpenurlTitleData` / `settings.*` 项 | 中 |
| **P8** | 视频房间 stub | 3 个 stub handler + Native→H5 `getVideoinfo` / `phonestate` / `recordSuccess` callback | 契约 §10 D 节（NoopVideoRoom 路径） | 小 |
| **P9** | SDK 真实化 + 监控 | Sentry / 七牛 SPM 接入；极光 / 闲聊 / Bugly 决策落地为 Noop / 不集成 | 契约 §10 全 26 项 | 中 |
| **P10** | 多渠道 + 灰度 | 渠道注入脚本 / CI 多渠道出包 / 真机回归 / 灰度切流 | 真机验收 + 灰度无回退 | 大 |

**相对成本说明**：
- 小：1 个工作日内可独立完成
- 中：2–4 个工作日，含联调
- 大：5–10 个工作日，跨多个模块或需对外联调

---

## 5. 各 Phase 详细计划

### Phase 0: 工程化基线（剩余项）

#### 目标
建立项目级别的代码质量与回归基线，使后续 Phase 每个 commit 都可被自动验证。

#### 前置
无。当前 M0 基础配置已完成。

#### 任务清单

- [ ] **0.1** 加入 SwiftLint（SPM 插件方式，避免全局安装依赖）
  - `Package.swift` 加 `SwiftLintPlugin`；规则文件 `.swiftlint.yml` 按 Design §13.4 配（行长 120、文件 ≤400、函数 ≤40）
  - 验收：`xcodebuild` 时 lint 自动跑，违规 warning 出现
- [ ] **0.2** 新建 `ylgamehallTests` 单测 target（Testing framework，不引 XCTest 旧 API）
  - 第一个测试 `BootstrapTests.testAppDelegateRespondsToShake`，确认 `applicationSupportsShakeToEdit=true` 已生效
  - 验收：`xcodebuild test` 通过
- [ ] **0.3** 新建 `ylgamehallContractTests` 契约测试 target（与 unit test 分离，跑得久也无所谓）
  - 暂只放空骨架，Phase 1 起逐项填入
- [ ] **0.4** 新建 `ylgamehallUITests` UI 测试 target（XCUI）
  - 暂只放空骨架，Phase 10 真机回归用
- [ ] **0.5** 加 CI 配置（GitHub Actions 或本地 Jenkinsfile，按项目方实际 CI 平台）
  - 跑：`xcodebuild test -scheme ylgamehall` + lint
  - 验收：push 后 CI 绿
- [ ] **0.6** 准备 SPM 包目录结构（**先建空 `Package.swift` 但暂不切 target**）
  - 决策：先单 target 内按目录组织，等 P3 之后代码量 > 5K 行再切 SPM
  - 这样早期开发不被 SPM 切分成本拖慢

#### 验收

- `xcodebuild test` 通过；CI 绿；SwiftLint 报告 0 违规

#### 风险

- 单测 / UI 测 target 的 signing 需配置（自动签 + 不同 Bundle ID 后缀）

---

### Phase 1: 最小垂直闭环（资源 + 桥 + 远程配置 + 第一个 handler）

#### 目标
打通**从启动 → 渠道注入读取 → 远程配置拉取 → 版本对比 → zip 升级（若需要）→ 加载 H5 → JS 与原生互调**的全链路，用最简单的 handler `vibrator` 作为验证标的。

> **Phase 1 范围扩展（ADR-008）**：原 Plan 漏掉了"远程配置 + 版本对比 + zip 升级"这一关键环节——若不做，上线后用户永远看不到 H5 团队的最新版本，永远停留在 IPA 内打包那一刻的 H5 旧版。2026-06-22 调研 msext `NewRootVC.m` 发现完整流程后插入新子项 1.10-1.13，原 1.10-1.13 后移为 1.14-1.17。

#### 前置
- ✅ Phase 0 基线
- ✅ `docs/res/gamehall.zip`（2023-12 旧版，足以验收 Phase 1；实施时拷贝到 `ylgamehall/Resources/gamehall.zip`）
- ⏳ 项目方提供 11 个渠道注入目录名值（缺则用临时 demo 值，含可访问的远端 `gameconfig`.txt）
- 备选：若 zip 加载有问题，临时用一个最小 H5（`<html>` 含一个 button 调 `bridge.callHandler('vibrator')`）做工程内测排查

#### 任务清单

##### 1.A 资源层

- [x] **1.1** 创建 `ylgamehall/Resources/ChannelConfig.plist` 含 11 个渠道键值对（沿用 msext 现网 demo 作为母包默认值；ADR-007 决策）
- [x] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift`
  - `init(bundle: Bundle = .main)`：读 `bundle.url(forResource: "ChannelConfig", withExtension: "plist")`，用 `PropertyListSerialization` 反序列化为 `[String: String]`
  - 公开 10 个只读属性：`gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig`（`qiniuDomain` 已于 2026-06-27 移除，七牛 CDN 改 RemoteConfig.audio_domain 远端注入，详见 ADR-009）
  - 单测 fixture：建立 mock bundle 含 fixture plist，验证 10 个 key 全部能读出且缺失 key 返回空串
- [x] **1.3** 实现 `ylgamehall/Source/Resource/SandboxPaths.swift`
  - 常量：`caches` / `documents` / `bundle`
  - `lobbyIndex() -> URL` 拼出 `{Caches}/{gamedir}/{gamestart}/index.html`
- [x] **1.4** 加 ZIPFoundation SPM 依赖
- [x] **1.5** 实现 `ylgamehall/Source/Resource/ResourceUnzipper.swift`（actor）
  - 从 `docs/res/gamehall.zip` 拷贝一份到 `ylgamehall/Resources/gamehall.zip`
  - `ensureReady() async throws`：检测 `version.xml` 不存在则解压 Bundle 内 `gamehall.zip` 到 caches
  - 单测：用 fixture zip 验证解压幂等性

##### 1.B 桥核心

- [x] **1.6** 实现 `Source/Bridge/BridgeProtocol.swift`
  - `BridgeProtocol` / `BridgeData` / `BridgeHandler` / `BridgeCallback` 类型
- [x] **1.7** 实现 `Source/Bridge/BridgeBus.swift`（@MainActor）
  - `register(_:handler:)` / `call(_:data:callback:)` / `didReceive(_:)`
  - 单测：mock WKWebView，验证 handler 注册 / 分发 / callback 配对
- [x] **1.8** 引入 WVJB JS 端协议源码
  - 从 [marcuswestin/WebViewJavascriptBridge](https://github.com/marcuswestin/WebViewJavascriptBridge) 取 `WebViewJavascriptBridge.js.txt`，作为 `Resources/JS/WebViewJavascriptBridge.js`
  - 注入方式：`WKUserScript`，`atDocumentStart`

##### 1.C WebView 容器（视图层 + 一个 handler）

- [x] **1.9** 实现 `Source/WebView/BridgedWebView.swift`
  - WKWebView 配置照 Contract §4.1（`javaScriptCanOpenWindowsAutomatically=NO`、`minimumFontSize=10`、`bounces=NO`、`scrollEnabled=NO` 等）
  - `WKProcessPool` 单例 `SharedProcessPool.shared`

##### 1.D 远程配置 + 版本对比 + zip 升级（**Phase 1 关键缺口，ADR-008**）

- [x] **1.10** 实现 `ylgamehall/Source/Network/RemoteConfigClient.swift`（actor）
  - URL 构造：`http://` + `BundleConfig.shared.gameConfig.replacingOccurrences("-", "/")` + `.txt`（**注意**：不带 `SERVERNew` 前缀，原 `daoqi/NewRootVC.m:250` 就是这样构造）
  - 拉远端 `.txt` 内容（实际是 JSON）→ `URLSession.async data(from:)`，10 s 超时
  - 指数退避重试（msext 用 4s 等间隔暴力 timer，新外壳用 1s / 2s / 4s 三次）
  - 反 JSON → 强类型 `RemoteConfig` Codable 模型（嵌套：`agentlist[].channellist[].marketlist[]` + `agentlist[].gamelist[].channellist[].marketlist[]`）
  - 单测：mock URLSession + fixture JSON 验证嵌套解析正确
- [x] **1.11** 实现 `ylgamehall/Source/Network/VersionResolver.swift`（纯函数 + 单测）
  - 输入：`RemoteConfig + (agentId, channelId, marketId, gameId)` 四组 ID
  - 输出：`ResolvedVersion { appVersion, appDownload, gameVersion, gameZip }`
  - 算法：4 级覆盖合并 —— 顶层 → 当前 agent → 当前 channel → 当前 market（最深胜出）；agent 子树和 game 子树分别合并，game 子树最终覆盖 agent 子树
  - 参考 `daoqi/NewRootVC.m:1372-1492` 的散落 if/else，新外壳实现成纯 reduce
  - 单测：覆盖所有边界（顶层有/无、深层缺失、agent vs game 优先级 4 组场景至少 12 用例）
- [x] **1.12** 实现 `ylgamehall/Source/Resource/LocalVersionReader.swift`（nonisolated namespace）
  - `localAppVersion: Int`：从 `BundleConfig.shared.appVersion` 读，转 Int
  - `localGameVersion: Int`：读 `Library/Caches/{gamedir}/{gamestart}/version.xml` 用 `XMLParser` 解析 `/game/version@value` 转 Int；缺失返回 0
  - 单测：fixture xml 验证解析；缺失 / 损坏 xml 返回 0
- [x] **1.13** 实现 `ylgamehall/Source/Resource/LobbyZipUpgrader.swift`（actor）
  - `upgradeIfNeeded(remote: ResolvedVersion) async throws -> UpgradeOutcome`
    - 比较 `remote.gameVersion > LocalVersionReader.localGameVersion` → 触发下载，否则直接返回 `.noop`
  - 下载链路：`URLSession.download(from:)` → 临时文件 `tmp/lobbyzip-{uuid}.zip` → ZIPFoundation 解压到 `tmp/lobbyzip-{uuid}/` → **原子 rename** 到 `lobbyRoot`（解压前清空旧 lobbyRoot；不学 msext "目录名 +1" hack）
  - 失败处理：超时 / 网络断 / hash 校验失败 / 解压失败 → throw 类型化错误；调用方决定是否回退用旧 H5
  - 单测：mock URLSession + fixture zip 验证升级路径与 noop 路径

##### 1.E 集成 + 烟雾测试

- [x] **1.14.a** AppIcon：从 `docs/res/Images.xcassets/AppIcon-1.appiconset` 拷贝 9 个 png + Contents.json 到 `ylgamehall/Assets.xcassets/AppIcon.appiconset`，替换 Xcode 默认空模板。已实测 BuildProject 通过。actool 3 个 warning（iPad 76 / 83.5 / 1024 缺失）暂不处理，iPad 会自动用 iPhone 缩放，1024 是 App Store 上架要求（企业签不强制）；待 Phase 10 上线前 polish 时补齐
- [x] **1.14.b** LaunchScreen：源素材 `docs/res/Res/Default-568h@2x~iphone.png` 是 CgBI PNG，物理 640×1136 但画面内容是"横躺"在竖向容器里（msext 旧 LaunchImage 系统依赖 device orientation 自动旋转）；现代 LaunchScreen.storyboard 没有这个魔法，直接显示会看到躺倒画面。处理：用 `sips -r -90` 一次性把素材逆时针旋转 90° 输出为标准 PNG（去 CgBI），物理像素 1136×640，存到 `ylgamehall/Assets.xcassets/SplashImage.imageset/SplashImage@2x.png`（universal idiom）。storyboard 用单一 UIImageView 全屏铺满，`contentMode = scaleAspectFit`，背景 `.black`，四边约束到 superview。aspectFit 在比 16:9 更宽的现代横屏 iPhone 上左右补黑边（黑底无违和），保画面完整不裁切 logo。WebContainer 的 SplashOverlay 用同款 contentMode，避免启动 → 容器视觉过渡时跳变。BuildProject 通过
- [x] **1.14.c** `LobbyZipUpgrader` 加 progress 回调
  - `upgradeIfNeeded(resolved:onProgress:) async throws -> Outcome`，`onProgress: @escaping @Sendable (Double) -> Void` 默认 no-op 报告 0.0…1.0
  - 文件内私有 `DownloadProgressDelegate: URLSessionDownloadDelegate, @unchecked Sendable`，实现 `didWriteData:totalBytesWritten:totalBytesExpectedToWrite:`；通过 `session.download(from:delegate:)` 注入
  - 完成时兜底报 1.0（部分 server 不发 Content-Length 或末尾片段晚到，避免进度条停在 99%）
  - RootViewController 烟雾测试用 `ProgressTicker` 节流到 10% 阶梯打印；BuildProject 通过
- [x] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`（基类）
  - 持有 `BridgedWebView`（通过其再持有 `BridgeBus`）+ 文件内私有 `SplashOverlay`
  - 16:9 letterbox：aspectRatio(16:9) + widthMax/heightMax required + widthFill/heightFill .defaultLow + centerXY 居中。屏幕比 < 16:9 → 上下黑边；> 16:9 → 左右黑边
  - **SplashOverlay**：UIImageView(SplashImage, scaleAspectFill) + UIProgressView + UILabel；`update(text:progress:)` 切换状态机
  - 启动流水线 `runBootPipeline()`：ensureReady → "拉取配置中..." → RemoteConfigClient.fetch → 分支（.shortText / .parsed → showmessage / IPA 升级 / LobbyZipUpgrader.upgradeIfNeeded with onProgress hop 到 MainActor）→ "加载大厅..." → `loadFileURL(SandboxPaths.lobbyIndex, allowingReadAccessTo: lobbyRoot)`
  - 终态弹窗：`showBlockingAlert`（短文本 / showmessage，永停）/ `showIPAUpgradeAlert`（确定 → openURL，永停）/ `showFatalAlert`（重试 → 重跑 pipeline）
  - 横屏锁定 + statusBar 显示，与契约 §4.2 一致
  - **暂不实现**：`webViewWebContentProcessDidTerminate:` 退避 reload（移到 Phase 3 网络层时一并处理）
  - BuildProject 通过
- [x] **1.14.e** WebView 加载完成后淡出 splash
  - `webView(_:didFinish:)` 内 `UIView.animate(withDuration: 0.3)` splash.alpha = 0 →  completion 内 `splash.removeFromSuperview()`
  - 期间 webView 已渲染好 H5 大厅，无白屏闪烁；splash 出 view 层级避免空持有
  - BuildProject 通过
- [x] **1.15** 实现 `Source/Bridge/Handlers/VibratorHandler.swift`
  - 无状态 `public enum VibratorHandler`，`static func register(on bridge: any BridgeProtocol)`
  - 入参忽略（参考 msext `RootVC.m:1816-1818`：`time` 参数实际不读取）
  - 副作用：`AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`；responseCallback：`.string("vibrator")`
  - `WebContainerViewController.viewDidLoad` 新增 `registerBridgeHandlers()` 单一聚合点，Phase 2+ 在此追加。BuildProject 通过
- [x] **1.16** `SceneDelegate` → `WebContainerViewController` 作 rootViewController
  - 替换 M0 占位 `RootViewController` + 删除 `ylgamehall/RootViewController.swift`（M0 烟雾测试堆栈整体退役，所有 1.2–1.13 print 验证记录在 git log，留着是死代码）
  - File System Synchronized Group 自动适应文件删除，无需改 pbxproj
  - BuildProject 通过
- [ ] **1.17** 把渠道目录里临时填入的 demo 值 + 一个 demo H5 跑通 / 真机验证完整 1.10-1.16 链路

##### 1.F WKURLSchemeHandler 迁移（Cocos 适配前置 + 跨页 localStorage 共享，ADR-010）

> Phase 1.F 是对 Phase 1.14.d / Phase 6.x `loadFileURL` 路径的**架构级替换**——把大厅 + 所有子游戏迁到 `ylgame://h5/...` 自定义 scheme，由 `AppSchemeHandler`（`WKURLSchemeHandler`）反向映射回沙盒。详见 ADR-010。三条触发动因：① 为未来 Cocos 子游戏 H5 build 适配铺路（file:// 下 Cocos `XHR/fetch` 受 null-origin 限制几乎必踩坑）② 大厅 + 子游戏共用单虚拟 host (`ylgame://h5`) → same-origin → **跨 H5 共享 localStorage**（现状 file:// 下也是同 null-origin 共享，A 方案显式保留此语义）③ 给 `<audio>/<video>` 提供 Range 请求支持。**H5 零改动红线已确认满足**（用户已 grep 验证现网 H5 无 `location.protocol === 'http:'` / 硬编码 `file://` / Service Worker 依赖三类问题）。

- [x] **1.18** 实现 `Source/WebView/AppSchemeHandler.swift`
  - `AppScheme` enum 常量：scheme=`ylgame` / host=`h5` / lobbyPrefix=`lobby` / subGamePrefix=`subgame`，全部 `nonisolated`（项目 MainActor-by-default，未标会让 IO 队列无法访问）
  - `@MainActor` 单例 `AppSchemeHandler.shared`，实现 `WKURLSchemeHandler` 两方法：`start` / `stop`
  - 路由：`/lobby/<rest>` → `SandboxPaths.lobbyRoot/<rest>`；`/subgame/<dir>/<rest>` → `SandboxPaths.subGameRoot(<dir>)/<rest>`
  - 读盘走后台并发队列 `ylgamehall.app-scheme-handler.io`（`.userInitiated`），结果 hop 回 MainActor 调 `didReceive/didFinish/didFailWithError`
  - 取消语义：`stop` 加 key 到 `cancelledKeys` Set，背景 IO 完成后 deliver 见标志直接短路并清理；避免 stop 后向 task 发消息触发 NSException
  - Sendable 安全：闭包只捕获 Sendable 的 `key` (`ObjectIdentifier`) + `resolved`（`ResolvedRequest`），不捕获非 Sendable 的 `any WKURLSchemeTask`
  - Range 头解析：支持 `bytes=start-end` / `bytes=start-` / `bytes=-suffix`，返回 206 + `Content-Range`；不支持 multi-range（Cocos 不发）
  - MIME 表：覆盖 Cocos H5 常用扩展（html / js / css / json / atlas / fnt / plist / wasm / png / jpg / webp / svg / mp3 / ogg / wav / mp4 / ttf / woff / woff2 等），未命中 `application/octet-stream`
  - 响应头：`Cache-Control: no-cache`（沙盒升级会原地覆盖文件，禁强缓存）+ `Access-Control-Allow-Origin: *`（防御性，部分 Cocos build fetch 默认 cors mode 会走 preflight）
  - BuildProject 通过
- [x] **1.19** 给 `Source/Resource/SandboxPaths.swift` 加 app:// URL builders
  - `lobbyIndexAppURL: URL` → `ylgame://h5/lobby/<gameStart>/index.html`
  - `subGameIndexAppURL(_ dir:, _ start:) -> URL` → `ylgame://h5/subgame/<dir>/<start>/index.html`
  - 用 `URLComponents` 组装避免手拼，`nonisolated` 与现有路径常量一致
  - BuildProject 通过
- [x] **1.20** 三处 WebView 加载入口切换 + scheme handler 注册
  - `BridgedWebView.init`：`configuration.setURLSchemeHandler(AppSchemeHandler.shared, forURLScheme: AppScheme.scheme)`（必须在 `WKWebView` init 之前注册）
  - `WebContainerViewController.runBootPipelineSteps` step 7：`loadFileURL(SandboxPaths.lobbyIndex, allowingReadAccessTo: lobbyRoot)` → `webView.load(URLRequest(url: SandboxPaths.lobbyIndexAppURL))`
  - `SubGameViewController.runBootPipelineSteps` step 3：同款切换到 `subGameIndexAppURL(effectiveGameDir, request.gameStart)`
  - `OverlayViewController` 不变（仍 `webView.load(URLRequest)` 加载 H5 给的外链 http/https URL）
  - BuildProject 通过
- [ ] **1.21** 真机验证 Phase 1.F 等价性（与 1.17 一起跑）
  - 大厅 H5 启动正常、不黑屏、splash 淡出时机不变
  - localStorage 跨大厅 ↔ 子游戏共享语义保持（手动验证：大厅写一项、push 子游戏读到同名 key）
  - `app_*.js` 同步 `<script src>` 引入正常（H5 顶层 `var x = app_xxx` 拿到实际渠道值，不是 H5 zip 内默认占位）
  - 升级路径：模拟远端 gameVersion +1 重启，handler 在 lobby zip 升级覆盖目录后立即读到新文件（`Cache-Control: no-cache` 起效）
  - 子游戏切换：H5 调 `SwitchOverGameData` push 子游戏 VC，子游戏 H5 加载正常，pop 回大厅 localStorage 数据保留
  - 备注：**老用户 file:// → ylgame:// 一次性 localStorage 清零**（origin 切换不可避免），已与项目方确认业务可接受、不做迁移补偿

#### 验收

- 真机启动后能看到 H5 demo 页（首次会先经历"远程配置拉取"流程）
- H5 按钮调 `bridge.callHandler('vibrator')` 后真机震动
- H5 收到 `responseCallback("vibrator")`
- 模拟 `game_version` 远端 +1，重启 App 后能看到 zip 重新下载 + 解压 + 加载新版本
- BuildProject 通过；契约测试 `VibratorContractTest` + 单测 `VersionResolverTests`、`RemoteConfigClientTests` 通过
- **Phase 1.F 追加**：H5 加载从 `file://` 迁到 `ylgame://h5/...`，大厅 + 子游戏 same-origin 跨页共享 localStorage，`<audio>` Range seek 正常

#### 风险

- WVJB JS 端协议握手时序对加载顺序敏感，必须用 `WKUserScript` 在 documentStart 注入
- iPad letterbox 实现细节：用 `aspectRatio` constraint vs 手算 frame；推荐用 Auto Layout `aspectRatio=16/9` + centerX/centerY
- 远端 `.txt` 实际是 JSON 但被命名为 .txt（msext 历史包袱），解析时不要被 mime-type 误导
- 远端配置 4 级覆盖逻辑历史代码 `daoqi/NewRootVC.m:1372-1492` 有几处可疑分支（`if(gameconfig.game_download && gameconfig.game_download)` 重复条件 / `if(result)` 包住整段 unzip 在首次时不进入），新外壳重写时**不要照抄分支结构**，按 ADR-008 描述的 reduce 算法重新表达

---

### Phase 2: 大厅简单 handler + 反向 callback

#### 目标
把不依赖外部 SDK 的 11 个 handler 全部实现，使大厅 H5 在不接微信/七牛/高德的前提下能完整跑大部分业务。

#### 前置
- Phase 1 完成

#### 任务清单

##### 2.A H5→Native handler（无外部依赖）

- [x] **2.1** `gameCopytext` / `gamepastetext`（剪贴板）— `Source/Bridge/Handlers/ClipboardHandler.swift`，UIPasteboard.general
- [x] **2.2** `vibrator` / `repeatvibrator` / `canclevibrator`（VibratorHandler.swift 扩展 2 项）
- [x] **2.3** `startshake` / `stopshake` / `SwitchShake`（ShakeHandler.swift，canShake / canVoice 状态 + WebContainer motionEnded 转发 + shakeEnd 反向）
- [x] **2.4** `voicePlaying`（VoicePlayingHandler.swift，actor VoiceCenter.shared.isEnabled 开关位，Phase 3 mediaTypeAudio 读此）
- [x] **2.5** `getphoneInfo` → 触发反向 callback `getphoneinfo`（DeviceInfoHandler.swift，注意大小写：大写 I 入、小写 i 出；DeviceInfoSnapshot 6 字段，IDFA 用 idfv 兜底因为 CLAUDE.md 不接 ATT）
- [x] **2.6** `browser` → 系统 Safari 打开 URL（BrowserHandler.swift，UIApplication.open async）
- [x] **2.7** `opensaoma` 空实现注册（OpenSaomaHandler.swift，契约 §3.1【22】要求）
- [x] **Bonus** `startlocation` stub（StartLocationHandler.swift，让 H5 启动不报 "no handler"；Phase 5 接 LocationKit 完整实现）
- [x] **Bridge 重构** `BridgeData.asXxx` 全部加 `nonisolated`，让 handler 闭包（@Sendable async）可以自由访问数据值；契约不变

##### 2.B Native→H5 反向 callback

- [x] **2.8** `Source/Bridge/Handlers/DeviceInfoHandler.swift`：DeviceInfoSnapshot 6 字段（Phase 2.5 一并完成）
  - `getphoneInfo` 收到 → `bridge.call("getphoneinfo", data: 表 A 6 字段)` 反向
- [x] **2.9** `Source/Resource/BatteryMonitor.swift`：监听 `UIDevice.batteryLevelDidChangeNotification`
  - 触发 → 重写 `app_battery.js` + `bridge.call("getBattery", data: .string("%.2f", level))`
- [x] **2.10** `Source/Resource/NetworkMonitor.swift`：NWPathMonitor 包装（Phase 1 已建）
  - 状态变化 → 重写 `app_network.js` + `bridge.call("getnetwork", data: .string("1"/"2"/"3"))`
- [x] **2.11** `Source/Resource/AppLifecycleObserver.swift`：监听 `UIApplication.didEnterBackgroundNotification` / `willEnterForegroundNotification`
  - 后台 → `bridge.call("appservice", data: .string("2"))`；前台 → `"1"`（值错位沿用 msext WKWebView 路径历史，参 Contract.md §3.2 `appservice` 值错位说明）
- [x] **2.12** ShakeDetector 集成在 `Source/Bridge/Handlers/ShakeHandler.swift`（Phase 2.3 一并完成）
  - `WebContainerViewController.motionEnded(_:with:)` 转发 → `bridge.call("shakeEnd", data: nil)`（仅 `canShake=YES` 时触发）

##### 2.C 外部订阅生命周期

- [x] **2.13** 实现 Design §2.4.2 的外部订阅生命周期管理
  - `setupExternalSubscriptions()`（viewWillAppear 调）：幂等启动 3 个 monitor + 设 onChange 闭包（battery / network 走 evaluateJavaScript + bridge.call 双轨；appservice 仅 bridge.call）
  - `teardownExternalSubscriptions()`（viewWillDisappear 调）：解绑 onChange / on{Background,Foreground} = nil，释放 bridge/webView 引用避免子游戏 push 后双发
  - `writeAppDataFiles` 精简为只写首次值 + 启动 NetworkMonitor/BatteryMonitor（读 currentXxx 用），AppLifecycleObserver 启动挪到 setup
  - 单例 monitor 全局只有一个 closure 引用，未来 Phase 6 子游戏 setup 时会覆盖（大厅已 teardown 不会冲突，符合 §2.4.3 栈深 ≤ 2 约束）

#### 验收

- 契约 §10 B 节中以下项目通过：
  - `getphoneInfo` 6 字段完整
  - `gameCopytext` + `gamepastetext`
  - `vibrator` / `repeatvibrator`
- 契约 §10 C 节全部通过：
  - 前后台切换 `appservice("1"/"2")`
  - 电量变化 `getBattery("0.XX")`
  - 飞行模式切换 `getnetwork("1"/"2"/"3")`
  - 摇一摇 `shakeEnd`

#### 风险

- `motionEnded:` 需要 `become first responder`，在 `viewDidAppear` 内调用 `becomeFirstResponder()`
- iOS 14+ `WKWebView.scrollView.bounces` 在某些版本被重置，需 viewWillAppear 内重新设置
- `NWPathMonitor` 必须保持引用，否则会被释放停止监听

---

### Phase 3: 音频体系

#### 目标
打通本地音频播放 / 远程语音播放 / 录音 → AMR 转码 → 七牛上传的完整链路。

#### 前置
- Phase 1 完成
- ⏳ 项目方提供：opencore-amr 静态库（可从 msext 拷贝）+ 七牛上传 token 颁发接口

#### 任务清单

##### 3.A 本地音频

- [x] **3.1** `Source/Audio/AudioPlayer.swift`（@MainActor）
  - `playOnce(url)` 单次按钮音（msext `buttunPlayer` 等价，多次点击不互打断 — 用 buttonPlayers 数组保活池）
  - `loopBackground(url, type:)` 循环背景音（msext `backgroundPlayer numberOfLoops = -1`，记录 backgroundType）
  - `stopBackground(type:)` 停同名背景音（type 与当前 backgroundType 匹配才停）
  - `Source/Bridge/Handlers/LocalAudioHandler.swift` 注册 `srcIsloop` handler，按 isloop=0/1/-1 分支调用 AudioPlayer
  - 音频文件路径：`{Caches}/{gamedir}/{gamestart}/assets/wav/{src}`（H5 zip 包内自带）
  - `Source/Bridge/Handlers/RemoteAudioHandler.swift` 注册 `prepareaudio` / `mediaTypeAudio` stub（仅 cb 维持契约，等 Phase 3.B/3.C/3.D 升级）
- [ ] **3.2** 真机验证：H5 调 `srcIsloop({src:"xxx.wav", isloop:0/1/-1})` 听到对应音效 — 等 H5 团队提供测试音频后做（Verification-Checklist Phase 3 E.14-E.16）

##### 3.B AMR 转码

- [ ] **3.3** 从 msext 拷贝 `libopencore-amrnb.a` / `libopencore-amrwb.a`（已 segalign 8 修复版）到 `Vendor/`
- [ ] **3.4** 拷贝 `VoiceConverter` 头文件 + 实现 → 改写为 `Source/Audio/VoiceCoder.swift` Swift wrapper
  - `amrToWav(_:dest:)` / `wavToAmr(_:dest:)`
  - 单测：fixture amr → 转 wav → 转回 amr，比较前后 hash

##### 3.C 录音 + 上传

- [ ] **3.5** `Source/Audio/AudioRecorder.swift`（actor）
  - `record() async throws -> AudioFile`：`AVAudioRecorder` 录 WAV
  - 麦克风权限：Info.plist 加 `NSMicrophoneUsageDescription = "{gamehallname}需要访问您的麦克风录制语音消息"`
- [x] **3.6** 七牛 SPM 依赖（`https://github.com/qiniu/objc-sdk` v8.9.2，传递依赖 happy-dns 1.0.4 自动解析）+ 客户端自签 token 路径
  - `Source/Network/QiniuConfig.swift`：4 项常量（AccessKey / SecretKey / BucketName / cdnDomain）+ publicURL，沿用 msext，整体 nonisolated
  - `Source/Network/QiniuTokenSigner.swift`：CryptoKit HMAC-SHA1 + Base64URL 自签，等价 msext QiniuManager.m:200-230
  - `Source/Network/QiniuUploader.swift`：actor，`upload(_:timeSec:) async throws -> UploadedFile`，`QNUploadManager(configuration: defaultConfigurationV2)` 路径
  - 真机上传单测待 Phase 3.C 完成（依赖 AudioRecorder 录音输入）
- [ ] **3.7** `prepareaudio` handler：拉起录音 → 停止录音 → AMR 转码 → 上传 → 反向 callback `getaudiourl` + `recordSuccess`（仅子游戏触发）

##### 3.D 远程语音回放

- [ ] **3.8** `mediaTypeAudio` handler：下载 AMR → 转 WAV → `AVAudioPlayer` 播放
  - 开始播放 → `bridge.call("gameui_play_voice", user)`
  - 播放结束 → `bridge.call("gameui_stop_voice", user)`
  - 受 `voicePlaying` 开关控制（=1 才播）

#### 验收

- 契约 §10 B 节中：
  - `srcIsloop` 背景音循环 / 停止
  - `mediaTypeAudio` 远端播放 + play/stop callback
  - `prepareaudio` → 录音 → 上传 → `getaudiourl({audiourl, time})`

#### 风险

- 麦克风权限被拒后的 UI 兜底（H5 alert 提示，契约 §3.1【4】）
- 七牛 token 时效性：每次录音前后端动态颁发 vs 长 token 缓存
- AVAudioSession 与背景音 / 通话音的混音规则（`.playAndRecord` mode）

---

### Phase 4: 分享 + 登录

#### 目标
微信授权登录 + 微信好友/朋友圈分享 + 闲聊分享（Noop stub）。

#### 前置
- Phase 1 完成
- ⏳ 微信 OpenSDK `.framework` + AppID + Universal Links
- ✗ ~~QQ OpenSDK~~ **不需要**：URL Scheme（参 msext `QQShareManager.m:14` `__has_include` fallback）
- ✗ ~~抖音 OpenSDK~~ **不需要**：URL Scheme + Photos 框架（参 msext `DouyinShareManager.m` 全程无 SDK）
- ⏳ 后台 `/wechat/login` 接口（或先用客户端直拼 fallback）

#### 任务清单

##### 4.A SDK 接入

- [x] **4.1** `Vendor/WechatSDK/` 入库微信 SDK（`WechatOpenSDK-NoPay.xcframework` 2.0.5，入 git）
  - Info.plist URL Scheme `wx586a9b321e56efb7` + LSApplicationQueriesSchemes `weixin` / `weixinULAPI` / `weixinURLParamsAPI` 三项已就位
  - Universal Links / Associated Domains 暂不接入：企业签分发，不上 App Store，无审核合规需求；`WXApi.registerApp(_, universalLink: "")` 空串即可
- [x] **4.2** **SharePanel 三选一面板 + SharePlatform 框架**（4.B 完成）
  - `Source/Share/SharePlatform.swift`：SharePlatform 协议（name / isInstalled / share async）+ ShareContent struct（nonisolated Sendable）+ ShareResult enum（success / cancelled / notInstalled / failed）+ ShareScene enum（friend / timeline）
  - `Source/Share/SharePanel.swift`：底部弹出三按钮（微信 / QQ / 抖音）+ 半透明背景点击关闭 + 0.25s 滑入/滑出动画；与 msext SharePanel.m 等价行为
  - `Source/Share/ShareCenter.swift`：sharefriend == "1" 弹 SharePanel；"2" 直接 WechatShare.timeline；与 msext gameController.m:585 行为 1:1
  - `Source/Share/{Wechat,QQ,Douyin}Share.swift`：三个平台 stub（仅返回 success），Phase 4.C/4.D/4.E 升级
  - `FriendsShareHandler` 升级：调 ShareCenter.dispatch + 完成后触发 sharesuccess 反向 callback（success/cancelled 都发，notInstalled/failed 不发，沿用 msext 乐观策略）
  - BuildProject 通过
- [x] **4.3** **QQ 分享走 URL Scheme，不依赖 SDK**（4.C 完成）
  - 移植 msext `QQShareManager.m:716-771` `simpleShareToQQFriend` 简化版
  - URL 构造：`mqqapi://share/to_fri?` 或 `mqqapi://share/to_qzone?`（scene == .timeline 走 qzone）+ `version=1&cflag=0&req_type=1&url=...&title=...&description=...`
  - `encode` 与 msext line 1262-1268 等价：仅保留 RFC 3986 unreserved（alphanumeric + `-._~`）其它 percent encode
  - `canOpenURL` 检查 + `await UIApplication.open` 异步打开；调起即视为 success
  - **当前仅链接分享**（type == "1"）；type == "2" 截图 / 远端图 留 Phase 4.F
  - Info.plist 加 `LSApplicationQueriesSchemes`：`mqq` / `mqqapi` / `mqqopensdkfriend` / `mqqopensdkapiV2/V3/V4`
- [x] **4.4** **抖音分享走 URL Scheme + Photos，不依赖 SDK**（4.D 完成）
  - 新增 `Source/Share/ImageProvider.swift`：`captureScreenshot()` 截 keyWindow + `downloadRemote(_:)` 异步下载远端图（共用基础设施）
  - 新增 `Source/Share/PhotoLibrarySaver.swift`：保存到相册（PHAccessLevel.addOnly 权限，iOS 14+ 推荐）
  - `Source/Share/DouyinShare.swift` 升级移植 msext `DouyinShareManager.m:201-269` `shareImageToDouyin`：
    1. 拿图片源：type=2 / type=1 都退化为截屏（抖音不支持纯链接，与 msext 行为一致）；type=其它=远端图 URL → downloadRemote
    2. PhotoLibrarySaver.save 保存相册
    3. `UIPasteboard.general.image = image` 兜底
    4. 按 msext 顺序尝试 `snssdk1128://camera` / `publish` / `share/image` / 基础 scheme
    5. 调起即视为 success
  - Info.plist 加 `NSPhotoLibraryAddUsageDescription` 文案
  - BuildProject 通过
- [x] **4.3** `Source/SDK/WeChat/WeChatSDK.swift` 启动注册（`WXApi.registerApp("wx586a9b321e56efb7", universalLink: "")`；新 SDK 2.x 无 `MMAPP_SUPPORT_*` flag 概念，简单注册即可）
- [x] **4.4** `Source/SDK/WeChat/WeChatManager.swift`（@MainActor）
  - 持久 delegate（WXApiDelegate，onResp nonisolated 跳 MainActor）
  - `authorize() async throws -> WXAuthCodePayload`（state UUID 配对）
  - `shareLink(_:scene:) async throws`（FIFO 串行；type=2/3 截图/远端图待 4.11 / 4.12 Phase 4.F）
- [x] **4.5** `SceneDelegate.openURLContexts` 仅 `WXApi.handleOpen`（QQ 走 URL Scheme 不依赖 SDK，无 SDK 顺序问题）

##### 4.B 授权登录

- [x] **4.6** `Source/Login/WeChatAuth.swift`（客户端直拼 sns/oauth2/access_token + sns/userinfo）
  - 接受 msext 同款客户端持 secret 路径（父项目 CLAUDE.md 已识别但接受；后台无 `/wechat/login` 中转）
  - 7 字段 user 包含 `openid` / `headimgurl` / `nickname` / `sex` / `city` / `Province`（大写 P）/ `unionid`
- [x] **4.7** `accreditlogin` handler：触发 OAuth → 反向 callback `sharelogin`（7 字段，Province 大写 P）

##### 4.C 分享

- [x] **4.8** `Source/Share/SharePlatform.swift` 协议 + `WechatShare` / `QQShare` / `DouyinShare`（闲聊不接，参 ADR-006）
- [x] **4.9** `Source/Share/ShareCenter.swift`：sharefriend=1 弹 SharePanel；=2 直接微信朋友圈（msext gameController.m:585 等价）
- [x] **4.10** `FriendsShareHandler.swift` → ShareCenter.dispatch → 反向 callback `sharesuccess({success:"2", type:<原 sharefriend>})`
- [x] **4.11** 截图分享 type=2（commit 8e4c36d）：captureScreenshot → JPEG 0.6 + ImageProvider.scaledThumb 0.4x → WeChatManager.shareImage（WXImageObject + setThumbImage）
- [x] **4.12** 远程图片分享 type=其它（commit 8e4c36d）：downloadRemote → JPEG 0.9 + scaledThumb 0.4x → shareImage 同一路径

#### 验收

- 契约 §10 B 节：
  - `accreditlogin` 收到 7 字段 `sharelogin`（Province 大写）
  - `friendsSharetypeUrlToptitleDescript` type=1/2/3 各跑一次，收到 `sharesuccess`
- 契约 §10 E 节：微信 / QQ 回调命中（QQ 必须先判）

#### 风险

- 微信审核：Universal Links 配置错误 → 授权回不来
- AppSecret 客户端泄露 = 严重安全问题；fallback 路径上线前必须切到后台
- 截图分享在 Liquid Glass / 新版 UIKit 下 `drawHierarchy` API 行为变化，需查 DocumentationSearch 验证

---

### Phase 5: 定位

#### 目标
高德定位 + 逆地理 → 反向 callback `getlocationinfo` 9 字段。

#### 前置
- Phase 1 完成
- ⏳ 高德 SDK XCFramework（Vendor 手动，ADR-006）+ 新 Bundle ID 重新申请 APIKey

#### 任务清单

- [x] **5.1** 高德官方至今**不提供 xcframework**，回退为 fat `.framework` 入 `Vendor/AMap/`（AMapFoundationKit 1.9.0 + AMapLocationKit 2.12.0）；Target Do Not Embed（静态库）；M Mac 模拟器链接失败是接受的现状，开发期跑真机，详见 `Vendor/AMap/README.md` 「已知限制」
- [x] **5.2** Info.plist `NSLocationWhenInUseUsageDescription = "进贤聚友棋牌需要访问您的位置以提供本地化服务"`
- [x] **5.3** `Source/SDK/AMap/AMapWrapper.swift`：`AMapLocationManager.updatePrivacyShow` + `updatePrivacyAgree` + `AMapServices.shared().apiKey`（注意：隐私 API 在 AMapLocationManager 类方法上，不在 AMapServices）
- [x] **5.4** `Source/Location/LocationService.swift`（actor）
  - `requestOnce() async throws -> LocationPayload` ✅
  - `stop()` 清理钩子 ✅
  - 持续模式 `startContinuous(onUpdate:)` 未实现（msext 实际只有 H5 一次性 startlocation；如未来 H5 用 data=1 持续，再补）
- [x] **5.5** `startlocation` handler：单次定位 → 反向 callback `getlocationinfo` 9 字段
  - latitude/longitude 是 string（`String(format: "%f", ...)`）
  - province 是小写 p（与 sharelogin.Province 大写不同）
- [x] **5.6** 失败路径：`getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`

#### 验收

- 契约 §10 B 节 `startlocation` → 9 字段完整
- 拒绝权限后 H5 收到 errorCode 12

#### 风险

- 高德 Bundle ID 绑定：APIKey 是绑死 Bundle ID 的，必须用项目方控制台重新申请（不能复用 msext 的 key）

---

### Phase 6: 子游戏容器

#### 目标
大厅 push 子游戏，子游戏返回大厅，`getWebdata` 链路打通。

#### 前置
- Phase 1 完成（大厅可加载）
- Phase 2/3/4/5 不强制，但子游戏页同样需要 19 个共享 handler

#### 任务清单

- [x] **6.1** `Source/Coordinator/AppCoordinator.swift`：2s 节流（`lastSwitchAt`）+ 栈深约束（`viewControllers.count < 2`）+ `.subGameDidReturn` 通知名（Design §2.4.3）
- [x] **6.2** `Source/WebView/SubGameViewController.swift`：与大厅同款 BridgedWebView + Splash + 16:9 letterbox + ExternalSubscriptions；`AppDataWriter(containerRole: .subGame)` 写 4 个 app_*.js；H5 未安装则抛 `SubGameBootError.subGameNotInstalled`（待 6.7 接入下载）
- [x] **6.3** 子游戏 handler 注册（拆散到独立 enum，不抽 `SubGameHandlers` 聚合类）：
  - 14 个与大厅共享 handler 直接复用各自 enum.register
  - 新增 `Source/Bridge/Handlers/BackGameDataHandler.swift`，仅 SubGameViewController 注册
  - 新增 `Source/Bridge/Handlers/VideoRoomHandlers.swift` stub（createRoom / getVideoinfo / exitRoom）：
    业务暂未启用视频功能，仅维持桥契约不让 H5 报 "no handler"；
    未来接 Agora 时把 3 个 stub 展开实现，注册点不变
- [x] **6.4** `SwitchOverGameData` handler（大厅 + 子游戏共用）：解参 `Gamedirectory` / `gamedownloadurl` / `data` → `AppCoordinator.shared.showSubGame(...)`；节流 / 栈深守门移到 Coordinator，handler 始终回 cb `"SwitchOverGameData"`（msext NewRootVC.m:541-566 等价）
- [x] **SceneDelegate**：以 `UINavigationController` 承载大厅，`AppCoordinator.shared.navigationController` 持引；导航栏隐藏 + 禁用边缘 pop 手势
- [x] **6.5** `backgameData` handler（仅子游戏 `BackGameDataHandler.swift` 注册）：
  - `AudioPlayer.shared.stopAllBackground()`（新增方法，msext 无条件 stop+nil 行为等价）
  - `AppCoordinator.shared.popSubGame(returningData:)` → 内部 popViewController + 发 `.subGameDidReturn`
  - callback 字面 `"backgameData"`
  - data 入参兼容 string / object（非字符串走 JSONSerialization 透传）
- [x] **6.6** 大厅监听 `.subGameDidReturn` → `bridge.call("getWebdata", data)`
  - 挂钩点：`WebContainerViewController.setupExternalSubscriptions`（与 battery/network/appservice
    同一生命周期，避免双发）；`teardownExternalSubscriptions` removeObserver
- [x] **6.7** 子游戏 zip 下载 / 解压（H5 端通过 SwitchOverGameData 传 `gamedownloadurl`）
  - `Source/Resource/SubGameDownloader.swift`：actor 单例，URLSession + ZIPFoundation
  - 缓存策略：`{Caches}/{gameDir}/{gameStart}/index.html` 已存在 → `.alreadyExists` 直接复用；
    未命中 → 下载 → staging 解压 → 原子 rename（与 LobbyZipUpgrader 同思路；
    msext gameController.m:1340-1401 行为等价，本项目以 staging 替代 msext 直接覆盖避免半残留）
  - SubGameViewController.runBootPipelineSteps 接入：splash 实时更新下载进度

#### 验收

- 契约 §10 B 节 `SwitchOverGameData` → push 子游戏 → 子游戏调 `backgameData` → 回大厅 → 大厅收 `getWebdata`
- 栈深永远 ≤ 3（Lobby + SubGame + Overlay）

#### 风险

- 子游戏 zip 下载失败兜底：超时 / 网络断 / hash 校验失败 → 弹错误页且 pop 回大厅
- 大厅 + 子游戏并存时双发桥事件（已由 Phase 2 ExternalSubscriptions 解决）

---

### Phase 7: 弹层（Overlay）

#### 目标
H5 调 `OpenurlTitleData` 打开弹层 WebView，弹层内 H5 用 `window.settings.*` 三接口操作，关闭后大厅收 `getWebdata`。

#### 前置
- Phase 1 完成

#### 任务清单

- [x] **7.1** `Source/WebView/OverlayViewController.swift`：独立 WKWebView，`WKWebViewConfiguration.websiteDataStore = .nonPersistent()`
  隔离弹层 Cookie / 缓存（不污染大厅 / 子游戏会话）
- [x] **7.2** polyfill JS 内联（不抽独立 .js 文件）：`.atDocumentEnd` 注入
  `window.settings={browser,finishweb,backgameData}` → `webkit.messageHandlers.*`
  （msext threeView.m:223-241 在 webViewDidFinishLoad 注入 `context[@"settings"]=jo` 等价时机）
- [x] **7.3** `WKScriptMessageHandler` 通过 `ScriptMessageProxy`（weak target 切断 retain cycle）
  注册 3 handler：`browser` / `finishweb` / `backgameData`（messageHandler 与 WVJB 不同 channel，
  可与大厅 WVJB 同名共存不冲突）
- [x] **7.4** `OpenurlTitleData` handler（大厅 + 子游戏都注册）：解参（`"title "` 末尾空格契约硬约束） →
  `AppCoordinator.showOverlay` → 3 秒节流由 Coordinator 守门（msext first_Time 等价）
- [x] **7.5** Overlay backgameData → `AppCoordinator.popOverlay(returningData:)` → 发 `.subGameDidReturn`
  → 父层（大厅 / 子游戏）观察后 callback H5 `getWebdata`
- [x] **7.6** Overlay `finishweb` → `popOverlay(returningData: nil)` 仅 pop 不发通知
- [x] **7.7** Overlay `browser` → `UIApplication.shared.open` Safari 外链
- [x] **额外**：SubGameViewController.setupExternalSubscriptions 也订阅 `.subGameDidReturn`
  （overlay 可从子游戏 push，pop 回子游戏时需要 callback 子游戏 H5 的 getWebdata；
  与大厅 / 子游戏的 viewWillAppear/Disappear 生命周期对齐，栈顶才挂钩避免双发）

#### 验收

- 契约 §10 B 节 `OpenurlTitleData` 完整链路 + `settings.finishweb()` 关闭 + `settings.backgameData(data)` 回传

#### 风险

- 弹层第三方外链 Cookie 不污染主业务态（`.nonPersistent()` 已隔离）
- 3 秒节流时间戳由谁持有（`OpenUrlHandler` 内部 actor 状态）

---

### Phase 8: 视频房间 stub

#### 目标
注册 3 个视频房间 handler 的 stub 实现 + 子游戏独有 3 个反向 callback，以满足契约边界 —— 即使业务暂不上视频，H5 也不会报错。

#### 前置
- Phase 6 完成（子游戏容器）

#### 任务清单

- [x] **8.1/8.2/8.3** 视频房间 3 件套 stub 已在 Phase 6 commit C 同期落地：
  - `Source/Bridge/Handlers/VideoRoomHandlers.swift` 直接 enum.register（不抽 protocol/Noop 类，
    业务暂未启用 Agora，未来接入时把 3 个 stub 展开即可，注册点不变）
  - `createRoom` stub → `cb("createRoom")`
  - `getVideoinfo` stub → `cb("getVideoinfo")`
  - `exitRoom` stub → `cb("exitRoom")`
- [x] **8.4** `Source/Resource/PhoneStateMonitor.swift`：CXCallObserver 监听通话状态（CallKit；不用废弃的 CTCallCenter）
  - 来电响铃中（hasConnected=false, isOutgoing=false, hasEnded=false）或 已连接（hasConnected=true）→ "2"
  - 挂断（hasEnded=true）→ "0"
  - 仅 SubGameViewController.setupExternalSubscriptions 挂载 + teardown 时 stop（大厅不订阅）
  - 反向 callback `bridge.call("phonestate", data:.string(state))`
- [ ] **8.5** `RecordUploader.onUploaded` 已在 Phase 3，需要在 SubGameHandlers 里**额外**触发 `recordSuccess({fileUrl, fileName, fileKey})`（契约 §3.2 表 [15]）
- [ ] **8.6** `AgoraVideoRoom.swift` 留蓝图骨架（`#if AGORA_ENABLED` 包裹，默认 OFF）

#### 验收

- 契约 §10 D 节中：
  - `createRoom` / `exitRoom` H5 调用不报 "bridge not found"
  - 子游戏接电话 → H5 收 `phonestate("2")` / `phonestate("0")`
  - 子游戏录音上传 → H5 收 `getaudiourl` **AND** `recordSuccess`（双 callback）

#### 风险

- `CXCallObserver` 在模拟器无效，必须真机测
- 视频房间真要启用时再走 §8.6 的 AgoraVideoRoom 实现路径

---

### Phase 9: SDK 真实化 + 监控接入

#### 目标
首版 SDK 集成定型：接入 Sentry 崩溃监控；极光 / 闲聊 / Agora 维持 Noop 占位（编译开关 OFF）；Bugly 不集成。

#### 前置
- Phase 1–8 完成

#### 任务清单

- [ ] **9.1** Sentry-Cocoa SPM 依赖 + `Source/Analytics/SentryCrashReporter.swift`
  - 编译开关 `SENTRY_ENABLED`（默认 ON）+ `NoopCrashReporter` fallback
  - DSN 通过 xcconfig 注入，不入 git
- [x] **9.2** `Source/Bridge/H5ErrorRelay.swift`：WKUserScript .atDocumentStart 注入 hook，拦截 `console.error` / `window.onerror` / `unhandledrejection`，通过独立的 `webkit.messageHandlers.h5error` 桥（与 WVJB 不同 channel）转发到原生 print。仅 BridgedWebView 安装（大厅+子游戏），Overlay 第三方外链不挂。零修改 H5。
- [x] **9.3** 极光（JAnalytics）：**不集成**（ADR-005 决策 + msext CLAUDE.md 同款实践：不主动现代化、Bugly 都已废弃没人看数据；极光同样无业务诉求）。`Source/Analytics/Tracker.swift` 协议 / `NoopAnalytics` 当前**未建**（YAGNI：H5 业务不调埋点 handler，建空协议反而 misleading；未来真要启用时再走 Vendor → 抽 Tracker 协议 → Noop fallback 的标准路径）
- [x] **9.4** 闲聊：**不集成 SDK**（msext 时代闲聊 SDK 已停更，业务方未启用）。SharePanel 三按钮仅微信 / QQ / 抖音，不含闲聊；DouyinShare / QQShare / WechatShare 已实现，闲聊连 stub 都不需要
- [x] **9.5** Agora：**不接入**（用户明确视频房暂不需要）。VideoRoomHandlers stub 已满足契约（createRoom / getVideoinfo / exitRoom 三 handler 不报 "not found"）；未来重启视频时按 Phase 8.6 蓝图展开
- [x] **9.6** Bugly：**不集成**（msext CLAUDE.md 已明示「Bugly 后台账号已废弃 / 不再续费 / 没人看数据」；新外壳沿用此决策，避免重蹈覆辙）。崩溃监控完全依赖 Phase 9.1 Sentry（如启用）+ 用户/运营反馈链

#### 验收

- 强制崩溃 → Sentry 后台收到 issue
- H5 内 `throw new Error()` → Sentry 看到 H5 错误 + `container_role` 标签
- `Tracker` 协议存在且默认绑到 `NoopAnalytics`，业务调用 `Tracker.event(...)` 不崩、不阻塞

#### 风险

- Sentry 账号必须 ≥ 2 人持有访问凭证（避免重蹈 Bugly 覆辙）
- 首版无用户统计，业务侧若想看激活 / 留存只能等启用 JAnalytics 或自建埋点

---

### Phase 10: 多渠道 + 真机回归 + 灰度

#### 目标
完成打包脚本 + CI 多渠道流水线 + 真机覆盖回归 + 内部灰度切流。

#### 前置
- Phase 1–9 完成

#### 任务清单

##### 10.A 多渠道打包

- [ ] **10.1** `Scripts/inject_channel.sh`：按 Design §7.4 实现
- [ ] **10.2** `Scripts/archive.sh`：xcodebuild archive + exportArchive 流水线
- [ ] **10.3** `Scripts/release.sh`：循环渠道列表，逐个出包到 `dist/`
- [ ] **10.4** 渠道环境文件 `channels/*.env` 模板

##### 10.B CI

- [ ] **10.5** CI 加 archive smoke test（至少一个渠道能出 ipa）
- [ ] **10.6** 契约测试纳入 CI 必跑

##### 10.C 真机回归

- [ ] **10.7** 按契约 §10 26 项清单逐项真机过：
  - A 节 启动（4 项）
  - B 节 桥接（12 项）
  - C 节 系统事件（4 项）
  - D 节 视频房间（4 项，stub 模式下只验 phonestate / recordSuccess / createRoom 不报错）
  - E 节 回归（3 项 + 截图分享）
- [ ] **10.8** 覆盖设备矩阵：iPhone（最新 + 一台老款）/ iPad / iOS 15.6 / iOS 26.x

##### 10.D 灰度

- [ ] **10.9** 与 msext 旧外壳并存（Bundle ID 已分离），内部 50 人手动覆盖装
- [ ] **10.10** 监控 Sentry crash-free rate ≥ 99.5%、用户反馈
- [ ] **10.11** 50 → 100 → 全量切流（按渠道，小渠道先切）

#### 验收

- 契约 §10 全 26 项真机通过
- 全量灰度内零回退要求
- CI 多渠道出包稳定

#### 风险

- 真机覆盖不全：单人测试时间有限，必要时项目方协调测试人力
- 灰度期间发现 P0 → 必须能 24h 内热修（设计 Sentry breadcrumb + 远程开关）

---

## 6. 横向工作流（贯穿全程）

### 6.1 SDK 集成节奏

| 集成方式 | SDK | 集成 Phase |
|---------|-----|----------|
| **SPM 优先** | ZIPFoundation / Sentry / 七牛 v8.9.x（`https://github.com/qiniu/objc-sdk`） | Phase 1 / 9 / 3 |
| **Vendor `.xcframework`** | 微信 / QQ / **高德 AMap**（ADR-006）；闲聊 / Agora / JAnalytics 均暂不集成（Noop 占位） | Phase 4 / 5 |
| **Vendor `.a`** | opencore-amr | Phase 3 |
| ~~CocoaPods~~ | **不引入**（ADR-006，纯 SPM + Vendor 两层管理） | — |

每集成一个 SDK 立即提交一个独立 commit（CLAUDE.md "及时提交" 规则）。

### 6.2 测试节奏

- **Phase 1 起每 Phase 必须有契约测试用例**纳入 `ylgamehallContractTests`
- 单元测试覆盖率目标：BridgeCore ≥ 90%、ResourceKit ≥ 85%、其余 Kit ≥ 70%（Design §12.1）
- UI 集成测在 Phase 10 完成 26 项验收清单的 XCUI 自动化

### 6.3 监控接入

- Sentry 默认 ON 但可关（编译开关 `SENTRY_ENABLED`）
- DSN / 七牛 token 接口 URL / 微信 AppID 等敏感配置走 xcconfig + `.gitignore`
- H5 错误回流通道（`reportH5Error` 桥 handler）在 Phase 9 接入

### 6.4 文档同步

- 每个 Phase 结束更新本文档 §8 进度追踪
- 桥接 handler 任何变动必须同步更新 Contract（Design §18.6）
- Phase 完成时如发现 Contract 描述与实测不符 → 优先修 Contract（事实为准）

#### Design 接口骨架覆盖里程碑

| 日期 | 完成 | 涉及 commit |
|------|------|-------------|
| 2026-06-22 | Phase 1 闭环（1.10–1.16）完整启动链路打通：SceneDelegate → WebContainer → ensureReady → fetch → resolve → upgrade → loadFileURL → fade out splash | `c95e80d` 及之前 |
| 2026-06-22 | Design 蓝图按 Contract 全 51 项接口补完实现骨架（H5↔Native 5 条通讯路径）：22 项 §3.1 异步 handler / 15 项 §3.2 反向 callback / 3 项 §3.4 弹层 polyfill / 15 项 §7.5 `app_*.js` 预注入全局变量 / 2 项 §3.7 WKUIDelegate alert/confirm | `92298b6` / `c6221d3` / `d48ecc0` / `a06a18c` / `5f5f6a9` / `33574a1` / `f68b0db` |
| 2026-06-22 | Contract §4.2 按 daoqi 原项目代码（grep `var app_` 字面字符串）全面修订：删 `app_gameid` / `app_compareCode`（原项目不写）/ 修正 `app_battery` → `app_getbattery`、`app_network` → `app_getnetwork`（文件名不带 get、变量名带 get）/ 补 6 项遗漏（version / Launchtype / getwifisignalLevel / gamename / invitationcode / gamesname） | `47a89aa` |
| 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 全面同步回退 | `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 通过 | `829cc8f` |
| 2026-06-22 | **`app_appversion` / `app_gameconfig` 业务语义纠正**：grep `daoqi/msext/NewRootVC.m:1190-1208` 发现 `app_appversion` 不是版本号、是审核切换标志（`'0'` 正常 / `'1'` 审核期）；`app_gameconfig` 也跟着 result 在 `BundleConfig.gameConfig` / `appleConfig` 切换。AppDataWriter.writeAppData 加 result 计算逻辑；Contract §4.2 + Design §7.5.1 同步纠正；Contract §4.2 修订记录加一行 | `0a313af` |
| 2026-06-22 | **Phase 2.A 8 个大厅 handler 落地** + **Phase 2.B 5 项反向 callback 落地** + BridgeData 加 nonisolated 解 Swift 6 actor 隔离 | `d38ff0f` + `23b3a1f` |
| 2026-06-22 | **业务期 `app_getbattery` / `app_getnetwork` 改为 evaluateJavaScript 重新赋值（不重写文件）**：启动期保持写文件，业务期变化时改为 `webView.evaluateJavaScript("window.app_xxx=N;")` 直接重新赋值（H5 已加载完 `<script src>` 不会再 fetch，重写文件无效）。与 §3.2 反向 callback 双轨同时执行。Design §7.5.2 / §7.5.4 + Contract §4.2 同步 | `4986f36` |
| 2026-06-22 | **Phase 2.C ExternalSubscriptions 生命周期管理**：setup/teardown 配对挂 viewWillAppear/viewWillDisappear，Phase 6 子游戏 push 时防双发 | `09f071e` |
| 2026-06-22 | 新增 `docs/Verification-Checklist.md` 功能验证清单：累积各 Phase 验证步骤、不在 Phase 进行中频繁验证、完成后统一跑。与 Contract §10 契约边界保持独立 | `b2ee1b3` |
| 2026-06-22 | Phase 3.A srcIsloop 本地音频 + 3.B/C/D RemoteAudio stub。AudioPlayer @MainActor 实现 playOnce/loopBackground/stopBackground 三方法与 msext 等价 | `0602b9d` |
| 2026-06-22 | **Phase 4.A 登录+分享 stub + QQ 改走 URL Scheme 不依 SDK**：AccreditLoginHandler + FriendsShareHandler stub；Plan §2.3 阻塞表去掉 QQ SDK | `aa80aec` |
| 2026-06-22 | **Phase 4 方向最终决策（A）**：grep msext NewRootVC.m / gameController.m 未发现 QQShareManager 引用 — Contract §3.1 ［2］ `sharetype` 取值仅"1"/"2"=微信 / "3"=闲聊无 QQ；msext 的 QQShareManager 只接原生 SharePanel（独立功能不接 H5 桥）。新外壳契约不变 → 不实现 QQ 分享 | `a6095ef`（**已被下一行撤销**） |
| 2026-06-22 | **Phase 4 方向再次纠正**：用户提示"原项目还有 QQ 分享、抖音分享"。深度调研发现 grep 范围之前不够 — msext `gameController.m:575-597`（不是 NewRootVC）显示 H5 调 `friendsShare(sharefriend=1)` 时原生**弹 SharePanel 三选一面板**（微信好友 / QQ / 抖音）；Contract §3.1 ［2］旧描述（sharetype=3 闲聊）已被 msext 新代码覆盖（sharetype 字段读了但不用）。QQ + 抖音都不依赖 SDK：QQ 走 URL Scheme（`QQShareManager.m:14` `__has_include` fallback），抖音走 URL Scheme + Photos（`DouyinShareManager.m` 全程无 SDK）。撤销 A 决策，新方向：Phase 4.2 SharePanel + 4.3 QQ URL Scheme + 4.4 抖音 URL Scheme | 本次 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「典型案例」段。

---

## 7. 真实风险与缓解（项目独有）

| 风险 | 触发条件 | 影响 | 缓解 |
|------|---------|------|------|
| 已就位的 `gamehall.zip` 是 2023-12 旧版，与现网 H5 有契约漂移 | Phase 1–9 联调 | 联调期发现 handler 名 / 字段被旧版掩盖、真上线时反而暴露 | 上线前阶段（Phase 10 灰度前）强制由 H5 团队提供最新版替换并复跑契约 §10 全部 26 项；联调期发现的契约疑点立即对照现网 msext 与 H5 团队对齐 |
| 微信 SDK 审核失败 / 改 Bundle ID 引起 Universal Links 失效 | Phase 4 真机调试 | 微信回调收不到 | Bundle ID 注册微信开放平台 + 配置 Universal Links 流程独立做一次（项目方协调） |
| 后台 `/wechat/login` 接口延期 | Phase 4 上线时 | secret 仍驻 IPA | 接受临时 fallback（标 TODO），上线前必须切到后台 |
| 高德 APIKey 与 Bundle ID 绑定失败 | Phase 5 真机定位 | 定位全失败 | 项目方控制台重新申请 key（本项目独立 key） |
| Swift 6 严格并发产生大量 warning/error | Phase 2 起 | 进度延迟 | 已启用 Approachable Concurrency（渐进模式），逐 Phase 修复，不一开始就 Complete 模式 |
| WKWebView 内 `evaluateJavaScript` 在 Liquid Glass 时代 API 变化 | iOS 26+ 真机 | 桥消息派发失败 | DocumentationSearch 优先查最新 API，不假设知识截止前的写法 |
| 真机回归人力不足（单人 + AI） | Phase 10 | 漏测 → 上线翻车 | 把 26 项验收清单写成 XCUI 自动化（可重复跑），手测只覆盖体验类 |
| Sentry 账号长期无人维护变 Bugly 翻版 | Phase 9 上线后 | 崩溃监控形同虚设 | 接入时同步把账号责任写入团队 onboarding，季度回顾会上检查存活 |
| 灰度期发现 P0 但无热修通道 | Phase 10 灰度 | 用户体验受损 | 设计期就要预留远程配置开关（如：`Sentry breadcrumbs` 中查 root cause + 一个 plist 控制的 feature flag）|

---

## 8. 进度追踪

> 每完成一项就把 `[ ]` 改 `[x]`，并在 commit message 里附 Phase X.Y 编号方便 git log 检索。

### Phase 0 工程化基线
- [x] 0.0 M0 基础配置（横屏 / Swift 6 / 代码启动）
- [x] 0.0.1 `.gitignore` + xcuserdata 清理
- [ ] 0.1 SwiftLint
- [ ] 0.2 单测 target
- [ ] 0.3 契约测试 target
- [ ] 0.4 UI 测试 target
- [ ] 0.5 CI 流水线
- [ ] 0.6 SPM 包目录决策（暂单 target）

### Phase 1 最小垂直闭环（含远程配置 + zip 升级，ADR-008）
- [x] 1.1 ChannelConfig.plist 母包默认值（ADR-007）
- [x] 1.2 BundleConfig
- [x] 1.3 SandboxPaths
- [x] 1.4 ZIPFoundation SPM
- [x] 1.5 ResourceUnzipper
- [x] 1.6 BridgeProtocol
- [x] 1.7 BridgeBus
- [x] 1.8 WVJB JS 协议
- [x] 1.9 BridgedWebView
- [x] 1.10 RemoteConfigClient（actor，URLSession async + 重试 + cache-busting + 短文本 + FlexibleString）
- [x] 1.11 VersionResolver（单链 4 层 fallback：agent→game→channel→market，纯函数）
- [x] 1.12 LocalVersionReader（version.xml 解析 + ATS 全局放行）
- [x] 1.13 LobbyZipUpgrader（actor，原子 rename 升级；端到端实测 260→261）
- [x] 1.14.a AppIcon 替换 Xcode 默认空模板（iPad / 1024 缺失留 Phase 10 polish）
- [x] 1.14.b LaunchScreen 改启动图（素材逆时针旋转 90° → 物理 1136×640，scaleAspectFit + 黑底左右补边）
- [x] 1.14.c LobbyZipUpgrader 加 progress 回调（URLSessionDownloadDelegate，0.0…1.0）
- [x] 1.14.d WebContainerViewController（SplashOverlay + 16:9 letterbox + 启动流水线 + 三种 alert）
- [x] 1.14.e WebView didFinish 后 0.3s 淡出 splash + removeFromSuperview
- [x] 1.15 VibratorHandler（单次振动，AudioServicesPlaySystemSound + responseCallback "vibrator"）
- [x] 1.16 SceneDelegate → WebContainerViewController（删除 M0 占位 RootViewController.swift）
- [ ] 1.17 Demo H5 联调 + 真机验证升级路径
- [x] 1.18 AppSchemeHandler（WKURLSchemeHandler 单例，ylgame://h5 路由 + Range + MIME 表 + 异步 IO + 取消语义，ADR-010）
- [x] 1.19 SandboxPaths.lobbyIndexAppURL / subGameIndexAppURL（URLComponents 拼装）
- [x] 1.20 BridgedWebView 注册 scheme handler + WebContainer/SubGame loadFileURL → webView.load(URLRequest) 切换
- [ ] 1.21 真机验证 Phase 1.F 等价性（与 1.17 一起跑：跨页 localStorage 共享 / app_*.js 同步引入 / 升级路径 / 子游戏切换）

### Phase 2 大厅简单 handler + 反向 callback
- [x] 2.1 剪贴板（ClipboardHandler.swift）
- [x] 2.2 振动扩展（VibratorHandler.swift，Phase 1.15 顺带做完）
- [x] 2.3 摇一摇开关（ShakeHandler.swift + ShakeDetector）
- [x] 2.4 voicePlaying（VoicePlayingHandler.swift，全局开关由 RemoteAudioHandler 读）
- [x] 2.5 getphoneInfo（DeviceInfoHandler.swift，注意契约小写 i）
- [x] 2.6 browser（BrowserHandler.swift，UIApplication.open）
- [x] 2.7 opensaoma 空注册（OpenSaomaHandler.swift，cb-only stub 维持契约）
- [x] 2.8 DeviceInfo（DeviceInfoHandler.swift 与 2.5 合并实现）
- [x] 2.9 BatteryMonitor（Source/Resource/BatteryMonitor.swift，UIDevice batteryStateDidChange）
- [x] 2.10 NetworkMonitor（Source/Resource/NetworkMonitor.swift，NWPathMonitor）
- [x] 2.11 AppLifecycleObserver（Source/Resource/AppLifecycleObserver.swift，appservice "1"/"2"）
- [x] 2.12 ShakeDetector（与 2.3 合并：UIResponder motionEnded → ShakeHandler）
- [x] 2.13 ExternalSubscriptions（WebContainerViewController + SubGameViewController 各自挂载/卸载）

### Phase 3 音频体系
- [x] 3.1 AudioPlayer（Source/Audio/AudioPlayer.swift，srcIsloop / stopBackground / volume 全路径）
- [ ] 3.2 本地音频真机验证
- [ ] 3.3 opencore-amr Vendor 接入（详见 docs/SDK-Integration-Guide.md §C，需手动）
- [ ] 3.4 VoiceCoder Swift wrapper
- [ ] 3.5 AudioRecorder + 麦克风权限（NSMicrophoneUsageDescription 已就位）
- [x] 3.6 七牛 SPM（objc-sdk 8.9.2）+ QiniuConfig + QiniuTokenSigner（CryptoKit HMAC-SHA1 自签）+ QiniuUploader actor（initWithConfiguration + defaultConfigurationV2）
- [ ] 3.7 prepareaudio handler
- [ ] 3.8 mediaTypeAudio handler

### Phase 4 分享 + 登录
- [x] 4.1 微信 SDK Vendor + URL Scheme（WechatOpenSDK-NoPay.xcframework 2.0.5 入库 Vendor/WechatSDK/；Info.plist CFBundleURLTypes + LSApplicationQueriesSchemes 3 项）
- ✗ ~~4.2 QQ SDK Vendor~~ **不需要**：QQ 走 URL Scheme，详见 §5 Phase 4.A 任务清单
- [x] 4.3 WeChatSDK register（Source/SDK/WeChat/WeChatSDK.swift；新 SDK 2.x 无 MMAPP flag 概念，registerApp + universalLink 即可）
- [x] 4.4 WeChatManager（state map + FIFO；Source/SDK/WeChat/WeChatManager.swift）
- [x] 4.5 SceneDelegate openURL（QQ 走 URL Scheme 不依赖 SDK，仅微信 WXApi.handleOpen）
- [x] 4.6 WeChatAuth（客户端直拼 sns/oauth2/access_token + sns/userinfo，沿用 msext 安全模型）
- [x] 4.7 accreditlogin → sharelogin（7 字段，Province 大写 P）
- [x] 4.8 SharePlatform 协议（Phase 4.B 完成）+ WechatShare（type=1 链接 done；type=2/3 见 4.11/4.12）
- [x] 4.9 ShareCenter（sharefriend=1 弹 SharePanel；=2 直接微信朋友圈；Phase 4.B 完成）
- [x] 4.10 friendsShare... handler（FriendsShareHandler.swift，sharesuccess 反向 callback）
- [ ] 4.11 截图分享（Phase 4.F，type=2）
- [ ] 4.12 远程图片分享（Phase 4.F，type=3）

### Phase 5 定位
- [x] 5.1 AMap Vendor 接入（高德官方不提供 XCFramework，回退为 fat `.framework`；M Mac 模拟器链接失败是接受的现状，详见 Vendor/AMap/README.md）
- [x] 5.2 定位权限文案（NSLocationWhenInUseUsageDescription）
- [x] 5.3 AMapWrapper 启动注册（updatePrivacyShow / updatePrivacyAgree 是 AMapLocationManager 类方法，apiKey 在 AMapServices.shared）
- [x] 5.4 LocationService actor（requestOnce 异步 + stop 清理钩子）
- [x] 5.5 startlocation handler（latitude/longitude string，province 小写 p）
- [x] 5.6 失败回包 errorCode 12（缺少定位权限）

### Phase 6 子游戏
- [x] 6.1 AppCoordinator 栈深节流（2s + viewControllers.count < 2）
- [x] 6.2 SubGameViewController 框架（commit B 接入 SubGameDownloader 后真实下载/缓存命中）
- [x] 6.3 子游戏 handler 拆散注册（含 BackGameDataHandler；视频房间留 Phase 8）
- [x] 6.4 SwitchOverGameData → AppCoordinator.showSubGame
- [x] 6.5 backgameData（停 audio + popSubGame + JSON 序列化兼容）
- [x] 6.6 getWebdata 通知链（.subGameDidReturn 观察者挂在 ExternalSubscriptions 生命周期）
- [x] 6.7 SubGameDownloader（actor，URLSession + ZIPFoundation + staging + 原子 rename）

### Phase 7 弹层
- [x] 7.1 OverlayViewController + 私有 dataStore（.nonPersistent）
- [x] 7.2 window.settings polyfill（.atDocumentEnd 注入）
- [x] 7.3 3 个 WKScriptMessageHandler（ScriptMessageProxy 切断 retain cycle）
- [x] 7.4 OpenurlTitleData handler（"title " 末尾空格 + 3s 节流由 Coordinator 守门）
- [x] 7.5 Overlay backgameData（→ popOverlay 发 .subGameDidReturn → 父层 getWebdata）
- [x] 额外 SubGameViewController 也订阅 .subGameDidReturn（覆盖 overlay→sub-game 返回）
- [x] 7.6 Overlay finishweb（OverlayViewController.handleMessage `.finishweb` → popOverlay nil）
- [x] 7.7 Overlay browser（OverlayViewController.handleMessage `.browser` → UIApplication.open，URL 容错带 percent encoding）

### Phase 8 视频房间 stub
- [x] 8.1/8.2/8.3 视频房间 3 件套 stub（VideoRoomHandlers.swift，业务暂未启用 Agora，未来重启时把 3 个 stub 展开即可，注册点不变；不单独抽 protocol/Noop 类避免过度设计）
- [x] 8.4 PhoneStateMonitor → phonestate（CXCallObserver；仅子游戏订阅）
- [ ] 8.5 recordSuccess 双 callback（依赖 Phase 3.C 录音上传完成；当前 prepareaudio 是 stub）
- [ ] 8.6 AgoraVideoRoom 蓝图（业务暂不需要视频，缓做；未来重启再写 #if AGORA_ENABLED 路径）

### Phase 9 SDK 真实化 + 监控
- [ ] 9.1 Sentry SPM + CrashReporter
- [x] 9.2 H5ErrorRelay（独立 webkit.messageHandlers.h5error，BridgedWebView 安装，Overlay 不挂）
- [x] 9.3 极光 JAnalytics 不集成（YAGNI；未来启用时按 Vendor → 抽协议 → Noop 流程）
- [x] 9.4 闲聊不集成 SDK（SharePanel 已只含微信/QQ/抖音三按钮）
- [x] 9.5 Agora 不接入（VideoRoomHandlers stub 已满足契约；未来重启按 8.6 蓝图）
- [x] 9.6 Bugly 不集成（沿用 msext CLAUDE.md「账号已废弃」决策）

### Phase 10 多渠道 + 回归 + 灰度
- [ ] 10.1 inject_channel.sh
- [ ] 10.2 archive.sh
- [ ] 10.3 release.sh
- [ ] 10.4 渠道环境文件
- [ ] 10.5 CI archive smoke
- [ ] 10.6 契约测试入 CI
- [ ] 10.7 26 项验收清单真机过
- [ ] 10.8 设备矩阵覆盖
- [ ] 10.9 内部 50 人灰度
- [ ] 10.10 Sentry 监控
- [ ] 10.11 全量切流

---

## 9. 决策记录（ADR-lite）

> 关键技术 / 工程决策的简短记录，便于回溯"为什么这样选"。新增决策追加到本节末尾。

### ADR-001：单 target 起步，暂不切 SPM（2026-06-21）
- **决策**：Phase 0–2 阶段保持单 target，按目录组织（`Source/Bridge`、`Source/Resource` 等）；待代码量 > 5K 行（约 Phase 3 完成时）再评估切 SPM
- **理由**：早期 SPM 切分成本（target 间循环依赖排查、增量编译配置、测试 target 接入）会拖慢核心垂直闭环验证；Design §2.2 也注明"目标稳态 8-10 个 target，以日常开发体验为先"
- **回滚条件**：单 target 编译时间 > 30s 时切 SPM

### ADR-002：使用已就位的 2023-12 旧版 gamehall.zip 起步（2026-06-21，原方案"H5 demo 先行"已作废）
- **背景**：私人素材池 `docs/res/gamehall.zip` 已存在（旧版，2023-12，11.5 MB），无需等 H5 团队最新版即可启动 Phase 1
- **决策**：Phase 1 实施时从 `docs/res/gamehall.zip` 拷贝到 `ylgamehall/Resources/gamehall.zip` 入 Bundle，跑通"渠道注入 → 解压 → 加载 H5 → 桥消息"全链路；旧 H5 业务代码与新版的契约差异不影响"桥本身是否工作"的验证
- **理由**：
  - 桥接口名 / 参数字段名 / JS 协议属于契约范畴，按 Contract 1:1 实现即可，不依赖 H5 业务代码新旧
  - 提早用真 H5 跑通比 demo H5 更能暴露真实问题（如 WVJB 握手时序、JS 注入文件路径等）
- **风险**：联调期遇到的契约疑点可能来自旧版 H5 已废弃接口，需对照现网 msext + H5 团队双确认
- **切换条件**：Phase 10 灰度前必须由 H5 团队提供最新版替换 `ylgamehall/Resources/gamehall.zip` 并复跑契约 §10 全部 26 项

### ADR-003：微信登录走后台中转 vs 客户端 fallback（2026-06-21）
- **决策**：默认走后台 `/wechat/login` 中转；后台未就绪时临时客户端直拼，但代码标 `// FIXME` 且**禁止以 fallback 状态上线**
- **理由**：客户端 secret 一旦进 IPA 永久泄露 + AppSecret 不可重置（Contract §0.4 / Design §17）；上线前必须闭环
- **复盘节点**：Phase 4 完成时检查后台接口状态

### ADR-004：项目资源目录结构由各 Phase 按需落地，不预先建仓库根 Resources/（2026-06-21 修订）
- **背景**：原 ADR-004（2026-06-21 初版）决议"项目资源统一放仓库根 `Resources/` + 打包脚本 Build Phase 引入"，并将 `gamehall.zip` / `Images.xcassets` / `Res/` 入库到 `Resources/`。但 `docs/res/` 后被维护者重新定义为**私人原始素材池**（不被项目感知，详见 CLAUDE.md），原 `Resources/` 入库的素材被搬走，使该 ADR 的语义崩塌
- **修订决策**：废止"仓库根 `Resources/`"的固定结构假设，**项目内资源目录由各 Phase 实施时按需落地**：
  - 静态 Bundle 资源 → `ylgamehall/Resources/`（synchronized group 自动收集，含 `gamehall.zip` / `ChannelConfig.plist` 等）
  - 原生 Asset Catalog → `ylgamehall/Assets.xcassets/`
  - 渠道注入值 → `ylgamehall/Resources/ChannelConfig.plist`（10 个 string key，母包默认值；ADR-007、ADR-009）
  - 应用级凭证 → `ylgamehall/Resources/AppSecrets.plist`（3 个 string key：wxAppSecret / qiniuAccessKey / qiniuSecretKey；ADR-009）
  - 闭源 SDK 二进制 → `Vendor/<SDK>/<SDK>.xcframework`
  - 私人原始素材池 → `docs/res/`（项目不感知，需要时拷贝到上述工程内目录）
- **理由**：
  - `docs/res/` 既已剥离为私人素材池，原"项目方资源统一放仓库根 Resources/"的前提不存在
  - synchronized group 模式下，把资源直接放 `ylgamehall/Resources/` 而非仓库根 `Resources/` 更省一步 Build Phase 配置
  - "不预先建 Resources/ 目录"避免空壳目录污染仓库；目录在该 Phase 真正需要时再建
- **影响**：
  - 仓库根 `Resources/` 不再存在（已删除）
  - Plan §2.2 / Phase 1 任务 / ADR-002 同步调整为 `docs/res/` → `ylgamehall/Resources/` 的拷贝模型
  - Design §7.0 同步重写

### ADR-005：JAnalytics（极光）首版降为 NoopAnalytics 占位（2026-06-21）
- **背景**：Design §14.2 原标注极光"✅ 启用，启动注册"，但项目方在 2026-06-21 评审时确认**首版无用户激活 / 留存分析需求**
- **决策**：与 Xianliao / Agora 同款渐进集成策略——首版不集成极光 SDK，`AnalyticsKit.tracker` 默认绑到 `NoopAnalytics`，业务调用 `Tracker.event(...)` 立即返回不做任何事；编译开关 `JANALYTICS_ENABLED` 默认 OFF，留蓝图 `JAnalyticsTracker.swift` 待启用
- **理由**：
  - 减少首版外部依赖（首版 SDK 集成清单：微信 / QQ / 高德 / 七牛 / opencore-amr / Sentry / ZIPFoundation，共 7 个；不集成 4 个：极光 / 闲聊 / Agora / Bugly）
  - 现网 msext 的极光后端运维状态不可知，与 Bugly 同样有"账号断档监控失效"风险（CLAUDE.md / 父级 msext 已有先例），新外壳起步不重蹈
  - Sentry 的 breadcrumb / transaction 可兜一部分用户行为路径，初期定位够用
  - 接入成本（AppKey 申请 / Vendor 二进制 / Info.plist 隐私文案）推迟到真有业务需求时
- **后续启用路径**（保持契约不变，业务无感）：
  1. 在 `Vendor/` 放入 `JAnalytics.framework`
  2. 把 `AnalyticsKit.tracker` 从 `NoopAnalytics()` 切换为 `JAnalyticsTracker()`
  3. xcconfig 注入 `JANALYTICS_ENABLED=1`
  4. Bootstrapper.registerSDKs 加 `JAnalytics.setup(appKey:)`
  5. Info.plist 加 AppKey + 隐私授权文案
- **复盘节点**：上线 3 个月后业务方根据数据需求决定是否启用

### ADR-006：纯 SPM + Vendor `.xcframework` 两层依赖管理，不引入 CocoaPods（2026-06-21）
- **背景**：Design 原方案是"SPM 优先 + CocoaPods 兜底 + Vendor 手动"三层策略，CocoaPods 仅服务于高德 AMap 定位 SDK 一个依赖
- **触发事件**：尝试 `pod init` 时，CocoaPods 1.15.2（Homebrew 上的最新版）自带的 `xcodeproj` gem 1.24.0 不识别 Xcode 26.5 默认的 `PBXFileSystemSynchronizedRootGroup`，pod init 直接抛 `unknown ISA` 异常
- **调研结论**：
  - 七牛 SDK 已官方支持 SPM（`https://github.com/qiniu/objc-sdk`，v8.9.x），原计划的"七牛走 CocoaPods"完全无必要
  - 高德 AMap 定位 SDK 官方未提供 SPM（截至 2026-06），但提供 XCFramework 直接下载
  - CocoaPods 1.16+ 才支持新 ISA，但 Homebrew 未跟进；需绕道 Bundler + Gemfile / brew --HEAD / rbenv 自管 Ruby，均增加工具链复杂度
- **决策**：依赖管理简化为**两层 — SPM 优先 + Vendor `.xcframework` 手动**，**完全不引入 CocoaPods**
  - SPM：ZIPFoundation / Sentry / Qiniu / WVJB JS（资源文件）
  - Vendor：微信 / QQ / **高德 AMap** / opencore-amr；闲聊 / Agora / JAnalytics 暂不集成（Noop 占位）
- **理由**：
  - 项目实际只有高德 AMap 1 个 SDK 让 CocoaPods 必要；为 1 个依赖引入整套 Ruby + Bundler + CocoaPods + workspace 工具链 + Xcode 26 兼容性维护，性价比低
  - 闭源 SDK 本就是 Vendor 性质（微信 / QQ / 闲聊 / JAnalytics / opencore-amr），AMap 走同款流程不增加心智负担
  - 生态趋势：Google 已宣布 2026 Q2 后 iOS SDK 全部停止 CocoaPods 支持；CocoaPods 维护活跃度下降
  - 工程入口仍是 `.xcodeproj`（不需要 `.xcworkspace`），团队 / CI 配置不需要切换
- **接入标准流程**（Vendor，写入 Design §14.1）：
  1. 二进制放 `Vendor/<SDKName>/<SDKName>.xcframework`
  2. Target → General → Frameworks → `+`，动态库选 "Embed & Sign"，静态库 "Do Not Embed"
  3. Library / Header Search Paths 用 `$(PROJECT_DIR)/Vendor/<SDKName>` 相对路径
  4. `Vendor/<SDKName>/README.md` 记录版本号 / 下载日期 / 官方更新页 URL
  5. 二进制入 git（项目规模可控，暂不强制 git-lfs）
- **回滚条件**：未来某天出现 ≥ 3 个仅有 podspec 而无 SPM / XCFramework 的 SDK 必接需求时，重新评估是否引入 CocoaPods
- **维护责任**：AMap 升级（年度级）→ 下载新 XCFramework 覆盖 `Vendor/AMap/` + 更新 README + 跑契约测试

### ADR-007：渠道注入用 `ChannelConfig.plist` 替代 msext 的"空目录名注入"机制（2026-06-21）
- **背景**：Contract §0.3 描述 msext 把 11 个渠道值编码为 Bundle 根的 11 个目录子文件夹名（如 `channel/FtJf0.../`，目录名本身就是值）。新项目首版尝试照搬此机制
- **触发事件**：与 Xcode 26.5 默认的 `PBXFileSystemSynchronizedRootGroup` 不兼容——
  - 子目录被同步组当作 group 处理，11 个 `.gitkeep` 在 Bundle 根撞名 → build 失败
  - 尝试 folder reference（蓝色文件夹）：Xcode 26.5 拖入对话框流程对此不友好，pbxproj 不会写入正确条目
  - 尝试 Run Script Build Phase：
    1. 默认开启的 User Script Sandboxing 拒绝读 `Scripts/copy_channel_injection.sh` 与 `ChannelInjection/`
    2. 即使关掉 sandbox，Run Script 在没有 Input/Output 声明 + 默认勾选"Based on dependency analysis"时，被 Xcode 视为"无依赖故无需运行"，clean build 也不跑
  - 综合工程成本估算：为保留"目录名编码"机制，需引入额外 Build Phase / 关闭 sandbox / 维护 Input/Output 列表，**全是 Xcode 行为兼容性维护，与项目目标无关**
- **决策**：渠道注入存储改用**单一 `ylgamehall/Resources/ChannelConfig.plist`** 含 10 个 string key（2026-06-22 初版 11 个，2026-06-27 移除 `qiniudomain`，详见 ADR-009）
  - 存储介质：plist（iOS 原生）
  - Bundle 加载：synchronized group 自动收集，零配置
  - 运行时读取：`BundleConfig.init(bundle:)` 用 `PropertyListSerialization` 反序列化
- **理由**：
  - **契约 100% 等价**：H5 端通过 `app_data.js` 看到的 JS 全局变量行为完全不变（契约边界在 `BundleConfig.shared.xxx`，与底层存储无关）
  - **CLAUDE.md 原则 B 落地**：原生内部自由重构，不要照搬旧项目；msext 那套是 iOS 9 / Xcode 14 时代的 hack，Xcode 26 + Swift 6 应当用更现代的存储
  - **IPA 后处理多渠道分发完全等价**：
    ```bash
    plutil -replace channel -string "<新渠道 ID>" ChannelConfig.plist
    plutil -replace market  -string "<新市场 ID>" ChannelConfig.plist
    codesign --force --sign "$IDENTITY" --entitlements "$ENT" <app>.app
    zip -r <channel>.ipa Payload/
    ```
    工作量与 msext 的 `mv` 目录名 + 重签完全相同，且 plutil 比 mv 一组路径更可读 / 可自动化
  - **维护成本显著降低**：单文件、可读、单测可注入 mock bundle、不依赖 Xcode 任何特殊配置
- **影响**：
  - Contract §0.3 描述的 msext 实现仅作为历史参考，新项目不照搬
  - Design §7.0 / §7.2 重写为 plist 方案
  - Plan Phase 1.1 简化为"建 plist"单步任务（取代原 1.1.a~d 四步 inject_channel.sh 方案）
  - `BundleConfig.swift` 接口（公开属性，当前 10 个）保持稳定，仅 init 实现切换
- **守护**：
  - **`ChannelConfig.plist` 必须保持 10 个 key 完整且类型为 string**（gameid/channel/gamedir/gamestart/gameconfig/market/agent/appversion/other/appleconfig）；新增 / 移除 key 视同契约边界变更，需更新 Design / Plan / Contract（如该值被 H5 通过 app_data.js 暴露）
  - 跨渠道相同的应用凭证（微信 AppSecret、七牛 AccessKey/SecretKey）放 `AppSecrets.plist`，不进 ChannelConfig（ADR-009）
  - 七牛 CDN 域名、bucket 名走 RemoteConfig 顶层 `audio_domain` / `audio_bucket` 远端注入，不在任何本地 plist（ADR-009）
  - 后处理工具必须**修改 plist 后立即重签**，否则 iOS 拒绝安装
  - 不允许把渠道值硬编码到 Swift 源码（违背"母包模式"的初衷：一份二进制 N 个渠道）
- **回滚条件**：若未来 Xcode / iOS 改动让 plist 方案无法工作（极不可能）或后处理脚本失效，重新评估目录名方案或其它存储介质

### ADR-008：Phase 1 补全远程配置 + 版本对比 + zip 升级（2026-06-22 起，含 3 次调研 revise）
- **背景**：Plan 初版 Phase 1 任务清单（13 项）只覆盖"启动 → 解压 Bundle 内 zip → 加载 H5"，**漏掉了 msext 启动流程里最关键的一环**：从 `gameconfig` 拼接出的远端 `.txt` 拉真实配置 → 单链 4 层 fallback 解析 → 与本地版本比较 → 必要时下载并替换 H5 zip
- **触发事件**：2026-06-22 起多轮调研 `daoqi/msext` 原项目，逐行核对核心方法：
  - 第一轮（粗粒度，2026-06-22）：发现远端配置流程整体形态、IPA / H5 zip 两条升级链
  - 第二轮（2026-06-22，**已废弃**）：误读 `onnet` 的 `else` 分支 + `chulishengji`（`NewRootVC.m:1372-1538`），错误地把双子树合并当作线上热路径
  - **第三轮（2026-06-27，最终采用）**：项目维护者直接指出契约真实结构是单链 4 层 `agentlist → gamelist → channellist → marketlist`。重新核对 `NewRootVC.m`，确认 `onnet` 的 `if(self.gamelist==nil)` **顶层分支**（`NewRootVC.m:689-810`）= 线上热路径就是这条单链；`else` 分支的 chulishengji 是历史遗留代码、主路径不走
- **核心算法（第三轮最终版）**：
  - 单链 4 层节点匹配：`agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid]`
  - 任一层 id 匹配失败立刻截断（短链）；最长 0..4 节点
  - 字段语义：每个字段从最深层向根回退取第一个非空值（等价 msext `if(xxx!="" && xxx!=nil) self.xxx=xxx` 逐层覆盖的最终态）
  - 5 字段（appVersion / appDownload / gameVersion / gameZip / showmessage）**共用同一套** `pickString` / `pickInt` 倒序查找接口，不再有"双子树 / 多分支合并"
- **第二轮误读复盘（保留以防再被翻出来）**：
  - 误读点：`viewWillAppear`（`NewRootVC.m:1609-1616`）与 `gonet`（`NewRootVC.m:1252-1258`）的 `self.gamelist` 同样取自 agent 节点下 `gamelist`，**但流程结构上先走 `onnet` 的 `if(self.gamelist==nil)` 分支命中后立即 return**，不会再进 else 的 chulishengji
  - 教训：原项目同名变量、不同入口的代码不能凭"看似都赋值了"判定走哪条；要顺着实际调用栈走一遍
- **决策**：在 Phase 1 视图层之前插入 4 个新子项 `1.10 RemoteConfigClient / 1.11 VersionResolver / 1.12 LocalVersionReader / 1.13 LobbyZipUpgrader`，原 `1.10-1.13` 后移为 `1.14-1.17`。**`VersionResolver` 按单链 4 层 fallback 实现（第三轮）**

#### ADR-008-A 远端 URL 构造（`RemoteConfigClient`）

- 基础 URL：`"https://" + BundleConfig.gameConfig.replacingOccurrences("-", "/") + ".txt"`
  - 新外壳改 HTTPS（msext 用 HTTP，`NewRootVC.m:250`）。后台已经支持 HTTPS。
- **cache-busting query**（msext `NewRootVC.m:1226, 1585`）：每次拉取时拼 `?vXXXXXXXXYYYYYYYY`（两个 `arc4random()` 各 8 位 hex 连写，**无 `=`**）。**新外壳保留此约定**——服务端可能根据这段 query 做无 cache 处理。

#### ADR-008-B 网络层

- API：`URLSession.data(from:)` async（替代 msext `[NSData dataWithContentsOfURL:]` 主线程同步阻塞）。10s 请求超时 + 30s 资源超时。
- 重试策略：**最初取 3 次 1/2/4 秒指数退避**（已在 1.10 落地）；上线前评估改为 **msext 风格 4s 等间隔无次数上限**——因为 msext 那套"无网静默重试 + 网络恢复立刻进"的体验在地铁场景是合理的，新外壳不应当回退。
- 静默重试：网络错误不弹窗、不打 log，等下次 tick；只在第一次失败时 UI 显示状态（启动图 + "重新连接中"）。这是契约。
- 短文本响应（`NewRootVC.m:1239-1243`）：trim 后 `length ≤ 100` 时**当 alert 内容直接弹窗**，并停止重试。运营杀手锏 #1。**新外壳用 `utf16.count` 实现 1:1 复刻**（msext 用 NSString length 即 UTF-16 unit 计数）。

#### ADR-008-C JSON 解析（`RemoteConfigClient`）

- 服务端响应是 UTF-8 编码的 JSON（扩展名 `.txt` 仅历史包袱）
- 顶层结构：`{ showmessage, agentlist[], scrollmsg, Ads, managerUrl, isclose, menunotice, ... 20+ H5 业务字段 }`
  - 原生**只读** `agentlist + showmessage`，**其余字段 H5 同 URL 再拉一次**自己读
  - Codable 忽略未识别字段（默认行为），新外壳自动满足
- **字段类型混乱（必须兼容）**：`marketid` / `game_version` / `app_version` 等本应为 string 的字段，服务端会发 number。msext 用 NSNumber 静默吞下；**新外壳用 `decodeFlexibleStringIfPresent` 扩展（String / Int / Double / Bool 全兼容）**——已在 1.10 实现
- **解析失败行为**：msext 静默 nil → 后续 for 循环 0 次跑空 → App 静默卡死（缺陷）。新外壳应当**显式抛 `ConfigParseFailed` + UI 弹错误页 + 提供重试按钮**

#### ADR-008-D 单链 4 层 fallback 算法（`VersionResolver`）

> **修订史**：2026-06-22 初版误指为 chulishengji 双子树合并；2026-06-27 第三轮按项目维护者澄清推翻，改为本节描述的单链 4 层 fallback。下文以最终版本为准；第二轮内容已删除，复盘说明见 ADR-008 顶部"第二轮误读复盘"。

新外壳 `VersionResolver.resolve()` 1:1 复刻 `NewRootVC.m:689-810` 主路径，输出 5 个目标字段：`(appVersion, appDownload, gameVersion, gameZip, showmessage)`。

**契约结构**：

```
agentlist[agentid]
  └─ gamelist[gameid]
       └─ channellist[channelid]
            └─ marketlist[marketid]
```

每一层节点都可独立携带 `app_version / app_download / game_version / game_zip / showmessage` 字段（缺省视为空）；越深的层覆盖越浅的层；越深的层缺省时回退到上一层。

**步骤**：

```swift
// 1. 顺链匹配，返回 0..4 个有序节点
//    任一层匹配失败立刻截断（之后不再尝试更深层）
let chain: [RemoteConfigNode] = buildChain(
    config, agentId, gameId, channelId, marketId
)
// 例：agent 命中、game 未命中 → chain = [agent]

// 2. 5 字段共用同一套倒序 fallback 接口
//    从最深层向根回退取第一个非空（等价 msext "下层非空即覆盖上层"的最终态）
ResolvedVersion(
    appVersion:  pickInt(chain,    \.appVersion),
    appDownload: pickString(chain, \.appDownload),
    gameVersion: pickInt(chain,    \.gameVersion),
    gameZip:     pickString(chain, \.gameZip),
    showmessage: pickString(chain, \.showmessage) ?? config.showmessage
)
```

**统一接口要求（必须保持）**：
- 4 层节点（Agent / Game / Channel / Market）conform 同一 `RemoteConfigNode` 协议，提供相同字段 getter
- 字段查找只有 `pickString` / `pickInt` 两个工具，5 字段全走它们 —— 不允许出现"为某个字段写专门 if 链"的多处实现
- `pickString` 跳过 nil / 空字符串；`pickInt` 跳过 nil / 解析失败 / 0（msext `[nil intValue] = 0` 行为兼容）

**与 msext 实际代码的关系**：
- msext 顺嵌套 for 循环里"非空即覆盖"`self.xxx = xxx` 的写法 —— **最终态等价**于从根向叶遍历后取最深一处非空值，再等价于从叶向根 fallback 取第一个非空值。三种写法语义一致，新外壳选第三种因为：(1) 5 字段共用同一接口最简洁；(2) `buildChain` 集中处理"任一层失败截断"语义；(3) 单测易写
- 不复刻 msext 那段把 `showmessage` 散在 5 处覆盖的写法 —— 用 `pickString(\.showmessage)` 一处搞定，最终态相同

#### ADR-008-E 决策顺序（`WebContainer` 编排）

5 字段拿到后**固定顺序**判断（msext 单链主路径在 `NewRootVC.m:768-793`）：

| 优先级 | 条件 | 动作 |
|---|---|---|
| 1 | `showmessage != ""` 且非 nil | 弹 alert（标题 `gamehallname + 提醒`，单按钮"确定"），**完全阻塞**，return |
| 2 | `[version_ios intValue] > [iosNumber intValue]` | 弹 IPA 升级 alert（标题"有新的版本更新，点击前往下载！更新过程中可能会白屏..."，单按钮"确定"，tag PublicTagfive） → Safari 外链 `app_download`，return |
| 3 | 否则 | `uplevel:download:` 内（`NewRootVC.m:1874-1885`）：`gamevers > [versioninfo intValue]` → `downFileFromServer:`；否则 `initView` 直接加载本地 H5 |

#### ADR-008-F LocalVersionReader（1.12）

- **iOS 本地版本**：`BundleConfig.shared.appVersion` 转 Int（来自 ChannelConfig.plist `appversion` 字段，等价 msext `[FuncPublic filename:@"appversion"]`）
- **H5 本地版本**：`Library/Caches/{gamedir}/{gamestart}/version.xml` 解析 `/game/version@value`
  - msext 用 GDataXML（`NewRootVC.m:664-682`）；新外壳用 Foundation `XMLParser`（无外部依赖）
  - **缺失 / 损坏返回 0**（必须保留，是首装后首次升级的兜底机制；msext `[nil intValue] = 0`）

#### ADR-008-G LobbyZipUpgrader（1.13）

- 下载链路：`URLSession.download(from: gameZip)` async + 进度回调
- 解压：临时目录 `staging-{uuid}/` + 原子 `moveItem` rename（不学 msext 先删后压的中断风险）
- **失败处理**：msext `requestFailed:` 只打 NSLog 静默；新外壳应当**弹错误页 + 回退用 Bundle 内 H5**（旧 zip 仍可用）

#### ADR-008-H 5 字段的他用（必须保留）

- `showmessage`：仅 alert，不传 H5 / 不存 UserDefaults
- `version_ios`：① 决策；② `initJSdata` 间接通过 `app_data.js` 注入 `app_appversion` 字段传给 H5（值 0 = gameconfig / 1 = appleconfig，控制 H5 走两条不同分发路径）
- `app_download`：仅本次启动用
- `game_version_` / `game_zip_`：决策 + downFileFromServer 拼下载 URL
- 全部**不存 UserDefaults**（msext 无缓存层）

#### ADR-008-I 与 msext 差异（实施层面，H5 契约不变）

| 维度 | msext 现状 | 新外壳决策 |
|------|----------|----------|
| 网络库 | 主线程同步 `dataWithContentsOfURL:` + ASIHTTPRequest 下载 | `URLSession.data(from:)` + `download(from:)` async，actor 隔离 |
| 重试 | 4s timer 无上限 | 1.10 初版用 3 次退避；可改为 msext 风格无上限 |
| URL | HTTP | HTTPS（后台已切） |
| cache-busting | `?vXXXXXXXX_YYYYYYYY` | **保留** |
| JSON 解析 | SBJSON | Codable + JSONDecoder + 自定义 `decodeFlexibleStringIfPresent`（兼容 number-as-string） |
| 算法 | onnet 单链 4 层（`NewRootVC.m:689-810`） | 单链 4 层 + 协议化 fallback，5 字段共用同一查找接口，纯函数 + 单测 |
| version.xml | GDataXML | Foundation XMLParser |
| 升级解压 | `removeItemAtPath` + ZipArchive | 临时目录 + 原子 rename |
| 失败 UI | 静默 / NSLog | 弹错误页 + 重试按钮（不留卡死黑洞） |

- **回滚条件**：若远端 `.txt` 配置服后台被替换为 RESTful API（路径 / 字段名变化），重新评估并实现新协议
- **影响 Phase 2 及之后**：
  - Phase 6 子游戏升级直接复用 `LobbyZipUpgrader` 的设计（仅参数化目录路径）
  - Phase 10 多渠道打包前 `gameconfig` 注入值由 IPA 后处理工具修改（ADR-007 `plutil` 路径）

---

### ADR-009：凭证 / 七牛运行参数三分制（2026-06-27）

- **背景**：本次修订前，微信 AppID/Secret + 七牛 AccessKey/SecretKey/bucket/CDN 域名分散在 3 处：
  - Swift 源码硬编码：`WeChatSDK.swift`(AppID) / `WeChatAuth.swift`(AppSecret) / `QiniuConfig.swift`(AccessKey/SecretKey/bucket/CDN)
  - `Info.plist` 的 CFBundleURLTypes：微信 AppID 又出现一次（iOS 系统级 URL Scheme，必须在 Info.plist）
  - `ChannelConfig.plist` 的 `qiniudomain`：CDN 域名又一份，但 Swift 代码完全没读取（双源僵尸字段）
- **问题**：无单一真相源 → 多渠道分发 / 后台改后台时容易遗漏某处；CDN 域名出现"plist 改了但代码用硬编码"的潜在 bug
- **决策（三分制）**：
  | 数据维度 | 唯一权威源 | 理由 |
  |---|---|---|
  | 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册，运行期不可注入，Info.plist 是事实唯一可写位置 |
  | 应用级凭证（微信 AppSecret、七牛 AccessKey、七牛 SecretKey） | `AppSecrets.plist` | 跨渠道相同的应用全局凭证；与渠道差异化字段语义分离 |
  | 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`（4 层 fallback + 顶层兜底） | 后台运维管理，按渠道差异化下发 |
- **远端注入时序**：`WebContainerViewController` 在 `parsed` 分支（即 RemoteConfig 拉到、IPA 校验前）调 `VersionResolver.resolve(...)` 一次得到所有字段（含 `audioDomain` / `audioBucket`），再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入；缺失抛 `BootError.audioConfigMissing`，与 showmessage 同等致命，弹 modal 永停
- **统一接口（硬约束）**：远端 RemoteConfig 所有字段的获取**必须**走 `VersionResolver.resolve(...)` 单一入口、走同一套 5 层 fallback：root（顶层）→ agent → game → channel → market，倒序找第一个非空。`RemoteConfig` 自身 conform `RemoteConfigNode` 协议作为链最浅一层兜底，因此 `pickString(chain, \.xxx)` 一行即包含顶层兜底，无需 `?? config.xxx` 这类特殊写法。新增字段：加进 `RemoteConfigNode` 协议 + 5 个 struct（含 `RemoteConfig`）+ `ResolvedVersion` + `resolve()` 内一行 `pick*`
- **2026-06-27 修订**：
  - 初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取，实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底（同 `app_version` / `game_zip` 等字段算法）。
  - 同日二次：曾短暂拆 `resolve()` / `resolveAudio()` 两个公开入口，违反"单一接口"原则。已合并为单一 `resolve()`，所有字段（版本 + 音频 + showmessage）一次返回
  - 同日三次：把 `RemoteConfig` 顶层也作为节点参与 fallback 链（链头 = 根），删掉 `showmessage / audioDomain / audioBucket` 三处特殊的 `?? config.xxx` 兜底；`appVersion / appDownload / gameVersion / gameZip` 也获得顶层兜底能力。所有字段真正完全统一
- **类型设计**：
  - `AppSecrets`：与 `BundleConfig` 同款 `nonisolated public final class Sendable`，3 个不可变 String 属性
  - `QiniuConfig`：从 `enum` 改为 `actor`，`accessKey`/`secretKey` 仍 nonisolated（直接读 `AppSecrets.shared`），`cdnDomain`/`bucketName` 进 actor 状态；`update(...)` / `publicURL(...)` async
  - `QiniuTokenSigner.uploadToken()` 改 async（因 bucketName 来自 actor）
  - `QiniuUploader.upload()` 预取 cdnDomain 一次（避免 SDK 同步 callback 内再 await）
- **删除清单**：
  - `WeChatSDK.swift` `static let appID = "..."` 硬编码 → 改读 Info.plist
  - `WeChatAuth.swift` `static let appSecret = "..."` → 改读 `AppSecrets.shared.wxAppSecret`
  - `QiniuConfig.swift` `static let accessKey/secretKey/bucketName/cdnDomain = "..."` 四处硬编码
  - `ChannelConfig.plist` `<key>qiniudomain</key>` 字段
  - `BundleConfig.swift` `qiniuDomain` 属性（10 key 守护规则同步）
- **回滚条件**：若未来 `Info.plist` 不再允许动态读取 CFBundleURLTypes（极不可能），或 RemoteConfig 接口被替换为按渠道差异化下发（需要进 4 层 fallback），重新评估

---

### ADR-010：WebView 加载从 `file://` 迁到 `ylgame://h5/...`（WKURLSchemeHandler，2026-06-29）

- **背景**：Phase 1.14.d 大厅 + Phase 6.x 子游戏都用 `webView.loadFileURL(_:allowingReadAccessTo:)` 加载 H5。这一路径上线后会面临两个外部驱动的兼容性压力：
  1. **未来 Cocos 子游戏 H5 build 接入**：Cocos 引擎 `assetManager` 用 XHR/fetch 加载 JSON / atlas / 音频 / 纹理；WKWebView 下 `file://` origin 是 null，XHR/fetch 触发 CORS null-origin 拒绝，Cocos 启动期资源加载几乎必踩坑
  2. **跨页 localStorage 共享**：现状 file:// 下因 origin = null 让大厅 + 子游戏天然共享 localStorage（业务依赖）；任何不同 origin 的迁移方案都会破坏这一语义
- **可选方案对比**：
  | 方案 | localStorage 共享 | Cocos 适配 | 离线启动 | 端口冲突 | 三方依赖 | 维护成本 |
  |---|---|---|---|---|---|---|
  | A：自定义 scheme `ylgame://h5/...` + `WKURLSchemeHandler` | ✅ 单虚拟 host (`h5`)，所有页 same-origin | ✅ 同源 XHR/fetch 通畅 | ✅ | 无 | 无（系统 API） | 自实现 handler（约 300 行 Swift） |
  | B：内置 GCDWebServer (`http://localhost:port/...`) | ✅ | ✅（行为最接近浏览器） | ✅ | 有 | 一个新 C 依赖 | 端口探测 + 后台挂起期生命周期 |
  | C：远程真域名 | ✅（远端 origin 也共享） | ✅ | ❌ 启动期必须有网 | 无 | 无 | 启动模式完全打破 |
  | D：保持 file:// | ✅（null origin 共享） | ❌ Cocos XHR 死路 | ✅ | 无 | 无 | 0 |
- **决策：选 A**：在不引入新依赖、不打破离线启动模式、不破坏 localStorage 共享语义的前提下，把所有 H5 入口迁到 `ylgame://h5/lobby/...` / `ylgame://h5/subgame/<dir>/...`。`AppSchemeHandler` 单例反向映射 path → 沙盒文件读盘，支持 Range / MIME / 异步 IO / 取消语义
- **H5 零改动红线**（CLAUDE.md 原则 A 第一准则）：项目方已确认现网 H5 不踩三类红线（`location.protocol === 'http:'` 字面比较 / 硬编码 `file://` 拼链接 / Service Worker 依赖），cookies 也未使用，仅需保留 localStorage 共享语义
- **URL 命名（带项目前缀）**：
  - scheme = `ylgame`（避免与其它 app 自定义 scheme 冲突）
  - 单虚拟 host = `h5`（决定 origin，所有 H5 页必须共用）
  - 大厅：`ylgame://h5/lobby/<gameStart>/index.html`
  - 子游戏：`ylgame://h5/subgame/<dir>/<start>/index.html`
- **关键实现要点**：
  - **背景 IO 队列读盘**，结果 hop 回 MainActor deliver；闭包只捕获 Sendable 的 `ObjectIdentifier` key，不捕获非 Sendable 的 `any WKURLSchemeTask`
  - **取消语义**：`stop` 把 key 加入 `cancelledKeys`；IO 完成后 deliver 见标志直接短路并清理两张表，避免 stop 后向 task 发消息触发 NSException
  - **Range 请求**：解析 `bytes=start-end` / `start-` / `-suffix` → 206 + `Content-Range`（Cocos `<audio>` seek 必需）
  - **MIME 表**：覆盖 Cocos 常用扩展（atlas / fnt / plist / wasm / webp / ogg / m4a / woff2 等），未命中 `application/octet-stream`
  - **响应头**：`Cache-Control: no-cache`（沙盒升级原地覆盖文件）+ `Access-Control-Allow-Origin: *`（防御部分 Cocos build fetch 默认 cors mode）
  - **WKWebsiteDataStore.default()**：所有 WebView 沿用默认（已有），确保 localStorage 持久化跨实例共享
- **行为对比（迁移前后等价性）**：
  | 行为 | file:// 现状 | ylgame://h5 新方案 |
  |---|---|---|
  | 大厅 / 子游戏 H5 加载 | loadFileURL | webView.load(URLRequest) |
  | 跨页 localStorage 共享 | null origin 共享 | 同 host (`h5`) same-origin 共享 |
  | `app_*.js` 同步 `<script src>` 引入 | 沙盒文件同目录 | 同左（handler 透明读相同物理路径） |
  | XHR / fetch 资源 | null origin 受限 | 同源直通 |
  | `<audio>` seek (Range) | WKWebView 原生支持 | handler 实现 206 |
  | 跨目录引用 (`../foo.js`) | allowingReadAccessTo: lobbyRoot | path 自然落回 lobbyRoot |
  | localStorage 持久化 | WKWebsiteDataStore.default() | 同左 |
- **存量用户影响**：file:// → ylgame:// origin 切换时**老用户 localStorage 一次性清零**（WebKit 不会跨 origin 自动迁移）。项目方已确认业务可接受、不做迁移补偿（如未来发现某条数据丢失影响大，可加"隐藏 file:// WebView 读出 storage → 写入 ylgame:// origin"的一次性桥接迁移）
- **回滚条件**：若发现某款 Cocos 子游戏在 `ylgame://` 下踩到无法绕过的红线（如硬编码 `location.protocol` 检查），优先：① 单游戏开 `http://localhost` 逃生门（per-game 配置走 B 方案），不退回全局 file://；② 把"`WebContentLoader` 加载入口"抽象成协议，主路径 A、单游戏 B 共存
- **影响 Phase 6 / Phase 10**：
  - Phase 6 子游戏 push 已自动复用 BridgedWebView 配置 → handler 自动生效，无需额外改动
  - Phase 10 灰度时把 Phase 1.21 验证清单纳入回归矩阵（跨页 localStorage / Cocos 接入预检脚本）

---

文档完成日期：2026-06-21
最后更新：2026-06-29（ADR-010 WebView 加载从 file:// 迁到 ylgame://h5 自定义 scheme + Phase 1.F 三个子项落地；前置历史 2026-06-27 ADR-009 凭证集中化 / 2026-06-22 ADR-008 三轮修订 / ADR-007 ChannelConfig.plist / ADR-006 纯 SPM + Vendor / ADR-005 极光降级）
