# Daoqi iOS 外壳新项目实施设计

> 配套文档：[`H5-Native-Contract.md`](./H5-Native-Contract.md) (契约,必须 1:1 落地)
> 本文档：实现蓝图,开发团队拿到即可启动
> 写作日期：2026-06-21

---

## 项目性质声明(读前必读)

本文档描述的是一个 **greenfield 全新 iOS 项目** 的完整架构设计。

请用"从零开始建一个新 iOS App"的姿态阅读:

- 文件结构、命名、模块边界、技术栈、并发模型、依赖选型 — 完全独立设计,按 Swift / iOS 现代最佳实践
- 一切原生内部实现追求 **架构优雅、高效、高性能、专业、成熟**
- 不背任何历史包袱,不复刻任何既有 iOS 项目的代码
- 整体姿态:**架构师从零起设计一个新 App**,而不是"工程师维护/重构一个老 App"

### 设计中需要"照搬"什么

只有以下三类外部契约要求新项目按既定形式落地,其它一切自由:

| 契约类型 | 来源 | 必须照搬什么 | 文档位置 |
|---------|------|------------|---------|
| H5 契约 | H5 端业务代码 | 桥接口名 / 参数字段名 / 数据结构 / JS 协议 / 初始化字面值 | `H5-Native-Contract.md` |
| 服务端契约 | 后台 API | 签名算法 / 字段顺序 / URL 路径 / 错误码 | 本文档 §6 |
| 三方 SDK 契约 | 微信/QQ/高德/Agora/七牛 SDK 文档 | 注册顺序 / 文件类型 flag / 回调签名 | 本文档 §4 §14 |

**判断某项是否属于契约的方法**:问"外部能否感知"。能感知 → 照搬;不能 → 自由设计。

### 渐进集成原则(未启用 SDK 处理方式)

部分桥接 handler 当前业务**暂未使用**(子游戏视频房间、闲聊分享、Bugly 监控等)。新项目首版**不集成这些 SDK**,但 **handler 仍然必须注册**,内部 stub 实现 —— 立即调 `responseCallback` 返回契约要求的字面值,不做实际业务,不依赖任何未集成的 SDK。

理由:
- H5 端代码不知道哪些 SDK 没集成,它仍然会调 `createRoom` / `friendsSharetypeUrlToptitleDescript(sharetype=3)` 等。若 handler 不注册,H5 会走"bridge handler not found"错误分支,业务流程中断
- Stub handler 保证契约不被违反,即使功能没启用也不会引起 H5 端报错
- 未来某天业务启动这些功能时,只需把 stub 换成真实 SDK 调用,**契约边界不需要任何改动**

详细的 stub 清单见 §5 / §8 / §14。

---

## 0. TL;DR

| 项 | 决策 |
|----|------|
| 语言 | **Swift 5.10+**(Swift 6 strict concurrency 渐进开启) |
| 最低系统 | **iOS 14.0**(覆盖 99.5% 现役设备,删掉所有 iOS <9 旧路径) |
| UI 层 | **UIKit + Storyboard 极少**,以代码 UI 为主;不引入 SwiftUI(WebView 容器场景不受益) |
| 并发 | **async/await + Actor**,弃用 Combine 与 GCD 散落用法 |
| Web 渲染 | **WKWebView** 单例 + ProcessPool 复用 |
| H5 桥 | 自研 Swift 桥(实现 WebViewJavascriptBridge 的 JS 端协议作为契约边界)+ 弹层注入 `window.settings.*` polyfill |
| 网络 | **URLSession + async/await + Codable**,自研 HTTPClient,Endpoint 协议化 |
| 持久化 | UserDefaults + FileManager;**不引入 CoreData/SQLite** |
| 依赖管理 | **纯 SPM + Vendor `.xcframework` 手动**(完全不引入 CocoaPods 工具链);开源 / 官方 SPM 走 SPM,闭源二进制无 SPM 的走 Vendor 手动 |
| 架构 | **Modular Clean Architecture** + **Coordinator 路由** |
| 监控 | **默认接入 Sentry**(崩溃 + 性能 + breadcrumb);提供编译开关 `SENTRY_ENABLED` 作为关闭逃生口 |
| 包名 / Target | 与现 `msext` 解耦,新名 `Daoqi`(支持企业签 / 超签 / TF 同包并存) |
| 上架 | 由项目方业务决定;设计层面不预设(若上架则补 PrivacyInfo.xcprivacy / ATT 文案,模块化解耦不影响其它代码) |

---

## 1. 设计目标 & 非目标

### 1.1 目标

1. **契约零偏差** — H5 不改一行代码,在新外壳上行为与现网一致(以 `H5-Native-Contract.md` §10 验收清单为准)
2. **架构优雅** — 模块边界清晰,单一职责;新增桥接接口/SDK 只动一处
3. **高性能** — 启动 < 1.5 s 进大厅 H5;桥接消息往返 < 16 ms(单帧);视频房间 1080p@30 稳定
4. **专业成熟** — 包含完整的崩溃监控/日志/单测/集成测/CI 流程;团队随时可换人维护
5. **可演进** — 新增渠道/功能不需要回改既有模块;SDK 可热替换(如 Agora → 自研)

### 1.2 非目标(避免过度设计)

- 不做 ARM64 之外的架构(armv7/armv7s 已无现役设备)
- 不做 iPadOS 适配优化(产品定位是 iPhone)
- 不引入 SwiftUI(纯 WebView 容器,SwiftUI 不会更快也不会更优雅)
- 不做 Combine + RxSwift 任何一种响应式流(用 async/await 足矣)
- 不做云原生 / 后端服务(范畴外)

---

## 2. 模块化架构

### 2.1 顶层依赖图

```
┌──────────────────────────────────────────────┐
│                    App                       │  入口、Coordinator、生命周期
└──────────────────────────────────────────────┘
            │              │              │
            ▼              ▼              ▼
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
│    Features     │ │     Core     │ │    SDKs     │
│  (Lobby/SubGame │ │ (Bridge/Net/ │ │  (WeChat/   │
│   /Overlay/...) │ │  Resource)   │ │  Agora/...) │
└─────────────────┘ └──────────────┘ └─────────────┘
            │              │              │
            └──────────────┴──────────────┘
                           │
                           ▼
                ┌──────────────────────┐
                │     Foundation       │  纯工具、扩展、协议
                └──────────────────────┘
```

**依赖方向严格自上而下**,任意一层不引用同层兄弟模块、不反向依赖。

### 2.2 SPM 包结构

按 Swift Package Manager 切分,每个目录是一个 SPM target:

```
Daoqi/
├── Package.swift                 # 顶层 SPM 配置(多 target)
├── Sources/
│   ├── App/                      # 入口
│   ├── AppCoordinator/           # 路由
│   ├── Lobby/                    # 大厅 feature
│   ├── SubGame/                  # 子游戏 feature
│   ├── Overlay/                  # 弹层 feature
│   ├── BridgeCore/               # H5 桥核心(协议+消息总线)
│   ├── BridgeHandlers/           # 桥 handler 注册(纯函数)
│   ├── WebViewKit/               # WKWebView 封装、JS 注入
│   ├── NetworkKit/               # HTTPClient、SGGateway facade
│   ├── ResourceKit/              # gamehall.zip 解压、渠道注入、目录管理
│   ├── AudioKit/                 # 录音/播放/AMR-WAV 转码
│   ├── ShareKit/                 # 分享统一入口
│   ├── LoginKit/                 # 微信授权
│   ├── LocationKit/              # 高德定位
│   ├── DeviceKit/                # 设备信息/电池/网络/振动
│   ├── VideoRoomKit/             # Agora 封装
│   ├── AnalyticsKit/             # Sentry(默认启用) / JAnalytics 极光(暂不集成,Noop 占位)
│   ├── Logger/                   # 统一日志
│   └── Foundation+/              # 通用扩展
├── Vendor/                       # 闭源 SDK(.framework / .a)
│   ├── WechatSDK.framework       # 启用
│   ├── AMapLocationKit.framework # 启用
│   └── libopencore-amr*.a        # 启用
│   # 暂不集成(对应代码模块用 Noop 占位):
│   # - JAnalytics.framework      # 启用用户统计时再放入
│   # - XianliaoSDK.framework     # 启用闲聊分享时再放入
│   # - AgoraRtcKit.framework     # 启用视频房间时再放入(或改用 SPM)
├── Resources/
│   ├── gamehall.zip
│   ├── Info.plist
│   ├── Daoqi.entitlements
│   ├── Assets.xcassets
│   └── ChannelInjection/         # 11 个渠道注入目录,打包脚本生成
│       ├── qiniudomain/
│       ├── gameid/
│       ├── channel/
│       ├── gamedir/
│       ├── gamestart/
│       ├── gameconfig/
│       ├── market/
│       ├── agent/
│       ├── appversion/
│       ├── other/
│       └── appleconfig/
├── Tests/                        # 单测
│   ├── BridgeCoreTests/
│   ├── ResourceKitTests/
│   ├── NetworkKitTests/
│   └── ...
├── UITests/                      # XCUI 集成测
├── Scripts/                      # 打包/渠道注入脚本
│   ├── inject_channel.sh
│   ├── archive.sh
│   └── version_bump.sh
└── Daoqi.xcworkspace             # Xcode 工作区
```

> **切分粒度提示**:上述 17 个 SPM target 是初始规划,M0 立项时跑一次 `xcodebuild -scheme Daoqi clean build` baseline 测量。若 clean build 时间 > 60s,把强耦合模块合并:`Lobby` / `SubGame` / `Overlay` 合为 `Containers`;`DeviceKit` 并入 `Foundation+`;`AudioKit` 与 `ShareKit` 看依赖深度可保持独立或合并。**目标稳态 8-10 个 target**,以日常开发体验为先。

### 2.3 模块职责矩阵

| 模块 | 暴露能力 | 内部依赖 |
|------|---------|---------|
| `BridgeCore` | `BridgeProtocol`、`BridgeMessage`、`BridgeBus`(actor) | Foundation+ |
| `WebViewKit` | `BridgedWebView`(包装 WKWebView+消息桥), `JSInjector` | BridgeCore, ResourceKit, Foundation+ |
| `BridgeHandlers` | `LobbyHandlers`、`SubGameHandlers`、`OverlayHandlers` | BridgeCore, 各 Kit(AudioKit/ShareKit/...) |
| `Lobby` / `SubGame` / `Overlay` | ViewController + Coordinator | WebViewKit, BridgeHandlers |
| `NetworkKit` | `HTTPClient`(async), `GameAPIClient`(签名 + Endpoint) | Foundation+ |
| `ResourceKit` | `BundleConfig`(渠道读取), `ResourceUnzipper`, `SandboxPaths` | Foundation+ |
| `AudioKit` | `AudioRecorder`, `AudioPlayer`, `VoiceCoder`(AMR↔WAV) | ResourceKit, NetworkKit |
| `ShareKit` | `ShareCenter`(协议) → `WeChatShare` 启用 / `XianliaoShare` 暂以 `NoopSharePlatform` 替代 | SDK Wrappers |
| `LoginKit` | `WeChatAuth`(async OAuth) | SDK Wrappers, NetworkKit |
| `LocationKit` | `LocationService` | AMapKit Wrapper |
| `DeviceKit` | `DeviceInfo`, `BatteryMonitor`, `NetworkMonitor`, `Vibrator`, `Pasteboard` | Foundation+ |
| `VideoRoomKit` | `VideoRoom`(协议) → `NoopVideoRoom` 当前启用 / `AgoraVideoRoom` 留蓝图 | (Agora SDK 暂未引入) |
| `AnalyticsKit` | `CrashReporter`(协议) → `SentryCrashReporter` 启用 / `Tracker`(协议) → `NoopAnalytics` 当前启用 / `JAnalyticsTracker` 留蓝图 | SDK Wrappers |

---

### 2.4 WebContainer 容器模型与生命周期

大厅 / 子游戏 / 弹层在新架构中是**同一种 `WebContainer` 的不同角色**,共享基类,差异只在注入的 handler 集合和入口 URL:

```
WebContainerViewController(基类 ~200 行)
├── WKWebView + ProcessPool 复用
├── BridgeBus 创建与公共 handler 注册委托
├── JSInjector(app_data.js / app_battery.js / app_network.js)
├── 生命周期 → 桥事件(appservice / getBattery / getnetwork)
├── 摇一摇 motionEnded → shakeEnd
└── 外部订阅生命周期管理(suspend/resume)
        ▲
        │ 继承 + 注入
   ┌────┴────────────────────┐
   ▼            ▼            ▼
Lobby        SubGame      Overlay
(20 handler) (23 handler) (3 handler)
+ 大厅 URL    + 子游戏 URL   + 外链 URL
              + VideoRoom    + JSExport polyfill
              + CallCenter
              + RecordUploader
```

#### 2.4.1 状态保持原则:push 不销毁

大厅 → 子游戏使用 `UINavigationController.pushViewController`,**大厅 VC + WebView + Bridge + JS 上下文全程保留**在 navigation stack 里。子游戏 → 大厅用 `popViewController`,大厅状态原封不动恢复。

理由:
- H5 端业务隐式依赖大厅状态(列表滚动、玩家余额、待办通知等)
- `getWebdata` callback 要求大厅 handler 一直注册着,新建大厅会丢数据
- 返回瞬时(无 loadFileURL 黑屏)

#### 2.4.2 外部订阅暂停 — 避免桥事件双发

旧架构隐患:大厅和子游戏都监听 `enterForeground` / 电池 / 网络 等通知,前后台切换时**两个都响应**,H5 收到双倍桥事件。

新架构方案:WebContainer 用 viewWillAppear / viewWillDisappear 管理外部订阅生命周期,**任何时刻只有最顶层可见 VC 向 H5 派发桥事件**:

```swift
public class WebContainerViewController: UIViewController {
    private let externalSubs: ExternalSubscriptions  // 注入

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        externalSubs.resume()
        // resume 内部:bind battery/network/lifecycle 监听
        // 立即派发一次当前状态(避免 H5 错过暂停期间的变化)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        externalSubs.suspend()
        // suspend 内部:unbind 所有监听,WebView 不释放,JS 继续运行
    }
}

@MainActor
final class ExternalSubscriptions {
    weak var bridge: BridgeProtocol?
    private let battery = BatteryMonitor()
    private let network = NetworkMonitor()
    private let lifecycle = AppLifecycleObserver()

    func resume() {
        battery.onChange = { [weak bridge] level in
            bridge?.call("getBattery", data: .string(String(format: "%.2f", level)), callback: nil)
        }
        network.onChange = { [weak bridge] state in
            bridge?.call("getnetwork", data: .string(state.rawValue), callback: nil)
        }
        lifecycle.onChange = { [weak bridge] state in
            // 值错位沿用历史 msext WKWebView 路径：背景="2" / 前台="1"
            bridge?.call("appservice", data: .string(state == .background ? "2" : "1"), callback: nil)
        }
        battery.start(); network.start(); lifecycle.start()
        // 立即派发一次,补齐暂停期间的状态
        battery.emitNow(); network.emitNow()
    }

    func suspend() {
        battery.stop(); network.stop(); lifecycle.stop()
        // 解绑回调,避免在子游戏 push 上来后大厅仍收到事件
    }
}
```

#### 2.4.3 栈深约束 ≤ 2 — 防止内存堆栈

旧架构隐患:H5 连续调 SwitchOverGameData 时,navigation stack 不断增长(`[Lobby, SubA, SubB, SubC, ...]`),内存爆炸。

新架构方案:Coordinator 在路由时显式判断:

```swift
@MainActor
final class AppCoordinator {
    private let nav: UINavigationController

    /// 进入子游戏:保证 nav stack 最多两层(Lobby + 当前 SubGame)
    func showSubGame(_ params: SwitchGameParams) {
        if nav.topViewController is SubGameViewController {
            nav.popViewController(animated: false)   // 先退出当前子游戏
        }
        let vc = SubGameViewController(params: params, coordinator: self)
        nav.pushViewController(vc, animated: true)
    }

    /// 打开弹层(Overlay 是浮层,允许 [Lobby, SubGame, Overlay] 三层)
    func showOverlay(_ params: OverlayParams) {
        let vc = OverlayViewController(params: params, coordinator: self)
        nav.pushViewController(vc, animated: true)
    }

    func popToLobby() { nav.popToRootViewController(animated: true) }
}
```

→ 任意 H5 操作下,navigation stack 最多 3 层(Lobby + SubGame + Overlay),内存占用可预测。

#### 2.4.4 WebView 数据隔离策略

新架构主张大厅 / 子游戏 / 弹层共用同一个 `WKProcessPool` 加速启动,但**默认共享 Cookie 和 LocalStorage**(因为它们都属于同一个 dataStore)。这在不同源 H5 间可能产生意外串扰。

策略:
- **大厅 + 子游戏** 共用 `WKWebsiteDataStore.default()`(都是项目自家 H5,共享业务态合理 — 比如登录态、用户偏好等)
- **弹层 Overlay**(加载第三方外链时)使用 `WKWebsiteDataStore.nonPersistent()` 私有 store,Cookie / LocalStorage 不写盘,**不污染主业务态**

```swift
extension WKWebsiteDataStore {
    static let businessStore = WKWebsiteDataStore.default()       // 大厅 + 子游戏
    static let overlayStore  = WKWebsiteDataStore.nonPersistent() // 弹层外链
}
```

OverlayViewController 在 WKWebViewConfiguration 上设置 `websiteDataStore = .overlayStore` 即可隔离。

#### 2.4.5 副作用清单:进入 / 退出子游戏的强制动作

无论 H5 端逻辑如何变化,以下副作用由 Coordinator + Handler 在路由时**强制完成**,不允许 H5 端遗漏:

| 时机 | 强制副作用 |
|------|----------|
| 大厅 → 子游戏(SwitchOverGameData) | ① `audio.stopBackground()` ② `WXApiManager` delegate 切上下文(详见 §8.4) |
| 子游戏 → 大厅(backgameData) | ① `audio.stopBackground()` ② `videoRoom.leave()` ③ 通过 `.subGameDidReturn` 通知 LobbyHandlers 回调 `getWebdata` |
| 进入 Overlay(OpenurlTitleData) | ① 节流(3 秒内重复打开吞掉) |
| 退出 Overlay(settings.finishweb / settings.backgameData) | ① 弹层 backgameData 同样发 `.subGameDidReturn` 通知,LobbyHandlers / SubGameHandlers 监听并 callback `getWebdata` 给当前 active 容器的 H5 |

---

## 3. H5 桥接核心实现

### 3.1 设计要点

WVJB 的 JS 端协议很简单:页面加载完成后,JS 会执行 `_setupWebViewJavascriptBridge`,把队列 message 通过 `iframe src` 或 `console.log` 通知原生。新外壳**自己实现 WVJB-JS 端的协议**,而不是引用 OC 版 WebViewJavascriptBridge 库:

- 优点:纯 Swift、无 OC 依赖、可严格并发安全、单元测试容易
- 落地路径:把 WebViewJavascriptBridge 的 JS 端代码(`WebViewJavascriptBridge.js.txt`)作为 WKUserScript 注入到 WKWebView,原生侧自研 `WKScriptMessageHandler` 接收并分发消息

### 3.2 协议骨架

```swift
// BridgeCore/BridgeProtocol.swift
public protocol BridgeProtocol: AnyObject, Sendable {
    /// 注册一个 H5 → Native handler。重复名覆盖。
    func register(_ name: String, handler: @escaping BridgeHandler)

    /// 主动调用 H5 端的 handler。callback 可选。
    func call(_ name: String, data: BridgeData?, callback: BridgeCallback?)
}

public typealias BridgeHandler = @Sendable (BridgeData?, BridgeCallback?) async -> Void
public typealias BridgeCallback = @Sendable (BridgeData?) -> Void

public enum BridgeData: Sendable {
    case string(String)
    case number(Double)
    case bool(Bool)
    case null
    case array([BridgeData])
    case object([String: BridgeData])

    // 字段访问语法糖
    public subscript(key: String) -> BridgeData? { ... }
    public var asString: String? { ... }
    public var asInt: Int? { ... }
}
```

### 3.3 关键实现

```swift
// WebViewKit/BridgedWebView.swift
@MainActor
public final class BridgedWebView: UIView {
    public let webView: WKWebView
    public let bridge: BridgeBus

    public init(config: WebViewConfig) {
        let cfg = WKWebViewConfiguration()
        cfg.processPool = SharedProcessPool.shared      // 进程池复用
        cfg.preferences.javaScriptEnabled = true
        cfg.preferences.javaScriptCanOpenWindowsAutomatically = false
        cfg.preferences.minimumFontSize = 10
        cfg.allowsInlineMediaPlayback = true

        // 注入 WVJB-JS 协议
        let userScript = WKUserScript(
            source: JSInjector.wvjbProtocol,
            injectionTime: .atDocumentStart,
            forMainFrameOnly: true)
        cfg.userContentController.addUserScript(userScript)

        self.webView = WKWebView(frame: .zero, configuration: cfg)
        self.bridge = BridgeBus(webView: webView, controller: cfg.userContentController)
        super.init(frame: .zero)
        layout()

        // 契约 §4.1:与现网保持的 ScrollView 配置
        webView.scrollView.contentInsetAdjustmentBehavior = .never  // iOS 11+
        webView.scrollView.bounces = false
        webView.scrollView.showsVerticalScrollIndicator = false
        webView.scrollView.showsHorizontalScrollIndicator = false
        webView.scrollView.isScrollEnabled = false                  // ⚠️ 契约要求,漏则 H5 上下滑动失控
    }
}

// BridgeCore/BridgeBus.swift
@MainActor
public final class BridgeBus: BridgeProtocol {
    private var handlers: [String: BridgeHandler] = [:]
    private weak var webView: WKWebView?
    private var pendingCallbacks: [String: BridgeCallback] = [:]
    private var callbackCounter = 0

    public func register(_ name: String, handler: @escaping BridgeHandler) {
        handlers[name] = handler
    }

    public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) {
        var payload: [String: Any] = ["handlerName": name]
        if let data = data { payload["data"] = data.jsonObject }
        if let callback = callback {
            callbackCounter += 1
            let cbId = "objc_cb_\(callbackCounter)"
            pendingCallbacks[cbId] = callback
            payload["callbackId"] = cbId
        }
        let json = try! JSONSerialization.data(withJSONObject: payload)
        let js = "WebViewJavascriptBridge._handleMessageFromObjC('\(json.base64)')"
        webView?.evaluateJavaScript(js)
    }

    // 接收 H5 → Native
    func didReceive(_ message: [String: Any]) async {
        guard let name = message["handlerName"] as? String else { return }
        let data = (message["data"]).map(BridgeData.from)
        let cbId = message["callbackId"] as? String
        let cb: BridgeCallback? = cbId.map { id in
            { [weak self] resp in self?.call(id, data: resp, callback: nil) }
        }
        if let handler = handlers[name] {
            await handler(data, cb)
        } else {
            Log.warn("Bridge: no handler for \(name)")
            cb?(nil)
        }
    }
}
```

### 3.4 弹层桥(window.settings polyfill)

H5 弹层页约定使用 `window.settings.xxx(...)` 同步调用形式(契约边界)。新项目弹层 WebView 用 WKWebView,通过注入一段 polyfill 把 `window.settings` 转换为 `WKScriptMessageHandler` 消息:

```swift
// WebViewKit/OverlayBridge.swift
public enum OverlayBridge {
    public static let polyfill = """
    window.settings = {
        backgameData: function(data) {
            window.webkit.messageHandlers.overlayBackgameData.postMessage(String(data));
        },
        browser: function(url) {
            window.webkit.messageHandlers.overlayBrowser.postMessage(String(url));
        },
        finishweb: function() {
            window.webkit.messageHandlers.overlayFinishweb.postMessage("");
        }
    };
    """
}
```

3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。

#### 3.4.1 ⚠️ 撤销：大厅 / 子游戏的 `window.settings.getXxx()` polyfill（无需实现）

> 本子节早期版本（commit `82bad8a`）曾设计 9 项同步 getter polyfill（`getchannelName` / `getmarketname` / `getothername` / `getbattery` / `getnetwork` / `getcompareCode` / `getGameinstall` / `getGameplay` / `getOther`），把 §附录 A 旧桥 JSExport 方法重新实现为 `window.settings.getXxx()` JS 函数。**经原项目深度审查后撤销**，理由：
>
> 1. **契约自身已明示可不实现**：Contract §3.3 标题"关于 iOS<9 旧桥的精确签名（**附录用，新外壳可不实现**）"，§附录 A 同样声明"这是 iOS<9 路径使用的旧协议，新外壳如最低系统 ≥ iOS 14 可不实现"。本项目最低 iOS 15.6 → 不需要
> 2. **H5 端真实路径是 §4.2 的 `app_*.js` 文件预注入**：H5 业务代码通过 `<script src="app_data.js">` 同步引入预生成的 15 个 `app_xxx` 全局变量直接读值，**不调** `window.settings.getXxx()`。详见 **§7.5 H5 `app_*.js` 预注入文件机制**
> 3. **过度设计**：原 polyfill 把数据快照塞进 `window.__nativeSnapshot` 全局对象 + 写一遍 getter 函数，实际工作量比直接写 `var app_xxx = ...` 文件大；同时引入了"两条路径都存在但 H5 只读其中一条"的混淆
>
> **保留的部分**：§3.4 头部的 `OverlayBridge.polyfill`（3 项 `backgameData` / `browser` / `finishweb`）是**弹层 threeView 内**的契约硬约束（Contract §3.4 明示"与系统版本无关，iOS 9+ 也走这条"），与本子节撤销的旧桥 getter polyfill 不是同一回事，继续保留。
>
> Phase 2+ 实施时直接按 §7.5 落地 `app_*.js` 文件预写，本子节内容**不要照搬**。

#### 3.4.1.legacy 9 项 getter 的等价数据来源（仅供 §7.5 映射参考）

**背景**：契约 §附录 A 列出 28 项旧桥 JSExport 方法（iOS<9 路径），与 §3.1 异步 callback 主表去重后多出 **9 项 H5 同步只读 getter**：

| getter | 返回类型 | 业务语义 | 数据源 |
|---|---|---|---|
| `getchannelName()` | string | 渠道 ID（11 项渠道注入之一）| `BundleConfig.shared.channel` |
| `getmarketname()` | string | 市场 ID（11 项渠道注入之一）| `BundleConfig.shared.market` |
| `getOther()` | string | 渠道 `other` 字段 | `BundleConfig.shared.other` |
| `getothername(name)` | string | 按 H5 传入 key 动态读 11 项渠道注入任意字段 | `BundleConfig.shared.value(forKey: name)` |
| `getcompareCode()` | int | 业务校验码（msext `RootVC.m` 沿用 zip 版本号或固定值） | 待原 msext 取值确认（Phase 2 实施时查 `RootVC.m:1560` 附近 `getcompareCode` 真实返回值，并对齐） |
| `getbattery()` | double | 当前电池电量 0.0–1.0 | `UIDevice.current.batteryLevel`（启动期 snapshot 一次） |
| `getnetwork()` | int | 当前网络类型（0 无 / 1 WiFi / 2 蜂窝） | `NWPathMonitor` 当前 path（loadFileURL 前 snapshot） |
| `getGameinstall(name)` | int | 子游戏目录是否存在（0/1） | 扫 `SandboxPaths.subGameRoot(name)` 后注入已安装列表 |
| `getGameplay(jsondata)` | void | 契约 §3.1 ［19］**空实现**，H5 仍会调，原生 noop 即可 | — |

**为什么不走 BridgeBus 异步 callback**：H5 端代码形式是 `var ch = window.settings.getchannelName()`、`var b = window.settings.getbattery()` 等**同步表达式**（取值后立即用于业务判断），WKWebView 时代 native 无法同步返回 JS 值（异步 evaluateJavaScript 改不了 H5 端代码 → 违反契约原则 A）。唯一不破契约的实现路径：**WebView 加载前在 `documentStart` 注入完整数据快照 + 同步 JS getter polyfill**，本地查询零延迟。

**数据快照时机**：
- 静态字段（渠道 / market / other / appVersion 等 11 项渠道注入）：app 启动期读 `ChannelConfig.plist` 后即不变，全程一次即可
- 动态字段（getbattery / getnetwork）：**每次 `loadFileURL` 前重新 snapshot 注入**（精度足够，原 msext 自身也只在 `viewDidLoad` 取一次，H5 业务里"启动时刻电量值"被复用整个会话）
- 已安装子游戏列表（getGameinstall）：每次 loadFileURL 前扫描沙盒 + 注入 → SwitchOverGameData 解压新子游戏后自然在下次 loadFileURL 刷新

**实现骨架**：

```swift
// Source/WebView/SettingsBridgePolyfill.swift
//
// 把契约 §附录 A 的 9 项 H5 同步 getter 实现为 documentStart 注入的 JS polyfill，
// 数据全部本地查询、零延迟，与原 msext JSExport 同步行为等价。
//
// 注入流程：
//   WebContainerViewController.loadFileURL(lobbyIndex) 调用前
//     → SettingsBridgePolyfill.makeUserScript(BundleConfig + DeviceSnapshot + InstalledGames)
//     → BridgedWebView 把 WKUserScript 加到 WKUserContentController
//     → loadFileURL → H5 一加载即可同步读 window.settings.getXxx()

@MainActor
public enum SettingsBridgePolyfill {

    /// 构造一段 documentStart 注入的 JS。data 是 Native 端拼好的快照。
    public static func makeUserScript(snapshot: Snapshot) -> WKUserScript {
        let json = (try? JSONSerialization.data(withJSONObject: snapshot.jsonObject))
            .flatMap { String(data: $0, encoding: .utf8) }
            ?? "{}"
        let source = """
        (function() {
            window.__nativeSnapshot = \(json);
            window.settings = window.settings || {};

            // ── 11 项渠道注入（静态，启动期一次性快照）────────────
            window.settings.getchannelName  = function() { return window.__nativeSnapshot.channel || ""; };
            window.settings.getmarketname   = function() { return window.__nativeSnapshot.market || ""; };
            window.settings.getOther        = function() { return window.__nativeSnapshot.other || ""; };
            window.settings.getothername    = function(name) {
                if (!name) return "";
                return (window.__nativeSnapshot.channelConfig || {})[name] || "";
            };

            // ── 业务校验码 + 设备动态字段（每次 loadFileURL 前刷新）──
            window.settings.getcompareCode  = function() { return window.__nativeSnapshot.compareCode || 0; };
            window.settings.getbattery      = function() { return window.__nativeSnapshot.battery || 0.0; };
            window.settings.getnetwork      = function() { return window.__nativeSnapshot.network || 0; };

            // ── 子游戏安装查询（每次 loadFileURL 前快照已安装列表）─
            window.settings.getGameinstall  = function(name) {
                if (!name) return 0;
                var list = window.__nativeSnapshot.installedGames || [];
                return list.indexOf(name) >= 0 ? 1 : 0;
            };

            // ── 已知空实现（契约 §3.1 ［19］，H5 仍会调）────────────
            window.settings.getGameplay     = function(_jsondata) { /* no-op */ };
        })();
        """
        return WKUserScript(source: source,
                            injectionTime: .atDocumentStart,
                            forMainFrameOnly: true)
    }

    public struct Snapshot: Sendable {
        public let channelConfig: [String: String]   // 11 项渠道注入完整字典
        public let channel: String
        public let market: String
        public let other: String
        public let compareCode: Int
        public let battery: Double
        public let network: Int                       // 0/1/2
        public let installedGames: [String]

        public var jsonObject: [String: Any] {
            [
                "channelConfig":   channelConfig,
                "channel":         channel,
                "market":          market,
                "other":           other,
                "compareCode":     compareCode,
                "battery":         battery,
                "network":         network,
                "installedGames":  installedGames
            ]
        }

        /// 从 BundleConfig + DeviceKit + SandboxPaths 拼装当前快照
        @MainActor
        public static func make() -> Snapshot {
            let bc = BundleConfig.shared
            return Snapshot(
                channelConfig: bc.asDictionary,
                channel:       bc.channel,
                market:        bc.market,
                other:         bc.other,
                compareCode:   CompareCodeProvider.current(),    // 见下文
                battery:       DeviceKit.batteryLevel(),
                network:       NetworkMonitor.shared.currentTypeCode,
                installedGames: SandboxPaths.installedSubGames()
            )
        }
    }
}
```

**`WebContainerViewController` 接入点**：

```swift
private func runBootPipelineSteps() async throws {
    // ... ensureReady / fetch / resolve / upgrade ...

    // 6. loadFileURL 前注入 settings polyfill
    splash.update(text: "加载大厅...", progress: nil)
    let snapshot = SettingsBridgePolyfill.Snapshot.make()
    bridgedWebView.installSettingsPolyfill(snapshot: snapshot)
    bridgedWebView.webView.loadFileURL(
        SandboxPaths.lobbyIndex,
        allowingReadAccessTo: SandboxPaths.lobbyRoot
    )
}

// Source/WebView/BridgedWebView.swift
extension BridgedWebView {
    /// 在 contentController 重置后追加 polyfill UserScript，
    /// 然后 loadFileURL 即可让 H5 在 documentStart 同步访问 window.settings.getXxx()
    public func installSettingsPolyfill(snapshot: SettingsBridgePolyfill.Snapshot) {
        let controller = webView.configuration.userContentController
        // 注：WebViewJavascriptBridge.js 这条 atDocumentStart UserScript 在 init 时
        // 已加入并保持不变；这里只追加 settings polyfill，互不影响
        controller.addUserScript(SettingsBridgePolyfill.makeUserScript(snapshot: snapshot))
    }
}
```

**与原 msext 的差异**：

| 维度 | msext 现状 | 新外壳决策 |
|------|----------|---------|
| JS↔Native 桥 | iOS 9+ JSContext + JSExport（同步原生返回值） | WKUserScript 注入 + 纯 JS polyfill（同步本地返回） |
| 数据传递 | JS 每次调用 selector → 进 ObjC runtime → 返回 | 启动期一次性 snapshot 注入 JS 全局，业务期纯 JS 查询 |
| 渠道注入读取 | `[FuncPublic getFilePath:@"other" PathType:3]` 扫目录 | 直接读 `BundleConfig.shared.channelConfig` 字典 |
| 性能 | 每次 H5 调用都有 JSContext 跨域开销（µs 级） | 业务期纯 JS 查询（ns 级） |
| 跨容器一致性 | 大厅 / 子游戏 / 弹层各自暴露 selector，要同步维护 | 同一份 `SettingsBridgePolyfill` 多处复用，单点定义 |

**注意事项**：
- `compareCode` 业务语义需对照 msext `RootVC.m:1560` 附近 `getcompareCode` 真实返回逻辑（Phase 2 实施前查清）；当前 Design 暂列字段，实现时补真值
- 验收：契约 §10 验收清单里所有"H5 同步取渠道值 / 设备状态"的项目（如 `window.settings.getchannelName() === channel注入值`）通过即视为契约等价
- 子游戏容器（SubGame WebContainer）也走同一份 polyfill，仅 `installedGames` 字段在子游戏内意义不同（一般不会再调 getGameinstall）

#### 3.4.2 OpenurlTitleData handler — 大厅打开 threeView 弹层（契约 §3.1 ［15］）

`OpenurlTitleData` 不在弹层内部，而是**大厅** WVJB 桥的异步 handler，作用是**触发**一个新的 OverlayViewController（threeView 等价容器）push 到导航栈，让 H5 弹层页面在 OverlayViewController 内运行。OverlayViewController 内部的 H5 才用 §3.4 的 `window.settings.{backgameData/browser/finishweb}` polyfill 通讯。

```swift
// Source/Bridge/Handlers/OpenurlTitleDataHandler.swift
@MainActor
public struct OpenurlTitleDataHandler {

    let bridge: BridgeProtocol
    let coordinator: LobbyCoordinator    // 负责 push OverlayViewController

    /// 节流状态：第一次调用后 3 秒内被吞掉（first_Time 标志）
    /// 用类持有可变状态而非 actor，因为本 handler 必须在 MainActor，
    /// 且 last open Date 只在主线程读写
    private final class Throttle {
        var lastOpen: Date?
    }
    private let throttle = Throttle()

    public func register() {
        bridge.register("OpenurlTitleData", handler: handle)
    }

    // MARK: - 【15】 OpenurlTitleData
    //
    // 契约 §3.1 ［15］：
    //   入参 url           : string   待加载 URL（HTTP）
    //   入参 "title "      : string   ⚠️ 键名末尾有空格，沿用历史，必须 obj["title "]
    //   入参 data          : string   业务数据，透传给 threeView 内的 H5
    //   入参 orientation   : int 0=竖屏 / 1=横屏（实际本项目固定横屏，参考即可）
    //   responseCallback   : "OpenurlTitleData"
    //   节流              : 第一次调用后 3 秒内重复调用被吞掉（first_Time 标志）

    private func handle(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("OpenurlTitleData")) }

        // 1. 节流：3 秒内重复调用直接返回（cb 仍要回，否则 H5 会等）
        let now = Date()
        if let last = throttle.lastOpen, now.timeIntervalSince(last) < 3 {
            Log.info("OpenurlTitleData throttled (last open \(String(format: "%.2f", now.timeIntervalSince(last)))s ago)")
            return
        }
        throttle.lastOpen = now

        // 2. 解析入参（注意 "title " 末尾空格）
        guard let obj = data?.asObject,
              let urlStr = obj["url"]?.asString,
              let url = URL(string: urlStr)
        else {
            Log.warn("OpenurlTitleData: bad url payload")
            return
        }
        let title       = obj["title "]?.asString ?? ""            // ⚠️ "title "
        let payload     = obj["data"]?.asString ?? ""
        let orientation = obj["orientation"]?.asInt ?? 1           // 默认横屏

        // 3. 触发 push OverlayViewController
        let request = OverlayRequest(
            url:         url,
            title:       title,
            data:        payload,
            orientation: orientation
        )
        coordinator.pushOverlay(request)
    }
}

public struct OverlayRequest: Sendable {
    public let url:         URL
    public let title:       String
    public let data:        String
    public let orientation: Int     // 0=竖屏 / 1=横屏（沿用契约，本项目实际只用横屏）
}
```

**OverlayViewController 接入**：

```swift
extension LobbyCoordinator {
    public func pushOverlay(_ request: OverlayRequest) {
        let vc = OverlayViewController(request: request)
        navigationController?.pushViewController(vc, animated: true)
    }
}

@MainActor
public final class OverlayViewController: UIViewController {
    private let request: OverlayRequest
    private let webView: WKWebView

    public init(request: OverlayRequest) {
        self.request = request
        // 配 WKWebView：注入 §3.4 window.settings polyfill（backgameData/browser/finishweb）
        let cfg = WKWebViewConfiguration()
        cfg.userContentController.addUserScript(
            WKUserScript(source: OverlayBridge.polyfill,
                         injectionTime: .atDocumentStart,
                         forMainFrameOnly: true)
        )
        // 把 H5 业务 data 透传到 JS 全局（H5 弹层页代码读 window.app_data 获取）
        let escaped = request.data
            .replacingOccurrences(of: "\\", with: "\\\\")
            .replacingOccurrences(of: "\"", with: "\\\"")
        cfg.userContentController.addUserScript(
            WKUserScript(source: "window.app_data = \"\(escaped)\";",
                         injectionTime: .atDocumentStart,
                         forMainFrameOnly: true)
        )
        self.webView = WKWebView(frame: .zero, configuration: cfg)
        super.init(nibName: nil, bundle: nil)
        title = request.title.trimmingCharacters(in: .whitespaces)
    }
    // 略：webView 布局 + load(request) + WKScriptMessageHandler 注册 3 个 polyfill 消息
}
```

#### 3.4.3 与原 msext 的差异

| 维度 | msext 现状 | 新外壳决策 |
|------|----------|----------|
| 弹层 WebView | iOS 8 时代 `UIWebView` + JSExport `Bridgetwo` | `WKWebView` + `WKUserScript` 注入 polyfill（同步 settings.* 等价） |
| `"title "` 末尾空格 | `[dict objectForKey:@"title "]` | `obj["title "]?.asString` 严格保持，注释说明 |
| 节流 first_Time | `static BOOL first_Time` + `NSTimer` 3 秒后重置 | 闭包内 final class 持 `Date` lastOpen，更线程友好 |
| H5 数据透传 | `[js evaluateScript:[NSString stringWithFormat:@"app_data='%@'", data]]` | `WKUserScript` atDocumentStart 注入，避免 race |
| orientation 入参 | 入参 string 转 int | BridgeData.asInt 直接拿；本项目实际固定横屏 |

### 3.5 性能优化

- **ProcessPool 复用**:大厅、子游戏、弹层共用同一个 `WKProcessPool`,Cookie/Cache 共享,避免重复初始化(800 ms → 50 ms)
- **JS 注入用 `WKUserScript`**:而不是 `evaluateJavaScript`,在 document load 之前就完成,避免 race
- **桥消息批处理**:H5 端 message queue 累积一帧再 flush,减少 evaluateJavaScript 次数
- **结构化日志**:Bridge 全链路用 `os_log` 类型化,Release 自动去 `.debug` 级别

### 3.6 WebView 进程崩溃恢复

iOS 系统在内存压力下会 kill `WKWebView` 的 Web Content Process,UI 上表现为**整个 WebView 变成白屏**,JS 全停,但 native 这边的 `WKWebView` 对象本身还在 — 系统不会自动回调 navigation delegate,如果不主动处理,用户看到的就是一个永久白屏的页面。

WebContainerViewController 必须实现 `webViewWebContentProcessDidTerminate:`:

```swift
extension WebContainerViewController: WKNavigationDelegate {

    func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
        Log.warn("WKWebView content process terminated, attempting reload")
        crashReporter.captureMessage("WebContentTerminated:\(self.containerRole)", level: .warning)

        let now = Date()
        recentTerminations.append(now)
        recentTerminations = recentTerminations.filter { now.timeIntervalSince($0) < 60 }

        if recentTerminations.count >= 3 {
            // 1 分钟内连续 3 次崩溃 → 不再自动 reload,弹错误页让用户主动重试
            showFatalErrorView()
            return
        }

        // 退避:第 1 次立即,第 2 次 1s,第 3 次 3s
        let delay = TimeInterval(recentTerminations.count - 1) * (recentTerminations.count - 1)
        DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak webView] in
            webView?.reload()
        }
    }
}
```

要点:
- **必须 reload**,不能依赖系统自动恢复(WebKit 故意不自动 reload,让 App 自己决定策略)
- **崩溃次数退避**:连续多次崩溃通常意味着真实 bug 或低内存,无限 reload 会陷入循环。1 分钟 ≥ 3 次切到 fallback UI
- **Sentry 上报**:每次崩溃都打 breadcrumb,持续高崩溃率应该触发告警
- **大厅 / 子游戏 / 弹层都受这条覆盖**,因为它们都继承自 WebContainerViewController

### 3.7 WKUIDelegate alert / confirm（契约 §4.3）

H5 用 `alert(msg)` / `confirm(msg)` 触发原生弹窗，WKWebView 默认行为是**直接吞掉不弹**——必须在容器 VC 上实现 `WKUIDelegate` 才能让 H5 业务的提示信息正常显示。这是契约 §4.3 的硬约束，**漏掉直接导致 H5 任何 alert/confirm 无响应**。

涉及 2 项接口（不在 §3.1 主表，单列）：
1. `runJavaScriptAlertPanelWithMessage` — H5 调 `alert(msg)`
2. `runJavaScriptConfirmPanelWithMessage` — H5 调 `confirm(msg)` 期待返回 true/false

```swift
// Source/WebView/WebContainerViewController.swift（追加 WKUIDelegate 扩展）
extension WebContainerViewController: WKUIDelegate {

    // MARK: - 【契约 §4.3】 H5 alert(msg)
    //
    //   标题：BundleConfig.shared.appDisplayName（msext 用 gamehallname 常量）
    //   按钮：单"确定"
    //   completionHandler 必须调一次（否则 WKWebView 永久阻塞同一帧的 JS 执行）

    public func webView(_ webView: WKWebView,
                        runJavaScriptAlertPanelWithMessage message: String,
                        initiatedByFrame frame: WKFrameInfo,
                        completionHandler: @escaping () -> Void) {
        let alert = UIAlertController(
            title: appDisplayName,
            message: message,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
            completionHandler()
        })
        present(alert, animated: true)
    }

    // MARK: - 【契约 §4.3】 H5 confirm(msg)
    //
    //   标题：同 alert
    //   按钮：确定 / 取消，分别 completionHandler(true) / completionHandler(false)
    //   completionHandler 必须以 bool 调一次

    public func webView(_ webView: WKWebView,
                        runJavaScriptConfirmPanelWithMessage message: String,
                        initiatedByFrame frame: WKFrameInfo,
                        completionHandler: @escaping (Bool) -> Void) {
        let alert = UIAlertController(
            title: appDisplayName,
            message: message,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "取消", style: .cancel) { _ in
            completionHandler(false)
        })
        alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
            completionHandler(true)
        })
        present(alert, animated: true)
    }
}
```

接入：`bridgedWebView.webView.uiDelegate = self` 在 `viewDidLoad` 跟 `navigationDelegate` 一起设置。

#### 3.7.1 与原 msext 的差异

| 维度 | msext 现状 | 新外壳决策 |
|------|----------|---------|
| 标题文案 | `gamehallname` PCH 常量 | `appDisplayName`（CFBundleDisplayName / CFBundleName，自包含） |
| confirm 按钮顺序 | "取消" 在左 / "确定" 在右 | 严格保持（iOS UIAlertController 默认 cancel 在左 + default 在右） |
| 弹窗样式 | UIAlertController + .alert | 同款 |
| 多帧并发 | msext 偶发"alert 串"叠加，体验略乱 | 当前同时只允许一个 alert（present 队列）；如未来 H5 业务有真并发，再加 pendingAlertQueue |

> ⚠️ 不实现这两个方法的代价：H5 业务里 `alert("登录失败")` 之类的提示**用户完全看不见**，业务流程哑火无可视化报错。是 Phase 1.17 真机联调时最容易暴露的缺漏点，因此 §10 验收清单单列。

---

## 4. 启动流程重新设计

### 4.1 整体时序(目标 < 1.5 s 进 H5)

```
SceneDelegate.scene(_:willConnectTo:options:)        T=0
    │
    ├─► (并行) BundleConfig.preload()                T=0   ←  渠道注入读取
    │       ↓ 同步读 11 个目录名,纯内存,~5 ms
    │
    ├─► (并行) SDK 注册                              T=0
    │       • WeChat                  ~10 ms
    │       • AMap (privacy + key)    ~15 ms
    │       • crashReporter.start()   ~20 ms(Sentry 默认启用)
    │       (JAnalytics 极光 / Xianliao 闲聊 / Agora 声网 / Bugly 当前未集成,见 §14.2)
    │       并发 Task,await 全部完成
    │
    ├─► Window 创建 + LobbyViewController push       T=50ms
    │
    ▼
LobbyViewController.viewDidLoad
    │
    ├─► (并行) ResourceUnzipper.ensureReady()        T=50ms
    │       • 检查 Library/Caches/{gamedir}/{gamestart}/version.xml
    │       • 不存在 → 异步解压 gamehall.zip(后台队列)
    │       • 存在 → 直接通过
    │
    ├─► (并行) BridgedWebView 创建 + 注册 20 handler T=50ms
    │       • 同步,~30 ms
    │
    ▼                                                  ▼
    await Both                                       T=300ms (假设首次解压 250ms,缓存命中 < 50ms)
    │
    ├─► JSInjector.writeAppData(...)                 T=300ms
    │       • app_data.js / app_battery.js / app_network.js
    │
    ├─► webView.loadFileURL(...)                     T=320ms
    │       • file:// 加载,WebKit 内部 ~500 ms 到 DOMContentLoaded
    │
    ▼
DOMContentLoaded → JS 桥握手完成                       T < 1.5s
```

### 4.2 关键代码

```swift
// App/SceneDelegate.swift
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options: UIScene.ConnectionOptions) {
        guard let scene = scene as? UIWindowScene else { return }

        Task { @MainActor in
            await Bootstrapper.shared.run()                // 并发 SDK 注册
            let lobby = LobbyViewController()
            let nav = NavigationController(rootViewController: lobby)
            window = UIWindow(windowScene: scene)
            window?.rootViewController = nav
            window?.makeKeyAndVisible()
        }
    }
}

// App/Bootstrapper.swift
@MainActor
final class Bootstrapper {
    static let shared = Bootstrapper()
    private(set) var isReady = false

    func run() async {
        async let cfg: Void = BundleConfig.preload()
        async let sdks: Void = registerSDKs()
        _ = await (cfg, sdks)
        isReady = true
    }

    private func registerSDKs() async {
        await withTaskGroup(of: Void.self) { group in
            group.addTask { WeChatSDK.registerFull(appId: AppIDs.weChat) }   // 含 contentFlag
            // JAnalytics(极光) / XianliaoSDK(闲聊) / Agora(声网) 暂不集成(§14.2)
            // — 对应模块用 Noop 占位,首版不参与启动注册
            group.addTask { AMapWrapper.configure() }
            group.addTask { crashReporter.start() }     // SentryCrashReporter by default; Noop if SENTRY_ENABLED=0
        }
    }
}

// SDK/WeChat/WeChatSDK.swift
public enum WeChatSDK {
    /// 完整注册:WXApi + 文件类型支持
    /// (registerAppSupportContentFlag 必须包含 DOC/DOCX/PPT/PPTX/XLS/XLSX/PDF 等
    ///  否则微信分享对应文件类型的回包识别失败 — 此为微信 SDK 契约)
    public static func registerFull(appId: String) {
        WXApi.registerApp(appId, enableMTA: true)
        let typeFlag: UInt64 =
            MMAPP_SUPPORT_TEXT | MMAPP_SUPPORT_PICTURE | MMAPP_SUPPORT_LOCATION
          | MMAPP_SUPPORT_VIDEO | MMAPP_SUPPORT_AUDIO | MMAPP_SUPPORT_WEBPAGE
          | MMAPP_SUPPORT_DOC  | MMAPP_SUPPORT_DOCX  | MMAPP_SUPPORT_PPT
          | MMAPP_SUPPORT_PPTX | MMAPP_SUPPORT_XLS   | MMAPP_SUPPORT_XLSX
          | MMAPP_SUPPORT_PDF
        WXApi.registerAppSupportContentFlag(typeFlag)
    }
}
```

> ⚠️ 漏 `registerAppSupportContentFlag` 会导致微信分享 PDF/DOCX/PPT/XLS 等业务文件回包识别失败 — 这是微信 SDK 的契约要求,任何 iOS App 接入分享都必须设置。

### 4.2.1 启动期容易遗漏的契约点

以下几项是 **H5 / SDK 契约要求**,在 Bootstrapper 或 SceneDelegate 内必须显式完成。容易遗漏是因为它们看起来是"散落小事",但任一漏掉都会破坏 H5 业务:

| 配置项 | 为什么必须 | 新项目落地 |
|--------|----------|-----------|
| `UIApplication.shared.applicationSupportsShakeToEdit = true` | 不设则 `motionEnded:withEvent:` 收不到摇一摇 → H5 调 `startshake` 后没有 `shakeEnd` 回调,业务断链 | SceneDelegate `sceneDidBecomeActive` 一次性设置 |
| Info.plist `UIStatusBarHidden = NO` + `UIViewControllerBasedStatusBarAppearance = YES` | H5 设计稿基于显示状态栏的可视区域绘制,隐藏会导致布局错位 | Info.plist 直接配,各 VC 重写 `preferredStatusBarStyle` 返回 `.default` |
| 写入 NSUserDefaults: `everLaunched` / `getcompareCode=0` / `FirstBOOL=set0` / `SecondBOOL=set0` / `ThirdBOOL=set0` / `VersionInfo=1.0` | 这些 key 通过 app_data.js 暴露给 H5,key 名与初值是 H5 契约 | KVStore 在首次启动时写入;key 名直接 hardcode,不允许重命名 |
| 首次启动写 `Library/Caches/{gamedir}/{gamestart}/app_gamesname.js`,内容 `var  app_gamesname=new Array('{gamestart}');` (注意 `var` 后两个空格) | H5 启动时静态 `<script>` 引入,缺则全局变量未定义 → H5 黑屏 | ResourceUnzipper 解压完成后,检测 `everLaunched=NO` 即生成此文件 |

### 4.3 URL Scheme 回调

iOS 13+ 用 `SceneDelegate`,处理顺序保持 QQ → 微信(契约 §1.2 要求,QQ 先判否则被 WXApi 吞):

```swift
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    for ctx in URLContexts {
        if QQShareSDK.handle(url: ctx.url) { continue }
        WeChatSDK.handle(url: ctx.url)
    }
}
```

### 4.4 前后台通知命名变更

旧 `AppDelegate` 用了**反人类命名**:
- 通知名 `enterForeground` ⇒ 实际触发于"进入后台"(`applicationDidEnterBackground`)
- 通知名 `applicationWillResignActive` ⇒ 实际触发于"回到前台"(`applicationDidBecomeActive`,且节流 0.3s)

新外壳**采用正确命名**(NSNotification 是模块内通讯,不是契约边界,可以自由命名),但**桥事件不变**:

```swift
extension Notification.Name {
    static let appDidEnterBackground = Notification.Name("Daoqi.appDidEnterBackground")
    static let appDidBecomeActive    = Notification.Name("Daoqi.appDidBecomeActive")
}

// SceneDelegate
func sceneDidEnterBackground(_ scene: UIScene) {
    NotificationCenter.default.post(name: .appDidEnterBackground, object: nil)
}
func sceneDidBecomeActive(_ scene: UIScene) {
    NotificationCenter.default.post(name: .appDidBecomeActive, object: nil)
}

// LobbyHandlers / SubGameHandlers 内监听并转桥
// 注：契约值与 NSNotification 字面语义相反，沿用 msext WKWebView 路径历史。
//     真·进入后台 → "2"；真·回到前台 → "1"。详见 Contract.md §3.2 `appservice` 值错位说明。
notifications.observe(.appDidEnterBackground) { [bridge] _ in
    bridge.call("appservice", data: .string("2"), callback: nil)   // 契约:进入后台="2"
}
notifications.observe(.appDidBecomeActive) { [bridge] _ in
    bridge.call("appservice", data: .string("1"), callback: nil)   // 契约:回到前台="1"
}
```

**0.3s 节流决策**:旧外壳在 `applicationDidBecomeActive` 里设了 `flag=NO` + 0.3s 后恢复,防止双重触发。新外壳**默认不要这个节流**,理由:
1. 节流的根因是旧代码在 `applicationDidBecomeActive` 内被多处触发,新外壳只有 SceneDelegate 一处发通知
2. 测试如果证实双触发,加节流是 1 行代码的事(`DispatchQueue.asyncAfter`),不是架构改动
3. H5 端通常对重复 `appservice("1")`（回前台）是幂等的(就是刷新 UI 状态)

如真机回归发现 H5 双触发产生问题,在 LobbyHandlers 内加 throttle 即可,不污染 SceneDelegate。

### 4.5 子游戏 → 大厅数据回传通知

旧外壳:子游戏 `backgameData` handler 发 `NSNotification "backgameDatatwo"`,大厅监听后转 `bridge.call("getWebdata", data)`。

新外壳同样是模块内通讯,**可以自由命名**。推荐用专属 `Notification.Name`:

```swift
extension Notification.Name {
    static let subGameDidReturn = Notification.Name("Daoqi.subGameDidReturn")
}

// SubGameHandlers 内
private func backgameData(_ data: BridgeData?, _ cb: BridgeCallback?) async {
    defer { cb?(.string("backgameData")) }
    audio.stopBackground()
    videoRoom.leave()
    NotificationCenter.default.post(name: .subGameDidReturn,
                                     object: data?.asString ?? "")
    coordinator.popSubGame()
}

// LobbyHandlers 内
notifications.observe(.subGameDidReturn) { [bridge] note in
    let payload = note.object as? String ?? ""
    bridge.call("getWebdata", data: .string(payload), callback: nil)
}
```

→ 新通知名只在 `Daoqi.*` 内部模块间使用,与契约的 `backgameDatatwo` 字符串无关。但**测试用例必须验证**:子游戏调 `backgameData` → 大厅 H5 收到 `getWebdata`。

---

## 5. 桥 Handler 注册组织

### 5.1 一个 handler 一个文件

避免上千行的 VC,每个 handler 拆成纯函数:

```swift
// BridgeHandlers/Lobby/LobbyHandlers.swift
struct LobbyHandlers {
    let bridge: BridgeProtocol
    let audio: AudioKit
    let share: ShareCenter
    let login: WeChatAuth
    let location: LocationService
    let device: DeviceKit
    let coordinator: LobbyCoordinator

    func register() {
        bridge.register("accreditlogin", handler: accreditLogin)
        bridge.register("srcIsloop",     handler: srcIsLoop)
        bridge.register("prepareaudio",  handler: prepareAudio)
        bridge.register("mediaTypeAudio",handler: mediaTypeAudio)
        bridge.register("startshake",    handler: startShake)
        bridge.register("stopshake",     handler: stopShake)
        // ... 20 个全列出
    }

    private func accreditLogin(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("Response from accreditlogin")) }
        do {
            let user = try await login.authorize()
            bridge.call("sharelogin", data: .object([
                "openid":     .string(user.openid),
                "headimgurl": .string(user.headimgurl),
                "nickname":   .string(user.nickname),
                "sex":        .string(String(user.sex)),
                "city":       .string(user.city),
                "Province":   .string(user.province),     // ⚠️ 大写 P,契约要求
                "unionid":    .string(user.unionid)
            ]), callback: nil)
        } catch {
            Log.error("WeChat auth failed: \(error)")
        }
    }
    // ...
}
```

### 5.2 子游戏额外 handler

```swift
// BridgeHandlers/SubGame/SubGameHandlers.swift
struct SubGameHandlers {
    let lobbyShared: LobbyHandlers       // 共享 19 个
    let videoRoom: VideoRoom              // 当前注入 NoopVideoRoom(见 §8.3)
    let bridge: BridgeProtocol
    let callCenter: CallCenterMonitor
    let recordUploader: RecordUploader

    func register() {
        lobbyShared.registerCommon()             // 19 个共享(SwitchOverGameData 除外)
        bridge.register("backgameData",  handler: backgameData)
        // 视频房间 3 个 handler 当前业务暂未使用,stub 实现保契约
        bridge.register("createRoom",    handler: createRoomStub)
        bridge.register("getVideoinfo",  handler: getVideoInfoStub)
        bridge.register("exitRoom",      handler: exitRoomStub)
        // SwitchOverGameData 不注册:子游戏内不再跳子游戏

        bindSubGameOnlyCallbacks()
    }

    // MARK: - 视频房间 stub(契约要求注册,业务暂未启用)

    private func createRoomStub(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        Log.info("createRoom called (stub, video room not enabled)")
        cb?(.string("createRoom"))
        // 故意不做任何业务;真实现见 §8.3 AgoraVideoRoom,启用时把构造器注入换掉即可
    }

    private func getVideoInfoStub(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        Log.info("getVideoinfo called (stub)")
        cb?(.string("getVideoinfo"))
    }

    private func exitRoomStub(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        Log.info("exitRoom called (stub)")
        cb?(.string("exitRoom"))
    }

    /// 子游戏独有的 3 个反向 callback(契约 §3.2 表 [13]/[14]/[15])
    /// 这是大厅页**没有**的事件源,必须在 SubGame 容器内独立挂钩
    private func bindSubGameOnlyCallbacks() {
        // 1) Native→H5 getVideoinfo:远端用户首帧解码后
        //    NoopVideoRoom 永远不会触发此回调,业务启用后自动生效,H5 无感
        videoRoom.didReceiveRemoteVideo = { [weak bridge] uid in
            bridge?.call("getVideoinfo", data: .string(String(uid)), callback: nil)
        }

        // 2) Native→H5 phonestate:CTCallCenter 状态变化(独立于视频房间,正常启用)
        callCenter.onStateChange = { [weak bridge] state in
            bridge?.call("phonestate", data: .string(state == .incoming ? "2" : "0"),
                         callback: nil)
        }

        // 3) Native→H5 recordSuccess:七牛上传成功(独立于视频房间,正常启用)
        recordUploader.onUploaded = { [weak bridge] file in
            bridge?.call("getaudiourl", data: .object([
                "audiourl": .string(file.publicUrl),
                "time":     .string(String(file.durationSeconds))
            ]), callback: nil)
            bridge?.call("recordSuccess", data: .object([
                "fileUrl":  .string(file.publicUrl),
                "fileName": .string(file.fileName),
                "fileKey":  .string(file.qiniuKey)
            ]), callback: nil)
        }
    }
}
```

> ⚠️ 大厅页(LobbyHandlers)**不应**注册 phonestate / recordSuccess / getVideoinfo 这 3 个 callback,否则 H5 在大厅就收到会触发业务分支错误。**只有进入 SubGame 容器才能挂钩**。
>
> ✅ 视频房间业务启用时,只需把 `videoRoom` 依赖从 `NoopVideoRoom` 换为 `AgoraVideoRoom`(§8.3),并把 3 个 stub handler 换成真实现 — H5 端无感,契约不变。

### 5.3 反向 callback 全列在 §3.2 文档

```swift
// 反向通知 H5 的辅助 facade
extension BridgeProtocol {
    func callShareSuccess(type: String) {
        call("sharesuccess", data: .object([
            "success": .string("2"),
            "type":    .string(type)
        ]), callback: nil)
    }

    func callAppService(_ state: AppState) {
        // 值错位沿用历史 msext WKWebView 路径：背景="2" / 前台="1"
        call("appservice", data: .string(state == .background ? "2" : "1"), callback: nil)
    }
    // ... 15 个全列出
}
```

---

## 6. 网络层

### 6.1 现代 HTTPClient

```swift
// NetworkKit/HTTPClient.swift
public actor HTTPClient {
    private let session: URLSession

    public init(config: URLSessionConfiguration = .default) {
        config.timeoutIntervalForRequest = 20
        config.urlCache = URLCache(memoryCapacity: 8 * 1024 * 1024,
                                    diskCapacity: 64 * 1024 * 1024)
        self.session = URLSession(configuration: config)
    }

    public func send<T: Decodable>(_ endpoint: Endpoint<T>) async throws -> T {
        var req = URLRequest(url: endpoint.url)
        req.httpMethod = endpoint.method.rawValue
        req.allHTTPHeaderFields = endpoint.headers
        req.httpBody = endpoint.body

        let (data, resp) = try await session.data(for: req)
        guard let http = resp as? HTTPURLResponse, http.isSuccess else {
            throw NetworkError.badStatus
        }
        return try endpoint.decoder.decode(T.self, from: data)
    }

    public func download(_ url: URL, to dest: URL,
                          progress: ((Double) -> Void)? = nil) async throws {
        // ... 下载 zip 用,带进度
    }
}
```

### 6.2 GameAPIClient(业务接口客户端)

业务接口客户端按 Swift 现代设计,使用 actor 隔离 + Endpoint 协议化 + Codable 解码。

#### 服务端契约:签名算法

服务端在校验请求签名,因此**签名算法与字段顺序是网络对外契约**,必须按现网约定实现:

- 算法:`MD5(按业务字段固定顺序拼接的 value 字符串 + salt)`
- salt:`"WERTY#$&(HJKfghjWERTYUIFGJFGHadf2222"`(项目方协调更换需先与服务端对齐)
- 字段顺序:**按业务接口约定**(每个接口有自己的字段序列),`code` 字段不参与签名,签名结果写回 `code`

Swift `Dictionary` 是无序的,直接遍历 `values` 会产生不稳定的签名 → 用有序结构 `OrderedParams` 显式持有字段顺序。

#### 全新设计:OrderedParams + GameAPIClient

```swift
// NetworkKit/OrderedParams.swift
public struct OrderedParams: Sendable, ExpressibleByDictionaryLiteral {
    public private(set) var items: [(key: String, value: String)] = []

    public init(dictionaryLiteral elements: (String, String)...) {
        items = elements
    }

    public mutating func set(_ key: String, _ value: String) {
        if let i = items.firstIndex(where: { $0.key == key }) {
            items[i] = (key, value)
        } else {
            items.append((key, value))
        }
    }
}

// NetworkKit/GameAPIClient.swift
public actor GameAPIClient {
    public static let shared = GameAPIClient()
    private let client: HTTPClient
    private static let salt = "WERTY#$&(HJKfghjWERTYUIFGJFGHadf2222"

    public init(client: HTTPClient = .init()) { self.client = client }

    public func send<Response: Decodable>(
        _ params: OrderedParams,
        to path: String,
        method: APIMethod = .post,
        as type: Response.Type = Response.self
    ) async throws -> Response {
        var p = params
        if p.items.contains(where: { $0.key == "code" }) {
            p.set("code", Self.sign(p))
        }
        let baseURL: URL = (method == .postTwo) ? Endpoints.serverTwo : Endpoints.server
        let endpoint = Endpoint<Response>(
            url: baseURL.appendingPathComponent(path),
            method: method.httpMethod,
            body: URLFormEncoder.encode(p),
            headers: ["Content-Type": "application/x-www-form-urlencoded"])
        return try await client.send(endpoint)
    }

    /// 服务端契约:按 items 顺序拼 value,追加 salt,MD5
    static func sign(_ params: OrderedParams) -> String {
        let payload = params.items
            .filter { $0.key != "code" }
            .map { $0.value }
            .joined()
        return (payload + salt).md5
    }
}

// 业务接口定义:每个接口的字段顺序写在业务层
public enum BizAPI {
    public static func getGroupPic(userId: String) async throws -> GroupPicResponse {
        let p: OrderedParams = [
            ("t", "get_group_pic"),
            ("i", userId),
            ("code", "")
        ]
        return try await GameAPIClient.shared.send(p, to: "")
    }
}
```

#### 契约测试

```swift
// Tests/Contract/SigningContractTests.swift
final class SigningContractTests: XCTestCase {
    func test_sign_isStableAcrossRuns() {
        let p: OrderedParams = [("t","x"), ("i","100"), ("code","")]
        XCTAssertEqual(GameAPIClient.sign(p), GameAPIClient.sign(p))   // 同输入同输出
    }

    func test_sign_matchesServerSpec() {
        // 固定 fixture,与服务端工程师对齐过的 (input → expected hash)
        let p: OrderedParams = [("t","get_group_pic"), ("i","100"), ("code","")]
        XCTAssertEqual(GameAPIClient.sign(p), "<expected MD5>")
    }
}
```

### 6.3 远程配置 + 版本对比 + zip 升级流水线（**Phase 1 实施**，ADR-008 详细记录）

完整链路由 4 个独立模块构成，全部 actor 隔离 + 单测覆盖：

```
RemoteConfigClient → VersionResolver → LocalVersionReader → LobbyZipUpgrader
  (拉 .txt 配置)     (4 级覆盖合并)     (读 version.xml)      (URLSession 下载 + 原子 rename)
       ↓                ↓                    ↓                       ↓
   RemoteConfig    ResolvedVersion       (appVer, gameVer)        UpgradeOutcome
                  {appVer, appDL,                                  .noop / .upgraded
                   gameVer, gameZip}
```

#### 6.3.1 RemoteConfigClient

```swift
// Source/Network/RemoteConfigClient.swift
public actor RemoteConfigClient {
    private let session: URLSession
    private let urlBuilder: () -> URL

    /// 默认根据 BundleConfig.shared.gameConfig 拼装：
    ///   http:// + gameConfig.replacingOccurrences("-", "/") + ".txt"
    /// 注意：原 msext 没有 SERVERNew 前缀（NewRootVC.m:250），照搬。
    public init(
        session: URLSession = .shared,
        urlBuilder: @escaping () -> URL = Self.defaultURL
    ) { ... }

    /// 拉远端 .txt（实际是 JSON），10s 超时。
    /// 指数退避 1/2/4 秒最多 3 次（msext 用 4s 等间隔 timer，新外壳更省电）。
    public func fetch() async throws -> RemoteConfig {
        for attempt in 0..<3 {
            do { return try await fetchOnce() }
            catch where attempt < 2 {
                try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
            }
        }
        throw RemoteConfigError.allRetriesFailed
    }
}

public struct RemoteConfig: Codable, Sendable {
    public let showmessage: String?
    public let agentlist: [Agent]?
}
public struct Agent: Codable, Sendable {
    public let agentid: String
    public let showmessage: String?
    public let app_version: String?
    public let app_download: String?
    public let game_version: String?
    public let game_zip: String?
    public let channellist: [Channel]?
    public let gamelist: [Game]?
}
public struct Channel: Codable, Sendable { ... }
public struct Game: Codable, Sendable { ... }
public struct Market: Codable, Sendable { ... }
```

#### 6.3.2 VersionResolver（单链 4 层 fallback，纯函数）

> **修订史**：2026-06-22 初版误指为 chulishengji 双子树合并；2026-06-27 按项目维护者澄清推翻，改为本节描述的单链 4 层 fallback。复盘见 Plan ADR-008 顶部"第二轮误读复盘"。

**契约结构**（参 daoqi `NewRootVC.m:689-810` 主路径）：

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

每层节点都可独立携带 `app_version / app_download / game_version / game_zip / showmessage`；越深层覆盖越浅层；越深层缺省时回退上一层。算法上等价于"从最深层向根回退取第一个非空"。

**统一接口（关键设计点）**：4 个节点类型 conform 同一 `RemoteConfigNode` 协议，5 个字段全部走 `pickString` / `pickInt` 两个倒序查找工具 —— **不允许为某个字段单独写 if 链**。

```swift
// Source/Network/VersionResolver.swift

private protocol RemoteConfigNode {
    var appVersion: String?  { get }
    var appDownload: String? { get }
    var gameVersion: String? { get }
    var gameZip: String?     { get }
    var showmessage: String? { get }
}
extension Agent: RemoteConfigNode {}
extension Game: RemoteConfigNode {}
extension Channel: RemoteConfigNode {}
extension Market: RemoteConfigNode {}

public struct ResolvedVersion: Sendable, Equatable {
    public let appVersion: Int        // 0 表示远端未指定
    public let appDownload: String?
    public let gameVersion: Int       // 0 表示远端未指定
    public let gameZip: String?
    public let showmessage: String?   // 非空时阻塞，运营杀手锏
}

public enum VersionResolver {

    public static func resolve(
        config: RemoteConfig,
        agentId: String,
        channelId: String,
        marketId: String,
        gameId: String
    ) -> ResolvedVersion {
        let chain = buildChain(
            config: config,
            agentId: agentId, gameId: gameId,
            channelId: channelId, marketId: marketId
        )

        return ResolvedVersion(
            appVersion:  pickInt(chain,    \.appVersion),
            appDownload: pickString(chain, \.appDownload),
            gameVersion: pickInt(chain,    \.gameVersion),
            gameZip:     pickString(chain, \.gameZip),
            showmessage: pickString(chain, \.showmessage) ?? config.showmessage
        )
    }

    /// 顺 agent → game → channel → market 匹配，任一层失败立刻截断。
    /// 返回 0..4 个有序节点（从根到叶）。
    private static func buildChain(
        config: RemoteConfig,
        agentId: String, gameId: String,
        channelId: String, marketId: String
    ) -> [RemoteConfigNode] {
        var chain: [RemoteConfigNode] = []
        guard let agent = config.agentlist?.first(where: { $0.agentid == agentId })
        else { return chain }
        chain.append(agent)
        guard let game = agent.gamelist?.first(where: { $0.gameid == gameId })
        else { return chain }
        chain.append(game)
        guard let channel = game.channellist?.first(where: { $0.channelid == channelId })
        else { return chain }
        chain.append(channel)
        guard let market = channel.marketlist?.first(where: { $0.marketid == marketId })
        else { return chain }
        chain.append(market)
        return chain
    }

    /// 倒序找第一个非空字符串
    private static func pickString(
        _ chain: [RemoteConfigNode],
        _ keyPath: KeyPath<RemoteConfigNode, String?>
    ) -> String? {
        for node in chain.reversed() {
            if let s = node[keyPath: keyPath], !s.isEmpty { return s }
        }
        return nil
    }

    /// 倒序找第一个能解析为非零整数的字段值
    /// JSON 里该字段可能是 string / int / double，Codable 已经 decodeFlexibleStringIfPresent
    /// 归一化为 String?；0 视同未提供继续向上找。
    private static func pickInt(
        _ chain: [RemoteConfigNode],
        _ keyPath: KeyPath<RemoteConfigNode, String?>
    ) -> Int {
        for node in chain.reversed() {
            if let s = node[keyPath: keyPath], let v = Int(s), v != 0 { return v }
        }
        return 0
    }
}
```

**关键测试用例**（单测必须覆盖）：

| 用例 | 验证 |
|------|------|
| 仅 agent 层有 app_version，其余空 | 取 agent |
| agent / game / channel / market 都有 app_version | 取 market（最深层赢） |
| agent / game / market 有，channel 缺 | 取 market（最深层赢） |
| channelid 不匹配 | chain 截断 = [agent, game]；取 game |
| gameid 不匹配 | chain = [agent]；取 agent |
| agentid 不匹配 | chain = []；返回全 0/nil + 顶层 showmessage |
| showmessage 仅顶层有 | fallback 到 `config.showmessage` |
| showmessage 仅 market 有 | 取 market（最深层赢） |
| showmessage market 与 agent 都有 | 取 market |
| 字段值 = 0 / "" / nil | 视同未提供，继续向上找 |
| JSON 把 app_version 发成 number 而非 string | `decodeFlexibleStringIfPresent` 兜底为 String，`Int(s)` 解析正常 |

#### 6.3.3 LocalVersionReader

```swift
// Source/Resource/LocalVersionReader.swift
public enum LocalVersionReader {

    /// 原生 IPA 版本，从 ChannelConfig.plist 的 appversion 字段（BundleConfig）。
    nonisolated public static var localAppVersion: Int {
        Int(BundleConfig.shared.appVersion) ?? 0
    }

    /// 大厅 H5 包版本：从 Library/Caches/{gamedir}/{gamestart}/version.xml 解析
    /// /game/version 节点的 value 属性。XMLParser 解析，缺失 / 损坏返回 0。
    nonisolated public static var localGameVersion: Int { ... }
}
```

#### 6.3.4 LobbyZipUpgrader

```swift
// Source/Resource/LobbyZipUpgrader.swift
public actor LobbyZipUpgrader {

    public enum UpgradeOutcome {
        case noop                    // 远端版本 ≤ 本地，无需升级
        case upgraded(from: Int, to: Int)
    }

    public func upgradeIfNeeded(
        remoteGameVersion: Int,
        remoteGameZip: String?
    ) async throws -> UpgradeOutcome {
        let local = LocalVersionReader.localGameVersion
        guard remoteGameVersion > local, let zipURL = remoteGameZip.flatMap(URL.init) else {
            return .noop
        }

        // 1. 下载到 tmp（断点续传由 URLSessionDownloadTask 默认支持）
        let (tmpZip, _) = try await session.download(from: zipURL)

        // 2. 解压到隔离目录
        let stagingDir = SandboxPaths.caches.appendingPathComponent("staging-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true)
        try FileManager.default.unzipItem(at: tmpZip, to: stagingDir)

        // 3. 原子 rename：清旧 lobbyRoot，把 stagingDir 移到位
        let lobbyRoot = SandboxPaths.lobbyRoot
        try? FileManager.default.removeItem(at: lobbyRoot)
        try FileManager.default.moveItem(at: stagingDir, to: lobbyRoot)

        // 4. 清 tmp
        try? FileManager.default.removeItem(at: tmpZip)

        return .upgraded(from: local, to: remoteGameVersion)
    }
}
```

#### 6.3.5 WebContainer 调用串 + Splash UI 状态机（Phase 1.14）

WebContainerViewController 同时承担两层：
- **下层**：BridgedWebView 嵌入 + 16:9 letterbox（屏幕比 < 16:9 上下黑边；> 16:9 左右黑边）
- **上层（Splash 覆盖）**：与 LaunchScreen.storyboard 同款启动图（横屏铺满）+ UIProgressView + 状态文字标签
  - 启动瞬间：LaunchScreen 静态图（iOS 系统级，0 延迟）
  - viewDidLoad：WebContainer 把自己的 Splash 覆盖在 BridgedWebView 之上（两者 alpha 0/1 由状态切换）
  - 启动流水线推进时：updateSplash(text: ..., progress: ...) 更新文字 + 进度条
  - WKWebView didFinish 后：UIView.animate(duration: 0.3) splash.alpha = 0 → removeFromSuperview

```swift
// Source/WebView/WebContainerViewController.swift（Phase 1.14）
override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = .black

    setupBridgedWebView()        // 16:9 letterbox 嵌入
    setupSplashOverlay()          // 启动图 + 进度条 + 文字标签

    bridgedWebView.webView.navigationDelegate = self

    Task { @MainActor in
        await runBootPipeline()
    }
}

private func runBootPipeline() async {
    do {
        updateSplash(text: "拼命启动中...", progress: nil)
        try await ResourceUnzipper.shared.ensureReady()

        updateSplash(text: "拉取配置中...", progress: nil)
        let outcome = try await RemoteConfigClient.shared.fetch()

        switch outcome {
        case .shortText(let msg):
            // 运营杀手锏 #1：弹 alert，App 永停（msext NewRootVC.m:1239-1243 契约）
            showBlockingAlert(msg)
            return
        case .parsed(let cfg):
            let bc = BundleConfig.shared
            let resolved = VersionResolver.resolve(
                config: cfg,
                agentId: bc.agent,
                channelId: bc.channel,
                marketId: bc.market,
                gameId: bc.gameId
            )

            // 运营杀手锏 #2：showmessage 非空 → 弹 alert + 完全阻塞
            if let msg = resolved.showmessage, !msg.isEmpty {
                showBlockingAlert(msg)
                return
            }

            // IPA 升级：弹窗 + Safari 外链（msext NewRootVC.m:1510-1520）
            if resolved.appVersion > LocalVersionReader.localAppVersion,
               let dl = resolved.appDownload {
                showIPAUpgradeAlert(dl)
                return
            }

            // H5 zip 升级：进度条实时更新
            _ = try await LobbyZipUpgrader.shared.upgradeIfNeeded(
                resolved: resolved,
                onProgress: { @MainActor [weak self] p in
                    self?.updateSplash(
                        text: String(format: "下载更新中 %d%%", Int(p * 100)),
                        progress: p
                    )
                }
            )
        }

        updateSplash(text: "加载大厅...", progress: nil)
        bridgedWebView.webView.loadFileURL(
            SandboxPaths.lobbyIndex,
            allowingReadAccessTo: SandboxPaths.lobbyRoot
        )
        // didFinish 回调里淡出 splash（见 WKNavigationDelegate 扩展）
    } catch {
        showFatalAlert(error)
    }
}

// MARK: - WKNavigationDelegate
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    UIView.animate(withDuration: 0.3, animations: { [weak self] in
        self?.splash.alpha = 0
    }, completion: { [weak self] _ in
        self?.splash.removeFromSuperview()
    })
}
```

**Splash UI 状态切换表**：

| 阶段 | 文字 | progress | 持续时长（典型） |
|------|------|----------|---------------|
| viewDidLoad 起 | "拼命启动中..." | 隐藏 | ~50 ms |
| 首装解压 zip 中 | "拼命启动中..." | 隐藏 | ~50-500 ms |
| 拉远端配置中 | "拉取配置中..." | 隐藏 | ~500-2000 ms |
| H5 zip 下载中 | "下载更新中 XX%" | 0.0-1.0 | ~1000-3000 ms |
| WebView 加载 H5 中 | "加载大厅..." | 隐藏 | ~500-1000 ms |
| WebView didFinish | （淡出消失）| - | 300 ms 动画 |

**所有失败 / 阻塞终态都用 UIAlertController（非 splash 文字）**：
- 短文本响应：弹 alert，标题 `gamehallname + 提醒`，单按钮"确定"，无 tap 处理（永停）
- showmessage 非空：同上
- IPA 升级：弹 alert，单按钮"确定"→ openURL(appDownload)，永停
- 网络错误 / 解析失败：弹 alert，单按钮"重试"，点击重新 runBootPipeline()
- zip 下载失败：弹 alert，单按钮"重试" + 单按钮"使用旧版本"（回退用 IPA 内 Bundle zip）
```

#### 6.3.6 与 msext 历史实现的差异（不要照抄的部分）

| 维度 | msext 现状（不要照抄） | 新外壳决策 |
|------|--------------------|----------|
| 网络模型 | `[NSData dataWithContentsOfURL:]` 主线程同步阻塞 + ASIHTTPRequest 后台下载 | URLSession `async data(from:)` / `download(from:)`，actor 隔离 |
| 失败重试 | `viewDidLoad` 起 4s 等间隔 `timer`，至无穷直到成功 | 指数退避 1/2/4 秒最多 3 次，超出 throw 由 UI 决定回退路径 |
| 4 级覆盖 | `onnet` 单链 4 层 if/嵌 for（`NewRootVC.m:689-810`） | 纯函数 `VersionResolver.resolve`，协议化 + 5 字段共用 `pickString` / `pickInt` 倒序 fallback，单测覆盖所有边界 |
| 解压策略 | `removeItemAtPath` 删旧 + `ZipArchive overWrite:YES`，半途崩溃留半残 | 解压到 `staging-{uuid}/` 临时目录 + 原子 `moveItem` rename，半途崩溃只留 tmp（下次启动可清） |
| 子游戏目录冲突 | 命名 `+1` 累积（`XXX → XXX1 → XXX11`） | 同款原子 rename，旧目录直接覆盖 |
| 配置解析 | SBJSON 三方库 | Codable + JSONDecoder |
| 版本号 | NSString → intValue（自动 0） | 强类型 Int，缺失明示 |

#### 6.3.7 子游戏升级复用

Phase 6 子游戏（`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownloadurl`）的升级逻辑**复用** `LobbyZipUpgrader` 的设计，差异仅在：
- 目标路径不同（`SandboxPaths.subGameRoot(dir)` 而非 `lobbyRoot`）
- 版本号 source 不同（子游戏 `version.xml` 而非大厅）
- 不重新拉远端配置（继承大厅的 `RemoteConfig`）

具体接口扩展到 Phase 6 设计时定。

---

## 7. 资源 & 渠道注入

### 7.0 项目资源目录约定

项目资源目录的最终结构**由各 Phase 实施时按需定义**,本节列出固定规则,具体目录形态在落地时决定。

#### 7.0.1 仓库内素材池 `docs/res/` 与项目无关

`docs/res/` 是项目维护者的**私人原始素材池**,**与项目架构无关**(详见 `CLAUDE.md` 「docs/res/ 与项目资源的关系」):

- 项目代码 / 构建脚本 / Xcode 工程都**不感知**它的存在,不引用任何 `docs/res/xxx` 路径
- 它的目录结构 / 命名 / 内容可由维护者任意调整,不影响构建
- git 跟踪只是为了多机同步素材,而非项目需要

需要某素材时,**从 `docs/res/` 拷一份**到项目内规划好的位置,后续维护与 `docs/res/` 不再有任何关联。

#### 7.0.2 项目内资源目录(Phase 实施时落地)

按现代 iOS 单 target + synchronized group 实践,推荐落点如下,**但具体目录在该 Phase 真正需要时再创建**(避免预先空目录):

| 落点 | 用途 | 入 Bundle 方式 |
|------|------|--------------|
| `ylgamehall/Resources/` | 静态打入 Bundle 的项目资源(`gamehall.zip` / `WebViewJavascriptBridge.js` / 本地音效 mp3 / `ChannelConfig.plist` 等) | synchronized group 自动收集 |
| `ylgamehall/Assets.xcassets/` | 原生 Asset Catalog(AppIcon / LaunchImage / 分享平台图标) | Xcode 默认 |
| `Vendor/<SDK>/<SDK>.xcframework` | 闭源 SDK 二进制(微信 / QQ / 高德 / opencore-amr) | Target Build Phase「Frameworks」手动加入 |

#### 7.0.3 渠道注入:ChannelConfig.plist 母包模式

**契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 `ChannelConfig.plist` 等价实现** —— 契约边界(H5 通过 `app_data.js` 看到的 JS 全局变量)完全不变,实现内部更简洁。

> **2026-06-27 修订(ADR-009)**:原 11 个 key 之一的 `qiniudomain` 已移除 —— 七牛 CDN 域名改由 RemoteConfig 顶层 `audio_domain` 远端动态下发,跨渠道无差异化诉求。
>
> **2026-07-03 修订**:新增 `multishare` 字段,控制子游戏 sharefriend=1 好友分享形态("1" = SharePanel 三选一 / "0" = 仅微信直发)。**当前 ChannelConfig.plist = 11 个 string key**。

**存储**:`ylgamehall/Resources/ChannelConfig.plist`,11 个 string key 一一对应渠道值:

```xml
<dict>
    <key>gameid</key>       <string>G2hw0u...</string>
    <key>channel</key>      <string>FtJf07...</string>
    <key>gamedir</key>      <string>FtJf07...</string>
    <key>gamestart</key>    <string>gamehall</string>
    <key>gameconfig</key>   <string>tsgames.daoqi88.cn-config_test-update_jsonv2_test</string>
    <key>market</key>       <string>2</string>
    <key>agent</key>        <string>veRa0qrBf0df2K1G4de2tgfmVxB2jxpv</string>
    <key>appversion</key>   <string>43</string>
    <key>other</key>        <string></string>
    <key>appleconfig</key>  <string></string>
    <key>multishare</key>   <string>1</string>
</dict>
```

**伴生配置**:`ylgamehall/Resources/AppSecrets.plist`(ADR-009 引入,跨渠道相同的应用级凭证 3 项):

```xml
<dict>
    <key>wxAppSecret</key>      <string>b27927...</string>
    <key>qiniuAccessKey</key>   <string>dQbQLU...</string>
    <key>qiniuSecretKey</key>   <string>RCZpwL...</string>
</dict>
```

> 微信 AppID 不在 AppSecrets.plist —— 它的唯一权威源是 `Info.plist` 的 CFBundleURLTypes(URLName=weixin 首个 scheme),iOS 系统级 URL Scheme 必须在 Info.plist 静态声明,运行期无法注入。

**运行时读取路径**:`Bundle.main.bundleURL.appendingPathComponent("ChannelConfig.plist")`,由 `BundleConfig.swift`(§7.2)用 `PropertyListSerialization` 反序列化。

**多渠道分发**(IPA 后处理,不重新 Xcode build):

```bash
unzip ylgamehall.ipa -d tmp/
APP="tmp/Payload/ylgamehall.app"
plutil -replace channel -string "<new_channel_id>" "$APP/ChannelConfig.plist"
plutil -replace market  -string "<new_market_id>"  "$APP/ChannelConfig.plist"
codesign --force --sign "$IDENTITY" --entitlements "$ENT" "$APP"
cd tmp && zip -r ../ylgamehall_<channel>.ipa Payload/
```

→ 修改 plist 与 msext 的"`mv` 目录名"机制工作量相当,但**完全规避 Xcode 26 synchronized group 与目录树的兼容问题**(详见 Plan ADR-007)。

> **为什么不照搬 msext 的目录树**:
> - msext 那套在 Xcode 14 / 老 group 模型下能工作;但 Xcode 26 默认 synchronized group 把子目录扁平化,适配代价高(尝试过 folder reference / Run Script + sandboxing 均有阻碍)
> - plist 是 iOS 原生最简单的配置存储,加入 `Resources/` 后 Xcode 自动入 Bundle,零配置
> - 修改成本 / 重签流程 / 维护成本 / IPA 后处理脚本 / H5 业务可观察行为,**与目录树方案完全等价**
> - CLAUDE.md 原则 B:"原生内部自由重构,不要照搬旧项目"——这是落地

### 7.1 SandboxPaths 集中管理

```swift
// ResourceKit/SandboxPaths.swift
public enum SandboxPaths {
    public static var caches: URL {
        FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
    }
    public static var documents: URL {
        FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
    }
    public static var bundle: URL { Bundle.main.bundleURL }

    /// {Caches}/{gamedir}/{gamestart}/index.html
    public static func lobbyIndex() -> URL {
        let cfg = BundleConfig.shared
        return caches
            .appendingPathComponent(cfg.gameDir)
            .appendingPathComponent(cfg.gameStart)
            .appendingPathComponent("index.html")
    }

    public static func subGameIndex(_ dir: String, _ start: String) -> URL {
        caches.appendingPathComponent(dir)
              .appendingPathComponent(start)
              .appendingPathComponent("index.html")
    }
}
```

### 7.2 渠道注入读取

```swift
// ResourceKit/BundleConfig.swift
//
// 从 Bundle 内的 ChannelConfig.plist 读取 10 个渠道注入值。
// 详见 §7.0.3 ChannelConfig.plist 母包模式 / ADR-007 / ADR-009。
public final class BundleConfig: @unchecked Sendable {
    public static let shared = BundleConfig()

    public let gameId:       String
    public let channel:      String
    public let gameDir:      String
    public let gameStart:    String
    public let gameConfig:   String
    public let market:       String
    public let agent:        String
    public let appVersion:   String
    public let other:        String
    public let appleConfig:  String

    public init(bundle: Bundle = .main) {
        let dict = Self.loadPlist(bundle: bundle)
        gameId      = dict["gameid"]      ?? ""
        channel     = dict["channel"]     ?? ""
        gameDir     = dict["gamedir"]     ?? ""
        gameStart   = dict["gamestart"]   ?? ""
        gameConfig  = dict["gameconfig"]  ?? ""
        market      = dict["market"]      ?? ""
        agent       = dict["agent"]       ?? ""
        appVersion  = dict["appversion"]  ?? ""
        other       = dict["other"]       ?? ""
        appleConfig = dict["appleconfig"] ?? ""
    }

    private static func loadPlist(bundle: Bundle) -> [String: String] {
        guard let url = bundle.url(forResource: "ChannelConfig", withExtension: "plist"),
              let data = try? Data(contentsOf: url),
              let plist = try? PropertyListSerialization.propertyList(
                  from: data, format: nil) as? [String: String]
        else { return [:] }
        return plist
    }
}
```

> 七牛 CDN 域名(录音上传后的公网 URL 组装)从远端 RemoteConfig 顶层 `audio_domain` 注入(ADR-009),启动期由 `WebContainerViewController` 调 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 写入 `QiniuConfig` actor。所有 AudioKit / RecordUploader 等模块从 `QiniuConfig.shared` 异步读取。
>
> **测试性**:`init(bundle:)` 接受任意 Bundle,单测可注入 mock bundle 验证不同 plist fixture。

### 7.3 Zip 解压(异步,非阻塞)

```swift
// ResourceKit/ResourceUnzipper.swift
public actor ResourceUnzipper {
    public func ensureReady() async throws {
        let dest = SandboxPaths.caches
            .appendingPathComponent(BundleConfig.shared.gameDir)

        if FileManager.default.fileExists(atPath:
            dest.appendingPathComponent(BundleConfig.shared.gameStart)
                .appendingPathComponent("version.xml").path) {
            return     // 已存在
        }

        let zip = Bundle.main.url(forResource: "gamehall", withExtension: "zip")!
        try await Task.detached(priority: .userInitiated) {
            try FileManager.default.createDirectory(at: dest,
                withIntermediateDirectories: true)
            try Zip.unzipFile(zip, destination: dest, overwrite: true, password: nil)
        }.value
    }
}
```

依赖 [`ZIPFoundation`](https://github.com/weichsel/ZIPFoundation) (SPM),替代旧 ZipArchive。

### 7.4 打包脚本(渠道动态切换)

```bash
#!/bin/bash
# Scripts/inject_channel.sh
# Usage: ./inject_channel.sh <channel_id> <gamedir> <gameid> <market> ...

set -e
INJECT_ROOT="Resources/ChannelInjection"

inject() {
    local key=$1 value=$2
    rm -rf "$INJECT_ROOT/$key"
    mkdir -p "$INJECT_ROOT/$key/$value"
    touch "$INJECT_ROOT/$key/$value/.gitkeep"
}

inject "channel"      "$1"
inject "gamedir"      "$2"
inject "gameid"       "$3"
inject "market"       "$4"
inject "agent"        "$5"
inject "appversion"   "$6"
inject "gamestart"    "$7"
inject "gameconfig"   "$8"
# qiniudomain 已于 2026-06-27 移除(ADR-009),七牛 CDN 域名走 RemoteConfig.audio_domain

echo "Channel injected: channel=$1 gamedir=$2 market=$4"
```

CI 一键多渠道出包:

```bash
for channel in xianliao 360 qq tencent; do
  ./Scripts/inject_channel.sh "$channel" "..." "..."
  xcodebuild archive -scheme Daoqi -archivePath build/$channel.xcarchive
  xcodebuild -exportArchive ...
done
```

### 7.5 H5 `app_*.js` 预注入文件机制（**最终路径：原生 `writeToFile` 写文件**）

**用途**：H5 业务代码通过 `<script src="app_data.js">` / `<script src="app_battery.js">` / `<script src="app_network.js">` / `<script src="app_gamesname.js">` 等同步 `<script>` 引入 4 个 `.js` 文件。**原生在 `loadFileURL` 之前用 `String.write(to:)` 把这 4 个文件写到 `{gamedir}/{gamestart}/`**，文件内是 `var app_xxx = "<实际渠道/启动/设备值>"` 形式的变量声明，H5 启动时 `<script src>` 同步引入直接读到正确值。**这是 H5 在 iOS 9+ 路径下读渠道 / 设备 / 启动信息的唯一真实路径**（§3.4.1 旧桥同步 getter 已撤销）。

> ⚠️ **2026-06-22 路径决策最终落定（中间走过两次弯路，复盘见 CLAUDE.md 典型案例 3）**：
>
> 1. **早期 commit `f68b0db`**：设计为"原生 `writeToFile` 写 4 个 `.js` 文件"路径（与原 msext `NewRootVC.initJSdata` 一致）→ **正确方向**
> 2. **中间 commit `246f215`**：误以为"H5 自带 `app_*.js`，原生用 `WKUserScript(.atDocumentEnd)` + `evaluateJavaScript` 覆盖 `window.app_xxx`"是更现代化的做法 → **错误调整**，撤销
> 3. **本次回退**：再次回到"原生 `writeToFile` 写文件"路径，与原 msext 行为 1:1 等价
>
> **为什么 `evaluateJavaScript` 路径不可行**（CLAUDE.md 典型案例 3 详记）：
> - `WKUserScript(.atDocumentStart)` 注入会被 H5 自带 `app_*.js` 内的 `var app_xxx = "默认值"` 声明覆盖回退
> - `WKUserScript(.atDocumentEnd)` 又太晚（H5 业务顶层 `<script>` 在 documentEnd 之前已跑过 `var x = app_xxx`，读到的是默认占位）
> - WKUserScript 没有"在 `<script src="app_data.js">` 之后、业务 `<script>` 之前"的精确时机
> - 任何"让 H5 改一行 / 改一个文件"的妥协方案都违反 CLAUDE.md **原则 A 第一准则**（H5 端零修改不可妥协），立即否决
>
> **正确解法**：回到 msext 老路 — 原生在 `loadFileURL` **之前**写 `app_*.js` 文件，H5 启动时 `<script src>` 同步引入即可读到实际值，时序 100% 等价 msext。哪怕这是"现代 WKWebView 不推荐"的写法，契约优先于现代化（CLAUDE.md 原则 A.2）。

#### 7.5.1 15 个 `app_*` 变量完整映射（按 daoqi/msext `NewRootVC.initJSdata`、`gameController.initJSdata`、`AppDelegate.applicationDidFinishLaunching` 审查得出）

| # | 变量名 | 大厅值（NewRootVC）| 子游戏值（gameController）| 数据源（新外壳） | 写入文件 |
|---|---|---|---|---|---|
| 1 | `app_version` | `"1"` 硬编码 | `"1"` 硬编码 | 硬编码 string `"1"`（沿用历史，H5 不会读出不同值） | `app_data.js` |
| 2 | `app_gameconfig` | `[FuncPublic filename:@"gameconfig"]` 或 `[FuncPublic filename:@"appleconfig"]`（动态切换，见下方）| 同左 | **动态切换**：`result==0` → `BundleConfig.gameConfig` / `result==1` → `BundleConfig.appleConfig`（详见 #6 app_appversion 计算）| `app_data.js` |
| 3 | `app_gamedir` | `self.gamedir` | `self.gamefilepath` | 大厅 `BundleConfig.shared.gameDir` / 子游戏 `subGameDir` | `app_data.js` |
| 4 | `app_gamestart` | `self.gamestart` | `self.game_name` | 大厅 `BundleConfig.shared.gameStart` / 子游戏 `subGameName` | `app_data.js` |
| 5 | `app_agent` | `self.agentinfo` | `self.agentinfo` | `BundleConfig.shared.agent` | `app_data.js` |
| 6 | `app_appversion` | `result`（0/1 二值标志，**不是版本号**） | `self.result_state`（同上） | **审核切换标志**（msext NewRootVC.m:1190-1208）：`远端 ResolvedVersion.appVersion >= 本地 BundleConfig.appVersion` → `result=0`（正常 gameconfig），否则 `result=1`（审核期 appleconfig）。写入字面 `'0'` / `'1'` 单引号包裹数字（与 msext `'%d'` 等价）| `app_data.js` |
| 7 | `app_market` | `self.market` | `self.market` | `BundleConfig.shared.market` | `app_data.js` |
| 8 | `app_channel` | `self.channel_id` | `self.channel_id` | `BundleConfig.shared.channel` | `app_data.js` |
| 9 | `app_Launchtype` | `"0"` 硬编码 | `"1"` 硬编码 | 大厅 `"0"` / 子游戏 `"1"`（⚠️ L 大写） | `app_data.js` |
| 10 | `app_getwifisignalLevel` | `"1"` 硬编码 | `"1"` 硬编码 | 硬编码 string `"1"`（⚠️ wifi 小写、signal/Level 区分大小写） | `app_data.js` |
| 11 | `app_gamename` | `self.gamestart` | `self.game_name` | 大厅 = `gameStart` / 子游戏 = 子游戏名（与 `app_gamestart` 同值） | `app_data.js` |
| 12 | `app_invitationcode` | `self.tuiguang_id` | `self.tuiguang_id` | `BundleConfig.shared.other`（推广码 / 邀请码，沿用 other 字段；如未来独立字段再改）| `app_data.js` |
| 13 | `app_gamesname` | 已安装子游戏 JS Array | 同上 + 新增本子游戏 | `SandboxPaths.installedSubGameNames()` JS Array literal | `app_gamesname.js` |
| 14 | `app_getbattery` | `String(format: "%.2f", UIDevice.batteryLevel)` | 同 | `UIDevice.current.batteryLevel`，每次 viewWillAppear + battery 变化通知 | `app_battery.js` |
| 15 | `app_getnetwork` | `"1"`/`"2"`/`"3"` | 同 | `NWPathMonitor` 状态映射（1 无网 / 2 WiFi / 3 蜂窝），每次 viewWillAppear + network 变化通知 | `app_network.js` |

#### 7.5.2 写入时机与机制（**两阶段：启动期写文件 + 业务期 evaluateJavaScript**）

```
启动期（loadFileURL 之前，原生写文件到沙盒，供 H5 同步 <script src> 引入）：
  {gamedir}/{gamestart}/app_data.js          一次性写（#1-#12 共 12 个 var）
  {gamedir}/{gamestart}/app_gamesname.js     一次性写（#13）
  {gamedir}/{gamestart}/app_battery.js       loadFileURL 前补一次（首次值）
  {gamedir}/{gamestart}/app_network.js       loadFileURL 前补一次（首次值）

业务期（H5 已加载完后，原生 evaluateJavaScript 直接重新赋值 window.app_*）：
  battery 变化 → webView.evaluateJavaScript("window.app_getbattery=N;")  （不重写文件）
  network 变化 → webView.evaluateJavaScript("window.app_getnetwork=N;")   （不重写文件）
  子游戏装新 → 重新进入大厅时调 writer.writeGamesName 一次（增量更新文件）
```

**为什么启动期写文件**（保留与 msext 等价的时序）：H5 启动时用 `<script src="app_data.js">` **同步**引入，必须在 `loadFileURL` 之前文件已存在于沙盒；H5 业务代码顶层 `var x = app_xxx` 直接拿到实际值。

**为什么业务期不重写文件，改用 evaluateJavaScript**：H5 加载完成后，`<script src>` 不会再 fetch，所以重写文件对当前页面无影响（仅影响下次 reload）。直接 `evaluateJavaScript("window.app_xxx = N;")` 重新赋值全局变量，H5 业务期同步读 `app_xxx` 立即拿到最新值，效率更高、无文件 IO 开销。

> **与 §3.2 反向 callback 的关系**：两条路径并存、互为补充：
> - **`window.app_xxx` 重新赋值**：H5 业务代码任何时刻同步读 `app_getbattery` 都拿到最新值
> - **`bridge.call("getBattery", ...)` 反向推送**：H5 注册的 callback 收到事件主动响应（业务期变化感知）
> 两路同时执行（onChange 闭包内一次完成），与原 msext changebattery + js eval 双轨等价。

> **不要照搬 `WKUserScript` 注入路径**（曾在 commit `246f215` 尝试过，已撤回）：
> - `.atDocumentStart` 注入会被 H5 自带 `app_*.js` 内的 `var app_xxx = "默认值"` 声明覆盖
> - `.atDocumentEnd` 又太晚，H5 业务顶层 `<script>` 在 documentEnd 之前已读过 `app_xxx`（默认值）
> - 没有"在 `<script src>` 之后、业务 `<script>` 之前"的精确时机
> - 任何要求 H5 配合改动的方案都违反 CLAUDE.md 原则 A 第一准则
> - 详细分析见 CLAUDE.md「典型案例：`app_*` 注入时序问题」

#### 7.5.3 Swift 骨架（`AppDataWriter`，写文件路径）

设计带可观察性：每次写文件都在 Debug 模式下把"写了哪个文件 + 内容预览 + 路径"打到控制台，便于联调时一眼对账 H5 端读到的值。Release 自动去 `.debug` 级别避免性能开销 + 防泄漏。

```swift
// Source/WebView/AppDataWriter.swift
import os.log

@MainActor
public struct AppDataWriter {

    let bundleConfig: BundleConfig
    let resolvedVersion: ResolvedVersion?    // 拉到远端后传入；首次 fallback nil
    let containerRole: ContainerRole          // .lobby / .subGame(name:)

    public enum ContainerRole: CustomStringConvertible {
        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))"
            }
        }
    }

    /// 集中日志入口：可被单测重定向；Debug 输出，Release 自动归档
    private static let log = Logger(subsystem: "ylgamehall", category: "AppDataWriter")

    /// 一次性写 #1-#12 到 app_data.js + #13 app_gamesname.js（loadFileURL 前调一次）
    public func writeInitial() throws {
        try writeAppData()
        try writeGamesName()
    }

    /// 写 #14 app_battery.js（电池变化 / viewWillAppear 触发）
    public func writeBattery(_ level: Float) throws {
        let value = String(format: "%.2f", level)
        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（NWPath 变化 / viewWillAppear 触发）
    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)")
    }

    private func writeAppData() throws {
        let bc = bundleConfig
        let launchtype: String
        let gameName:   String
        let gameDir:    String
        let gameStart:  String

        switch containerRole {
        case .lobby:
            launchtype = "0"
            gameName   = bc.gameStart
            gameDir    = bc.gameDir
            gameStart  = bc.gameStart
        case .subGame(let name, let dir):
            launchtype = "1"
            gameName   = name
            gameDir    = dir
            gameStart  = name
        }

        let appVersion = resolvedVersion?.appVersion.description ?? bc.appVersion

        // ⚠️ 严格保持：单引号包裹字符串、msext 沿用形式；大小写不能改
        // 数值字段（app_version / app_Launchtype / app_getwifisignalLevel / app_appversion）
        // 是字面数字，无引号；msext "var app_version=1;" / "var app_appversion='%d';"
        let lines = """
        var app_version=1;\
        var app_gameconfig='\(escape(bc.gameConfig))';\
        var app_gamedir='\(escape(gameDir))';\
        var app_gamestart='\(escape(gameStart))';\
        var app_agent='\(escape(bc.agent))';\
        var app_appversion='\(escape(appVersion))';\
        var app_market='\(escape(bc.market))';\
        var app_channel='\(escape(bc.channel))';\
        var app_Launchtype=\(launchtype);\
        var app_getwifisignalLevel=1;\
        var app_gamename='\(escape(gameName))';\
        var app_invitationcode='\(escape(bc.other))';
        """
        let url = filePath("app_data.js")
        try lines.write(to: url, atomically: true, encoding: .utf8)

        // Debug 打印每个 key=value 一行，便于和 H5 console.log(app_xxx) 逐项对账
        Self.log.debug("""
        [\(containerRole.description, privacy: .public)] write app_data.js → \(url.path, privacy: .public)
          app_version            = 1
          app_gameconfig         = '\(bc.gameConfig, privacy: .public)'
          app_gamedir            = '\(gameDir, privacy: .public)'
          app_gamestart          = '\(gameStart, privacy: .public)'
          app_agent              = '\(bc.agent, privacy: .public)'
          app_appversion         = '\(appVersion, privacy: .public)'
          app_market             = '\(bc.market, privacy: .public)'
          app_channel            = '\(bc.channel, privacy: .public)'
          app_Launchtype         = \(launchtype, privacy: .public)
          app_getwifisignalLevel = 1
          app_gamename           = '\(gameName, privacy: .public)'
          app_invitationcode     = '\(bc.other, privacy: .public)'
        """)
    }

    private func writeGamesName() throws {
        // JS Array literal: new Array('game1','game2',...)，与 msext AppDelegate.m:259 完全一致
        let installed = SandboxPaths.installedSubGameNames()
        let escaped = installed.map { "'\(escape($0))'" }.joined(separator: ",")
        let line = "var  app_gamesname=new Array(\(escaped));\n"
        //         ^ 注意 var 后两个空格，msext 字面沿用
        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: \(installed.count, privacy: .public) games → \(url.path, privacy: .public)\n  games = \(installed, privacy: .public)")
    }

    private func filePath(_ name: String) -> URL {
        // 与 msext NewRootVC 一致：{gamedir}/{gamestart}/app_*.js
        let base: URL
        switch containerRole {
        case .lobby:
            base = SandboxPaths.lobbyAssets    // {Caches}/{gamedir}/{gamestart}/
        case .subGame(_, let dir):
            base = SandboxPaths.subGameAssets(dir: dir)
        }
        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")
    }
}
```

#### 7.5.4 WebContainerViewController 接入点

```swift
private func runBootPipelineSteps() async throws {
    // ... ensureReady / fetch / resolve / upgrade ...

    let writer = AppDataWriter(
        bundleConfig:    .shared,
        resolvedVersion: resolved,
        containerRole:   .lobby
    )

    // 1. 启动期：loadFileURL 前一次性写 4 个 app_*.js 文件
    //    H5 通过 <script src> 同步引入即可读到实际值
    NetworkMonitor.shared.start()
    BatteryMonitor.shared.start()
    AppLifecycleObserver.shared.start()

    try writer.writeInitial()                                       // app_data.js + app_gamesname.js
    try writer.writeBattery(BatteryMonitor.shared.currentLevel)     // app_battery.js
    try writer.writeNetwork(NetworkMonitor.shared.currentCode)      // app_network.js

    splash.update(text: "加载大厅...", progress: nil)
    bridgedWebView.webView.loadFileURL(
        SandboxPaths.lobbyIndex,
        allowingReadAccessTo: SandboxPaths.lobbyRoot
    )

    // 2. 业务期：变化时**不重写文件**，改为 evaluateJavaScript 重新赋值 window.app_*
    //    + bridge.call 反向通知（双轨同时进行）
    let bridge = bridgedWebView.bridge
    let webView = bridgedWebView.webView

    BatteryMonitor.shared.onChange = { level in
        let value = String(format: "%.2f", level)
        Task { @MainActor in
            _ = try? await webView.evaluateJavaScript("window.app_getbattery=\(value);")
        }
        bridge.call("getBattery", data: .string(value), callback: nil)
    }
    NetworkMonitor.shared.onChange = { code in
        Task { @MainActor in
            _ = try? await webView.evaluateJavaScript("window.app_getnetwork=\(code);")
        }
        bridge.call("getnetwork", data: .string("\(code)"), callback: nil)
    }
    // appservice 无对应 app_* 全局变量，只走 bridge.call
    // 值错位沿用历史 msext WKWebView 路径：背景="2" / 前台="1"
    AppLifecycleObserver.shared.onBackground = {
        bridge.call("appservice", data: .string("2"), callback: nil)
    }
    AppLifecycleObserver.shared.onForeground = {
        bridge.call("appservice", data: .string("1"), callback: nil)
    }
}
```

> ⚠️ 持续重写文件**不会** trigger H5 端 `<script src>` 重新加载（浏览器只在页面 load 时引入一次）。这条路径只服务于"刷新 → reload → 读到最新值"的场景，与 §3.2 ［2］［3］的 `bridge.call("getBattery"|"getnetwork", ...)` 事件 callback **互为补充**：
> - 事件 callback = 业务期 H5 主动收到变化推送
> - 文件重写 = 下次 reload 时 H5 读到的初始值是最新的
>
> 两条路径并存，与原 msext 行为等价。

#### 7.5.5 大厅 / 子游戏 / 弹层差异

| 容器 | `app_data.js` 写入 | `app_gamesname.js` | `app_battery.js` | `app_network.js` | 备注 |
|------|---|---|---|---|---|
| 大厅（WebContainer.lobby） | `app_Launchtype=0` + `gamedir = BundleConfig.gameDir` + `gamename = gameStart` | 写当前已安装子游戏列表 | ✓ | ✓ | `app_appversion` 用远端 ResolvedVersion |
| 子游戏（WebContainer.subGame） | `app_Launchtype=1` + `gamedir = 子游戏目录` + `gamename = 子游戏名` | 同样写（与大厅同份内容） | ✓ | ✓ | 子游戏专属 `result_state` 字段，新外壳暂用同 appVersion |
| 弹层（OverlayViewController / threeView） | ✗ 不写 | ✗ | ✗ | ✗ | 弹层 H5 走 `window.settings.{backgameData/browser/finishweb}`（§3.4），不读 `app_*` 全局变量 |

#### 7.5.6 与原 msext 的差异

| 维度 | msext 现状 | 新外壳决策 |
|------|---|---|
| 写入主体 | `NewRootVC.initJSdata` / `gameController.initJSdata` 散落在 VC 内 | 抽象为 `AppDataWriter` struct，三个容器复用同一份代码 |
| 写入机制 | `[NSString writeToFile:atomically:YES encoding:UTF8 error:nil]` 写 `app_*.js` 到沙盒 | `String.write(to:atomically:encoding:)`（等价） |
| 数据源 | `self.gameconfig` 等实例属性，跨多处赋值 | 统一从 `BundleConfig.shared` + `ResolvedVersion` 拉，单一真相 |
| battery / network 时机 | `viewWillAppear` + 前台通知双重保险 | `addObserver` 注册一次 + `loadFileURL` 前补一次首次值（避免双重写入） |
| 字符串包裹符 | `var x='%@'` 单引号 | `var x='...'` 单引号一致（与 msext 字面对齐） |
| 转义处理 | 直接 `stringWithFormat:@"var x='%@'"` 不转义 | 完整转义 4 种特殊字符（避免渠道字段含单引号导致 H5 JS 解析错） |
| 大小写 | `app_Launchtype` / `app_getwifisignalLevel` 严格保持 | 同款，注释提示 |
| 文件位置 | `{gamedir}/{gamestart}/app_*.js`（沙盒 Caches）| 同款（统一走 `SandboxPaths.lobbyAssets` / `subGameAssets`）|

#### 7.5.7 与 §3.4.1 撤销 polyfill 的关系

| 路径 | 状态 | 用途 |
|---|---|---|
| §7.5 `app_*.js` 文件预写 | ✓ **主路径** | iOS 9+ 全部 H5 业务读 `app_xxx` 全局变量；原生 `writeToFile` 在 `loadFileURL` 前写入沙盒，H5 `<script src>` 同步引入即可（与 msext 1:1 等价）|
| §3.4.1 polyfill | ✗ **已撤销** | 仅 iOS<9 旧桥 `window.settings.getXxx()`，本项目最低 iOS 15.6 不需要 |
| §3.4 OverlayBridge polyfill | ✓ **保留** | 弹层 `window.settings.{backgameData/browser/finishweb}`，与系统版本无关 |
| §3.2 事件 callback `getBattery` / `getnetwork` / `appservice` | ✓ **保留** | 业务期 H5 主动收到的变化推送，与 §7.5 文件重写互为补充 |
| `WKUserScript` + `evaluateJavaScript` 覆盖 `window.app_xxx` | ✗ **探索后撤回** | 时序无法 1:1 等价 msext，详细分析见 §7.5 头部 + CLAUDE.md「典型案例 3」|

#### 7.5.8 联调时的可观察性（Phase 1.17 调试手段）

H5 端读到错误值时，第一步是确认"原生写了什么、H5 读到什么"。四条对账路径：

**1. Xcode 控制台 — 原生写入 log（§7.5.3 已埋点）**

启动后 Xcode console 应出现：

```
[lobby] write app_data.js → /Users/.../Caches/<gamedir>/<gamestart>/app_data.js
  app_version            = 1
  app_gameconfig         = 'tsgames.daoqi88.cn-config_test-update_jsonv2_test'
  app_gamedir            = 'FtJf07...'
  app_gamestart          = 'gamehall'
  app_agent              = 'veRa0qrBf0df...'
  app_appversion         = '43'
  app_market             = '2'
  app_channel            = 'FtJf07...'
  app_Launchtype         = 0
  app_getwifisignalLevel = 1
  app_gamename           = 'gamehall'
  app_invitationcode     = ''
[lobby] write app_gamesname.js: 0 games → ...
[lobby] write app_battery.js: app_getbattery=0.85 → ...
[lobby] write app_network.js: app_getnetwork=2 → ...
```

若没出现 → AppDataWriter 没接入 / log subsystem 过滤掉了，先按 §7.5.4 接入点检查。

**2. 沙盒文件直接读 — `cat`（确认文件落盘正确）**

```bash
xcrun simctl get_app_container booted com.skyapp.ylgamehall data
# 拿到容器路径后
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_data.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_battery.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_network.js
cat <容器>/Library/Caches/<gamedir>/<gamestart>/app_gamesname.js
```

文件应包含 `var app_xxx=...;` 字面字符串。若文件不存在 / 为空 → AppDataWriter 没跑到 / 路径错；若文件正确但 H5 读 undefined → H5 端 `<script src>` 路径不对或加载时序问题。

**3. H5 console 对账 — Safari Web Inspector**

模拟器跑时 macOS Safari → 开发菜单 → Simulator → 当前 H5 页面 → Web Inspector → Console：

```js
console.log("app_version =", app_version);            // 期望 1
console.log("app_channel =", app_channel);            // 期望 ChannelConfig.plist channel 值
console.log("app_Launchtype =", app_Launchtype);      // 大厅期望 0，⚠️ L 大写
console.log("app_getwifisignalLevel =", app_getwifisignalLevel);  // ⚠️ wifi 小写
console.log("app_getbattery =", app_getbattery);      // ⚠️ 带 get 前缀
console.log("app_getnetwork =", app_getnetwork);
console.log("app_gamesname =", app_gamesname);        // 期望 Array
```

| 现象 | 可能原因 |
|---|---|
| 全部为 H5 zip 包内默认占位（不是实际渠道值）| AppDataWriter 没在 loadFileURL 前调到 / 写入路径与 H5 `<script src>` 引入路径不一致 / writeAppData 抛错被吞 |
| 某项为 `undefined` | 拼写错误（`app_launchtype` 小写 l / `app_battery` 少 get / `app_getNetwork` 驼峰错） |
| `app_getbattery` = 默认值不变 | BatteryMonitor 没启动 / 模拟器 batteryLevel = -1（模拟器无电池硬件） |
| `app_gamesname` 是空数组但实际已装子游戏 | `SandboxPaths.installedSubGameNames()` 实现错 / 子游戏目录路径不对 |

**4. 真机调试**：iPhone 上 Safari → 设置 → 高级 → 网页检查器开启，然后 macOS Safari 同样路径。

切换后 Xcode console 应出现 `inject battery: ...` / `inject network: ...` 日志，同时 H5 端 console.log 再读应反映新值。

> ⚠️ 上面 H5 控制台和原生 log 调试**仅在 Debug 配置生效**。Release 版本 os.log `.debug` 级别会被系统过滤掉，避免性能开销和敏感字段（agent / channel）外泄。Phase 9 Release 监控走 Sentry breadcrumb（§11.3），不依赖 console。

> ⚠️ 上面 H5 控制台和文件路径调试**仅在 Debug 配置生效**。Release 版本 os.log `.debug` 级别会被系统过滤掉，避免性能开销和敏感字段（agent / channel）外泄。Phase 9 Release 监控走 Sentry breadcrumb（§11.3），不依赖 console。

---

### 7.6 WebView 加载方式：`WKURLSchemeHandler`（Phase 1.F，ADR-010）

> Phase 1 / Phase 6 初版用 `webView.loadFileURL(_:allowingReadAccessTo:)` 加载 H5。Phase 1.F 把这一路径迁到自定义 scheme `ylgame://h5/...` + `AppSchemeHandler`。三条动因：① 为未来 Cocos 子游戏 H5 build 适配铺路；② 大厅 + 子游戏共用单虚拟 host (`ylgame://h5`) → same-origin → **保留跨页 localStorage 共享**（业务依赖现状）；③ 给 `<audio>` / `<video>` 提供 Range 请求支持。详细决策见 `docs/Development-Plan.md` ADR-010。

#### 7.6.1 URL 结构

| 入口 | URL | 反向映射 |
|------|-----|---------|
| 大厅 | `ylgame://h5/lobby/<gameStart>/index.html` | `{Caches}/{gameDir}/<gameStart>/index.html` |
| 子游戏 | `ylgame://h5/subgame/<dir>/<start>/index.html` | `{Caches}/<dir>/<start>/index.html` |
| `app_*.js` 同步引入 | `ylgame://h5/lobby/<gameStart>/app_data.js` | `{Caches}/{gameDir}/<gameStart>/app_data.js` |
| 跨目录引用（H5 `../foo.js`） | `ylgame://h5/lobby/foo.js` | `{Caches}/{gameDir}/foo.js`（等价 file:// 时代 allowingReadAccessTo: lobbyRoot 语义） |
| 弹层外链（不变） | http(s)://... | 走 WebKit 默认网络栈，不经 handler |

**关键约束：scheme + host 必须完全一致**。大厅与所有子游戏都用 `ylgame://h5/...`，否则 origin 拆分破坏 localStorage 共享。

#### 7.6.2 `AppSchemeHandler` 实现要点

```swift
// Source/WebView/AppSchemeHandler.swift（实际实现 ~330 行）
@MainActor
public final class AppSchemeHandler: NSObject, WKURLSchemeHandler {
    public static let shared = AppSchemeHandler()

    private let ioQueue = DispatchQueue(
        label: "ylgamehall.app-scheme-handler.io",
        qos: .userInitiated,
        attributes: .concurrent
    )

    // 关键：只用 Sendable 的 ObjectIdentifier 跨闭包边界，
    // 不把非 Sendable 的 any WKURLSchemeTask 捕获进 @Sendable 闭包
    private var tasksByKey: [ObjectIdentifier: any WKURLSchemeTask] = [:]
    private var cancelledKeys: Set<ObjectIdentifier> = []

    public func webView(_ webView: WKWebView, start task: any WKURLSchemeTask) {
        let key = ObjectIdentifier(task)
        tasksByKey[key] = task
        // 1) 路由 + 文件路径解析（主线程，廉价）
        let resolved = try? Self.resolveRequest(url: task.request.url!, ...)
        // 2) 读盘 → IO 队列；deliver 回 MainActor
        ioQueue.async { [weak self] in
            let result = Self.readAndBuildResponse(resolved: resolved!)
            Task { @MainActor in
                self?.deliver(result: result, forKey: key)
            }
        }
    }

    public func webView(_ webView: WKWebView, stop task: any WKURLSchemeTask) {
        // 仅标记取消；不立即移除 tasksByKey（避免与 IO 完成竞态）
        cancelledKeys.insert(ObjectIdentifier(task))
    }
}
```

#### 7.6.3 关键设计抉择

1. **单虚拟 host = `h5`**：所有 H5 页共用此 host，保证 same-origin → localStorage / IndexedDB / sessionStorage 全局共享。**不要**用 `app://lobby` / `app://subgame` 区分，那会拆 origin
2. **`nonisolated` 常量**：项目启用了 MainActor-by-default，`AppScheme` enum 所有 `static let` + `MimeMap` 函数必须显式 `nonisolated`，否则背景 IO 队列无法访问（编译期 actor 隔离错）
3. **Sendable 闭包安全**：`ioQueue.async` 是 `@Sendable` 边界，闭包只能携带 Sendable 类型。`any WKURLSchemeTask` 不是 Sendable → 用 `ObjectIdentifier` key 跨边界，主线程持表查 task 派发回调
4. **取消语义严格性**：WKURLSchemeTask 协议规定 `stop` 后**禁止**再向 task 发任何消息（否则 NSException 闪退）。`cancelledKeys` Set + `deliver` 内短路防御
5. **Range 请求**：解析 `Range: bytes=...` → 206 + `Content-Range`；Cocos `<audio>` seek 必需，HTML5 `<video>` 同样依赖
6. **MIME 表覆盖 Cocos 常用扩展**：`.atlas` / `.fnt` / `.plist` / `.wasm` / `.webp` / `.ogg` / `.m4a` / `.woff2` 全打表，未命中走 `application/octet-stream`（WKWebView 仍能下载，只是不会按内容类型行为）
7. **响应头策略**：
   - `Cache-Control: no-cache`：沙盒升级（`LobbyZipUpgrader` / `SubGameDownloader`）会原地覆盖文件，禁强缓存避免错版
   - `Access-Control-Allow-Origin: *`：防御部分 Cocos build 的 fetch 默认 `mode: 'cors'` 走 preflight
   - `Accept-Ranges: bytes`：告诉 H5 媒体元素本资源支持 Range
8. **路由 fail-fast**：未知前缀（既不是 `/lobby/` 也不是 `/subgame/`）直接 404，避免误读到沙盒任意目录

#### 7.6.4 BridgedWebView 注册时机

```swift
// Source/WebView/BridgedWebView.swift init()
let configuration = WKWebViewConfiguration()
// ... 其它配置 ...

// 必须在 WKWebView(configuration:) 创建之前注册
configuration.setURLSchemeHandler(
    AppSchemeHandler.shared,
    forURLScheme: AppScheme.scheme   // "ylgame"
)

let webView = WKWebView(frame: .zero, configuration: configuration)
```

注意：WKWebViewConfiguration 是 **value copy** 但内部状态共享。注册一次后所有由这份 configuration 派生的 WKWebView 都会用同一 handler 实例。BridgedWebView 每次 init 都重新创建 configuration，但 handler 是 singleton，并发安全（主线程访问 + Sendable 闭包边界）。

#### 7.6.5 与 file:// 旧路径的等价性表

| 行为 | file:// 现状 | ylgame://h5 新方案 | 等价性 |
|------|------|------|------|
| 大厅 / 子游戏加载 | `loadFileURL(_:allowingReadAccessTo:)` | `load(URLRequest(url:))` | ✅ |
| 跨页 localStorage | null origin 共享 | host=`h5` same-origin 共享 | ✅ 业务可观察等价 |
| `app_*.js` `<script src>` 同步引入 | 沙盒文件同目录 | handler 透明读相同物理路径 | ✅ 时序等价 |
| XHR/fetch 资源 | null origin 受限 | 同源直通 | 🆙 **改善**（Cocos 适配前提） |
| `<audio>` Range seek | WKWebView 原生 | handler 实现 206 | ✅ 行为等价 |
| 跨目录 `../foo.js` | allowingReadAccessTo 放行 | path 自然落回 lobbyRoot | ✅ |
| WKWebsiteDataStore | `.default()` 持久化 | 同左 | ✅ |
| `location.protocol` | `"file:"` | `"ylgame:"` | ⚠️ H5 可观察差异（项目方已 grep 验证现网 H5 不踩此红线） |

#### 7.6.6 接入 Cocos 子游戏的预检脚本（建议加入 SDK-Integration-Guide）

```bash
# 子游戏 zip 解开后
grep -RnE "location\.protocol|window\.location\.(protocol|host)" $SUBGAME_DIR
grep -RnE "['\"](file|https?)://[^'\"]"                          $SUBGAME_DIR
grep -Rn  "serviceWorker\.register"                              $SUBGAME_DIR
grep -Rn  "document\.cookie"                                     $SUBGAME_DIR
```

四条全为空 / 仅命中无害日志 → 该子游戏可直接接入；命中关键路径 → 评估是否单游戏开 http://localhost 逃生门（per-game 配置）。

#### 7.6.7 故障排查矩阵

| 症状 | 可能原因 |
|------|---------|
| H5 不加载，splash 永不淡出 | scheme handler 未注册 / configuration 注册时序错（必须在 WKWebView init 前） |
| H5 加载但黑屏 | 路由失败（404）→ Xcode console 看 `✗ file not found:`；检查 lobbyRoot 实际存在的文件名是否与 URL path 段对应 |
| `app_data.js` 未生效（H5 用默认值） | AppDataWriter 写盘失败 / 写入路径与 handler 读路径不一致；检查 `SandboxPaths.lobbyIndex.deletingLastPathComponent()` 是否等于 `lobbyRoot/<gameStart>/` |
| 子游戏切换后大厅 localStorage 丢失 | 子游戏与大厅 URL host 不一致 → origin 拆分；检查 `subGameIndexAppURL` 是否仍是 `ylgame://h5/...` |
| `<audio>` 中段 seek 失败 | handler Range 处理错；检查 `parseRange` 是否正确返回 206 + `Content-Range` |
| Cocos XHR 仍失败 | 个别 Cocos build 自检 `location.protocol === 'http:'`；按 7.6.6 预检脚本定位字面命中点 |
| `NSInternalInconsistencyException` "Completed task" | handler 对 stopped task 仍调了 `didReceive/didFinish`；检查 `cancelledKeys` 短路逻辑 |

---

## 8. 能力模块详细设计

### 8.1 AudioKit

```swift
// AudioKit/AudioPlayer.swift
@MainActor
public final class AudioPlayer {
    private var background: AVAudioPlayer?
    private var button: AVAudioPlayer?
    private var voice: AVAudioPlayer?

    public func playOnce(_ url: URL) { ... }
    public func loopBackground(_ url: URL, type: String) { ... }
    public func stopBackground(type: String) { ... }
    public func playVoice(_ url: URL,
        onStart: @escaping () -> Void,
        onEnd: @escaping () -> Void) { ... }
}

// AudioKit/VoiceCoder.swift
/// 全新 Swift wrapper,直接调用 libopencore-amr 静态库的 C 接口
/// (opencore-amr 是闭源静态库,业界标准 AMR 编解码实现,无 Swift 替代品)
public enum VoiceCoder {
    public static func amrToWav(_ src: URL, dest: URL) throws { ... }
    public static func wavToAmr(_ src: URL, dest: URL) throws { ... }
}

// AudioKit/AudioRecorder.swift
public actor AudioRecorder {
    public func record() async throws -> AudioFile { ... }
    /// 录音 → WAV → AMR → 上传七牛 → 返回 URL + 时长
    public func recordAndUpload() async throws -> (audioUrl: String, time: Int) { ... }
}
```

#### 8.1.1 H5 audio handlers 实现骨架

把契约 §3.1 §B 的 4 项 audio handler（【3】–【6】）粘到 AudioKit 上的胶水代码。注册集中到 `AudioHandlers` 一个 struct，便于发现 / 单测。

```swift
// Source/Bridge/Handlers/AudioHandlers.swift
import Foundation

public struct AudioHandlers {

    let bridge: BridgeProtocol
    let player: AudioPlayer
    let recorder: AudioRecorder
    let voiceCenter: VoiceCenter    // 8.1.1 引入，控制远端语音的播放总开关

    public func register() {
        bridge.register("srcIsloop",      handler: srcIsloop)
        bridge.register("prepareaudio",   handler: prepareAudio)
        bridge.register("mediaTypeAudio", handler: mediaTypeAudio)
        bridge.register("voicePlaying",   handler: voicePlaying)
    }

    // MARK: - 【3】 srcIsloop — 本地音频播放
    //
    // 契约 §3.1 ［3］：
    //   入参 src    : string  音频文件名，路径 = Library/Caches/{gamedir}/{gamestart}/assets/wav/{src}
    //   入参 isloop : int     0 单次 / 1 循环 / -1 停同类循环
    //   responseCallback: "Response from srcIsloop"
    //
    // 与原 msext 的差异（不破契约）：
    //   - 原项目用 NSURL fileURLWithPath: 拼接 + AVAudioPlayer 同步初始化；
    //     新外壳通过 SandboxPaths 统一管理，所有路径在一处可校验
    //   - 原项目 backgroundType 用全局 NSString *；新外壳把状态封装在
    //     @MainActor 的 AudioPlayer 内部，避免跨线程访问竞态

    private func srcIsloop(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("Response from srcIsloop")) }
        guard let obj = data?.asObject,
              let src = obj["src"]?.asString,
              let isloop = obj["isloop"]?.asInt
        else {
            Log.warn("srcIsloop: bad payload \(String(describing: data))")
            return
        }

        let url = SandboxPaths.lobbyAssets
            .appendingPathComponent("wav")
            .appendingPathComponent(src)

        await MainActor.run {
            switch isloop {
            case 0:  player.playOnce(url)
            case 1:  player.loopBackground(url, type: src)
            case -1: player.stopBackground(type: src)
            default: Log.warn("srcIsloop: unexpected isloop=\(isloop)")
            }
        }
    }

    // MARK: - 【4】 prepareaudio — 启动麦克风录音
    //
    // 契约 §3.1 ［4］：
    //   入参   : 忽略
    //   副作用 : 权限检查 → recordAndUpload → getaudiourl callback
    //   responseCallback: "Response from prepareaudio"
    //
    // 权限态参考契约 §6.2「ifauth」三态语义：
    //   0 denied/restricted → 弹 alert 并放弃
    //   1 not determined    → AudioRecorder 内部触发系统询问
    //   2 authorized        → 直接录
    //
    // 与原 msext 的差异：
    //   - 原项目 VoiceRecorderBaseVC 是 UI 录音控件，含 cancel/redo 按钮，
    //     与 H5 的录音 UI 重复。新外壳走"无 UI 录音"路径：H5 自己画按钮，
    //     原生只负责拿到 AVAudioRecorder 数据 → 转码 → 上传，不弹任何原生界面
    //   - 上传走七牛 SDK（已在 ADR-006 选型）；旧 PostFile 接口不再支持

    private func prepareAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        cb?(.string("Response from prepareaudio"))

        switch await Permissions.microphone() {
        case .denied, .restricted:
            // 契约要求：通过 H5 alert 提示（不是原生弹窗），文案 = "{gamehallname}需要访问您的麦克风"
            bridge.call("alertMessage",
                        data: .string("\(BundleConfig.shared.appDisplayName)需要访问您的麦克风"),
                        callback: nil)
            return
        case .notDetermined, .authorized:
            break
        }

        do {
            let result = try await recorder.recordAndUpload()
            // 契约 §3.2 ［9］：getaudiourl({audiourl, time})
            bridge.call("getaudiourl",
                        data: .object([
                            "audiourl": .string(result.audioUrl),
                            "time":     .string(String(result.time))    // ⚠️ 字符串，非 number
                        ]),
                        callback: nil)
        } catch {
            Log.error("prepareaudio failed: \(error)")
        }
    }

    // MARK: - 【5】 mediaTypeAudio — 远程语音回放
    //
    // 契约 §3.1 ［5］：
    //   入参 audiourl: string   远端 AMR URL
    //   入参 user    : string   发声方用户 ID
    //   responseCallback: "Response from mediaTypeAudio"
    //   开始播放: gameui_play_voice(user)  （§3.2 ［7］）
    //   播放结束: gameui_stop_voice(user)  （§3.2 ［8］）
    //
    // 总开关由 voicePlaying 控制（【6】），关闭态直接静默不播

    private func mediaTypeAudio(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("Response from mediaTypeAudio")) }

        guard let obj = data?.asObject,
              let urlStr = obj["audiourl"]?.asString,
              let user = obj["user"]?.asString,
              let remote = URL(string: urlStr)
        else {
            Log.warn("mediaTypeAudio: bad payload")
            return
        }

        guard await voiceCenter.isEnabled else {
            Log.info("mediaTypeAudio: voicePlaying off, skip user=\(user)")
            return
        }

        do {
            let amr = try await AudioDownloader.fetchToCache(remote)
            let wav = try await VoiceCoder.amrToWavAsync(amr)
            await MainActor.run {
                player.playVoice(wav,
                    onStart: { [weak bridge] in
                        bridge?.call("gameui_play_voice", data: .string(user), callback: nil)
                    },
                    onEnd: { [weak bridge] in
                        bridge?.call("gameui_stop_voice", data: .string(user), callback: nil)
                    })
            }
        } catch {
            Log.error("mediaTypeAudio failed for user=\(user): \(error)")
            // 失败也要补 stop_voice，避免 H5 端 UI 永远停在"播放中"状态
            bridge.call("gameui_stop_voice", data: .string(user), callback: nil)
        }
    }

    // MARK: - 【6】 voicePlaying — 语音播放总开关
    //
    // 契约 §3.1 ［6］：
    //   入参: int 1=允许 / 其他=静默
    //   responseCallback: "Response from voicePlaying"
    //
    // VoiceCenter 持有 actor 隔离的 bool 状态，mediaTypeAudio 每次读它
    // 决定要不要走完整的下载→转码→播放链路

    private func voicePlaying(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("Response from voicePlaying")) }
        let on = (data?.asInt ?? 0) == 1
        await voiceCenter.setEnabled(on)
    }
}

/// 远端语音播放总开关（actor 隔离，跨 bridge handler 共享）
public actor VoiceCenter {
    public private(set) var isEnabled: Bool = true
    public func setEnabled(_ on: Bool) { isEnabled = on }
}
```

#### 8.1.2 与原 msext 的差异（不要照抄的部分）

| 维度 | msext 现状（不要照抄） | 新外壳决策 |
|------|--------------------|----------|
| 录音 UI | `VoiceRecorderBaseVC` 原生录音控件 + cancel/redo 按钮 | 无原生 UI；H5 自己画按钮，原生只录 + 转 + 传 |
| 上传 | 旧 `gameapi.0791ts.cn/api/UpLoad/PostFile` 直传后台 | 七牛 SDK（ADR-006 已选型） |
| 状态共享 | `static NSString *backgroundType` 全局变量 | `@MainActor` AudioPlayer 内部状态 |
| 总开关 | `static BOOL voicePlaying` | `actor VoiceCenter` 隔离 |
| 路径拼接 | 各处 `NSURL fileURLWithPath:` 散落 | 统一走 `SandboxPaths.lobbyAssets` |

### 8.2 ShareKit

策略模式分发,新增平台只加一个 conformance:

```swift
// ShareKit/SharePlatform.swift
public protocol SharePlatform: Sendable {
    func shareLink(_ link: ShareLink) async throws -> ShareResult
    func shareImage(_ image: ShareImage) async throws -> ShareResult
}

public enum ShareResult { case success, cancelled, failed(Error) }

public final class ShareCenter {
    private let weChat: SharePlatform = WeChatShare()
    private let xianliao: SharePlatform = NoopSharePlatform(name: "xianliao")  // 暂未启用

    public func dispatch(_ req: ShareRequest) async throws -> ShareResult {
        let platform: SharePlatform = req.sharetype == 3 ? xianliao : weChat
        switch req.type {
        case 1: return try await platform.shareLink(req.asLink())
        case 2: return try await platform.shareImage(req.asScreenshot())
        default: return try await platform.shareImage(try await req.asRemoteImage())
        }
    }
}

// ShareKit/NoopSharePlatform.swift
/// 暂未集成的分享平台占位实现:返回 success 让 H5 业务继续(契约要求 sharesuccess 回包)
/// 启用时,把 ShareCenter.xianliao 的初始化换成 XianliaoShare() 即可
public struct NoopSharePlatform: SharePlatform {
    let name: String
    public func shareLink(_ link: ShareLink) async throws -> ShareResult {
        Log.info("Share to \(name) is not enabled, returning success stub")
        return .success
    }
    public func shareImage(_ image: ShareImage) async throws -> ShareResult {
        Log.info("Share to \(name) is not enabled, returning success stub")
        return .success
    }
}
```

> 说明:闲聊 SDK 暂不集成,但 H5 调 `friendsSharetypeUrlToptitleDescript({sharetype:"3", ...})` 时仍然会收到 `sharesuccess({success:"2", type:...})`,业务流程不中断。启用闲聊 SDK 后,`NoopSharePlatform(name:"xianliao")` 替换为 `XianliaoShare()`,H5 端无感切换。

### 8.3 VideoRoomKit

抽象 protocol,当前默认注入 NoopVideoRoom(Agora 暂不集成),业务启用时换成 AgoraVideoRoom:

```swift
// VideoRoomKit/VideoRoom.swift
public protocol VideoRoom: AnyObject {
    func join(roomId: String, playerId: String, localView: UIView) async throws
    func setupRemote(uid: UInt, view: UIView)
    func leave()
    var didReceiveRemoteVideo: ((UInt) -> Void)? { get set }
}

// VideoRoomKit/NoopVideoRoom.swift
/// 暂未集成 Agora 时使用:所有方法立即返回,不做任何动作
/// 上层 SubGameHandlers 已经把 createRoom/exitRoom/getVideoinfo 改成 stub responseCallback
/// 此 Noop 实现仅用于满足依赖注入,使 SubGameHandlers 不需要 nil-check
public final class NoopVideoRoom: VideoRoom {
    public var didReceiveRemoteVideo: ((UInt) -> Void)?
    public init() {}
    public func join(roomId: String, playerId: String, localView: UIView) async throws {
        Log.info("VideoRoom.join called (NoopVideoRoom, Agora not integrated)")
    }
    public func setupRemote(uid: UInt, view: UIView) {}
    public func leave() {}
}

// VideoRoomKit/AgoraVideoRoom.swift
/// 真实现:Agora SDK 接入后启用。
/// 当前不在工程的 SPM 依赖里,业务启用时再:
///   ① 在 Package.swift 加 .package(url: "...AgoraRtcEngine_iOS"...)
///   ② 用此类替换 NoopVideoRoom 注入 SubGameHandlers
///   ③ 文件保留在 VideoRoomKit/ 模块中作为蓝图(以下为业务启用时的实现轮廓,而非当前已编译代码)
#if AGORA_ENABLED   // 编译开关,当前默认 OFF
import AgoraRtcKit
final class AgoraVideoRoom: NSObject, VideoRoom, AgoraRtcEngineDelegate {
    private let kit: AgoraRtcEngineKit
    var didReceiveRemoteVideo: ((UInt) -> Void)?

    init() {
        self.kit = AgoraRtcEngineKit.sharedEngine(withAppId: AppIDs.agora, delegate: nil)
        super.init()
        self.kit.delegate = self
    }

    func join(roomId: String, playerId: String, localView: UIView) async throws {
        let channel = md5("\(BundleConfig.shared.agent)\(BundleConfig.shared.gameId)\(roomId)")
        let canvas = AgoraRtcVideoCanvas()
        canvas.uid = UInt(playerId) ?? 0
        canvas.view = localView
        kit.setupLocalVideo(canvas)
        try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
            kit.joinChannel(byKey: nil, channelName: channel, info: nil,
                            uid: canvas.uid) { _, _, _ in cont.resume() }
        }
    }

    func setupRemote(uid: UInt, view: UIView) { /* setupRemoteVideo */ }
    func leave() { kit.leaveChannel(nil) }

    func rtcEngine(_ engine: AgoraRtcEngineKit,
                   firstRemoteVideoDecodedOfUid uid: UInt, size: CGSize, elapsed: Int) {
        didReceiveRemoteVideo?(uid)
    }
}
#endif
```

> **业务启用 Agora 的步骤**(未来某天需要时):
> 1. 在 `Package.swift` 加 Agora SPM 依赖,工程加 `AGORA_ENABLED` 编译开关
> 2. SubGameHandlers 的 `videoRoom` 依赖从 `NoopVideoRoom()` 改为 `AgoraVideoRoom()`
> 3. `SubGameHandlers.createRoomStub` / `getVideoInfoStub` / `exitRoomStub` 换成真实业务逻辑(调 `videoRoom.join` / `videoRoom.leave` 等)
> 4. H5 端代码不动 — 它仍然 `bridge.callHandler('createRoom', {...})`,看到的 `getVideoinfo(uid)` 反向回包等契约不变

### 8.4 DeviceKit

```swift
// DeviceKit/DeviceInfo.swift
public enum DeviceInfo {
    public static var snapshot: [String: String] {
        [
            "PhoneAdresseMAC":    UIDevice.current.identifierForVendor?.uuidString ?? "",
            "PhoneDeviceBrand":   UIDevice.current.model,
            "PhoneIMEI":          ASIdentifierManager.shared().advertisingIdentifier.uuidString,
            "PhoneModel":         UIDevice.current.localizedModel,
            "PhoneProvidersName": CarrierName.current ?? "",
            "PhoneVersion":       UIDevice.current.systemVersion
        ]
    }
}

// DeviceKit/NetworkMonitor.swift
@MainActor
public final class NetworkMonitor {
    public enum State: String { case none = "1", wifi = "2", cellular = "3" }
    private let monitor = NWPathMonitor()
    public var stateDidChange: ((State) -> Void)?

    public func start() {
        monitor.pathUpdateHandler = { [weak self] path in
            Task { @MainActor in
                let state: State =
                    path.status != .satisfied ? .none :
                    path.usesInterfaceType(.wifi) ? .wifi : .cellular
                self?.stateDidChange?(state)
            }
        }
        monitor.start(queue: DispatchQueue.global(qos: .utility))
    }
}
```

用 `Network.framework` 替换 `AFNetworkReachabilityManager`。

#### 8.4.1 H5 device handlers + 系统事件反向 callback 实现骨架

DeviceKit 共承载 **9 项契约接口**：
- §3.1 异步 handler 6 项：`vibrator` / `repeatvibrator` / `canclevibrator` / `gamepastetext` / `gameCopytext` / `getphoneInfo`
- §3.2 反向 callback 3 项：`getBattery`（电量变化推送）/ `getnetwork`（网络变化推送）/ `appservice`（前后台切换推送）

注：契约 §3.2 ［2］［3］的反向 `getBattery` / `getnetwork` 名字与 §3.4.1 polyfill 的同步 getter `getbattery()` / `getnetwork()` 重合但语义不同：
- **polyfill** = H5 同步问"当前是什么"（一次性快照，注入到 `window.settings`）
- **callback** = Native 异步推"刚刚变成了什么"（事件驱动，通过 `bridge.call(...)` 推给 H5）
- 两条路径并存、互不矛盾，H5 根据使用场景选其一

```swift
// Source/Bridge/Handlers/DeviceHandlers.swift
import UIKit
import AudioToolbox

@MainActor
public struct DeviceHandlers {

    let bridge: BridgeProtocol
    let pasteboard: UIPasteboard = .general
    // BatteryMonitor / NetworkMonitor / AppLifecycleMonitor 在 register() 内启动
    // 并把回调钩到 bridge.call(...)，构成"事件 → callback"的反向链路

    let batteryMonitor: BatteryMonitor
    let networkMonitor: NetworkMonitor
    let appLifecycle:   AppLifecycleMonitor

    public func register() {
        // ─── §3.1 异步 handler ──────────────────────────────
        bridge.register("vibrator",        handler: vibrator)
        bridge.register("repeatvibrator",  handler: repeatVibrator)
        bridge.register("canclevibrator",  handler: cancelVibrator)
        bridge.register("gamepastetext",   handler: gamePasteText)
        bridge.register("gameCopytext",    handler: gameCopyText)
        bridge.register("getphoneInfo",    handler: getPhoneInfo)

        // ─── §3.2 反向 callback 钩子 ────────────────────────
        bindReverseCallbacks()
    }

    // MARK: - 【10】 vibrator — 单次振动
    //
    // 契约 §3.1 ［10］：入参忽略 → AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
    //                  responseCallback: "vibrator"（注意：vibrator 系列的 callback 是
    //                                   handler 名字本身，不是 "Response from xxx"，
    //                                   与 audio 系列约定不同；参考 msext Bridge.m 约定）
    //
    // 当前实现见 Source/Bridge/Handlers/VibratorHandler.swift（Phase 1.15 已落地，
    // 本节是把它放进 DeviceHandlers 聚合时的等价骨架）

    private func vibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
        cb?(.string("vibrator"))
    }

    // MARK: - 【11】 repeatvibrator — 重复振动（实际同 vibrator）
    //
    // 契约 §3.1 ［11］：入参 int 但被忽略，原 msext RootVC.m:1820 实现就是单次 vibrate
    // 沿用历史，不真的循环

    private func repeatVibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
        cb?(.string("repeatvibrator"))
    }

    // MARK: - 【12】 canclevibrator — 释放系统振动音
    //
    // 契约 §3.1 ［12］：AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)
    // 实际意义有限（kSystemSoundID_Vibrate 是系统常量，dispose 也不会真的"取消"），
    // 但 H5 仍会调用，必须 noop 实现 + callback 维持契约

    private func cancelVibrator(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)
        cb?(.string("canclevibrator"))
    }

    // MARK: - 【13】 gamepastetext — 读剪贴板
    //
    // 契约 §3.1 ［13］：
    //   入参 : 忽略
    //   responseCallback: 剪贴板字符串内容（不是 "gamepastetext"），nil 时回空串
    //
    // 注意：iOS 14+ 读剪贴板会弹"已粘贴自 XX"系统提示，无法关闭，是平台行为
    // 不算契约违反；如未来 H5 业务希望静默读，需 H5 端配合改用 UIPasteboard 检测 API

    private func gamePasteText(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        let text = pasteboard.string ?? ""
        cb?(.string(text))
    }

    // MARK: - 【14】 gameCopytext — 写剪贴板
    //
    // 契约 §3.1 ［14］：
    //   入参 data : string  要写入剪贴板的文本
    //   responseCallback: "gameCopytext"

    private func gameCopyText(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        if let text = data?.asString {
            pasteboard.string = text
        }
        cb?(.string("gameCopytext"))
    }

    // MARK: - 【21】 getphoneInfo（大写 I）— 请求设备信息
    //
    // 契约 §3.1 ［21］：
    //   入参 : 忽略
    //   responseCallback: "getphoneInfo"（注意大写 I）
    //   反向调用: bridge.call("getphoneinfo"（小写 i！）, data: 表 A, callback: nil)
    //
    // 大小写 i 不一致是 msext 历史遗留契约，必须严格保持：
    //   - H5 调原生时用大写：bridge.callHandler("getphoneInfo", ...)
    //   - 原生回调 H5 时用小写：bridge.callHandler("getphoneinfo", ...)
    //
    // 表 A 字段名固定（PhoneAdresseMAC / PhoneDeviceBrand / PhoneIMEI / PhoneModel /
    // PhoneProvidersName / PhoneVersion），值统一为 string，详见 Contract §3.2 表 A

    private func getPhoneInfo(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        cb?(.string("getphoneInfo"))
        // 反向 push 设备信息快照（注意 handler 名小写 i）
        let snapshot = DeviceInfo.snapshot
        bridge.call("getphoneinfo",
                    data: .object(snapshot.mapValues { .string($0) }),
                    callback: nil)
    }

    // MARK: - §3.2 反向 callback 钩子（事件驱动）

    private func bindReverseCallbacks() {
        // 【2】 getBattery — UIDeviceBatteryLevelDidChangeNotification
        //   数据：字符串 "%.2f" 形式的小数电量（0~1）
        UIDevice.current.isBatteryMonitoringEnabled = true
        batteryMonitor.onChange = { [weak bridge] level in
            let str = String(format: "%.2f", level)
            bridge?.call("getBattery", data: .string(str), callback: nil)
        }
        batteryMonitor.start()

        // 【3】 getnetwork — NWPath 变化
        //   数据：字符串 "1"=无网 / "2"=WiFi / "3"=蜂窝
        networkMonitor.stateDidChange = { [weak bridge] state in
            bridge?.call("getnetwork", data: .string(state.rawValue), callback: nil)
        }
        networkMonitor.start()

        // 【4】 appservice — App 前后台切换
        //   数据：字符串 "2"=进入后台 / "1"=回到前台（值错位沿用 msext WKWebView 路径历史，
        //   参 Contract.md §3.2 `appservice` 值错位说明，以及原 NewRootVC.m:1773/1782）
        appLifecycle.onBackground = { [weak bridge] in
            bridge?.call("appservice", data: .string("2"), callback: nil)
        }
        appLifecycle.onForeground = { [weak bridge] in
            bridge?.call("appservice", data: .string("1"), callback: nil)
        }
        appLifecycle.start()
    }
}

// MARK: - 支撑监控器（Source/DeviceKit/）

@MainActor
public final class BatteryMonitor {
    public var onChange: ((Float) -> Void)?
    public func start() {
        NotificationCenter.default.addObserver(
            forName: UIDevice.batteryLevelDidChangeNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            self?.onChange?(UIDevice.current.batteryLevel)
        }
    }
}

@MainActor
public final class AppLifecycleMonitor {
    public var onBackground: (() -> Void)?
    public var onForeground: (() -> Void)?
    public func start() {
        let nc = NotificationCenter.default
        nc.addObserver(forName: UIApplication.didEnterBackgroundNotification,
                       object: nil, queue: .main) { [weak self] _ in
            self?.onBackground?()
        }
        nc.addObserver(forName: UIApplication.willEnterForegroundNotification,
                       object: nil, queue: .main) { [weak self] _ in
            self?.onForeground?()
        }
    }
}
```

#### 8.4.2 与原 msext 的差异

| 维度 | msext 现状（不要照抄） | 新外壳决策 |
|------|--------------------|----------|
| 振动取消 | `AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)` 同款 | 同款（契约硬约束，结构性意义有限但 H5 仍依赖 callback） |
| 剪贴板 | `[UIPasteboard generalPasteboard].string` | `UIPasteboard.general.string`（行为等价；iOS 14+ "已粘贴"提示是平台行为，不算契约违反） |
| 电量监听 | `UIDeviceBatteryLevelDidChangeNotification` + `[NSString stringWithFormat:@"%.2f"]` | `Notification.batteryLevelDidChangeNotification` + `String(format:)`（等价） |
| 网络监听 | `AFNetworkReachabilityManager` | `Network.framework` `NWPathMonitor`（ADR-006，无 AF 依赖） |
| 前后台 | `UIApplicationDidEnterBackgroundNotification` + `UIApplicationWillEnterForegroundNotification` 各自挂大厅 VC | `AppLifecycleMonitor` 集中处理，多 VC 复用 |
| getphoneInfo 大小写 | 大写 I 入 / 小写 i 出 | 严格保持（契约硬约束，msext Bridge.m 沿用） |

### 8.5 微信 / QQ 多发起方派发(关键设计)

#### 问题背景

微信 SDK 是**全局单例**(`WXApi`),只接受**一个全局 delegate**。所有授权回包 (`SendAuthResp`) 和分享回包 (`SendMessageToWXResp`) 都通过这个唯一 delegate 派发。

新架构里大厅和子游戏的 H5 **都会调用** `accreditlogin` 和 `friendsSharetypeUrlToptitleDescript`:

```
H5 大厅           H5 子游戏
   │                 │
   ▼                 ▼
LobbyHandlers   SubGameHandlers
   │                 │
   └─────────┬───────┘
             ▼
       WXApi.send(...)
             │
             ▼
        (微信 App)
             │
             ▼  回包(只有 1 个 delegate 接收)
        WXApiDelegate
             │
             ❓ 派发给大厅还是子游戏?
```

**旧架构的错误做法**:让大厅和子游戏轮流持有 delegate(`WXApiManager.delegate = self / nil`),靠"谁活着谁注册"传球。这在以下场景必然出错:
- 大厅发起授权后**用户立刻切去子游戏**,授权回包来时 delegate 已经换给子游戏 → 大厅 H5 永远收不到 `sharelogin`,卡死
- 子游戏发起分享后回到大厅,分享回包来时归属错乱
- VC 之间忘记同步 delegate → 回包静默丢失

#### 新架构方案:Continuation Map by state

`WXApi.delegate` **永久绑定**到 `WeChatManager.shared`,**永不切换**。所有回包统一进入这个单例,再通过**请求-响应配对**派发给正确的发起方。

微信 OAuth2 协议天然提供 `state` 字段作为配对 key —— 发起请求时客户端塞一个 UUID,微信回包字面回传,**这就是天生的请求 ID**:

```swift
// SDK/WeChat/WeChatManager.swift
@MainActor
public final class WeChatManager: NSObject, WXApiDelegate {
    public static let shared = WeChatManager()

    /// in-flight 授权请求:state → continuation
    /// 谁 await,谁拿到回包,与"哪个 VC 在前台"无关
    private var pendingAuth: [String: CheckedContinuation<WXAuthCode, WeChatError>] = [:]

    /// in-flight 分享请求(SendMessageToWXResp 不带 state,只能用 FIFO 队列 + 串行约束)
    private var pendingShare: CheckedContinuation<Void, WeChatError>?

    private override init() {
        super.init()
        // 启动时一次性注册,delegate 永不切换
        WeChatSDK.registerFull(appId: AppIDs.weChat)
        // WXApi.delegate(handleOpenURL 回调路径)由 SceneDelegate.openURLContexts 转给 self
    }

    /// 发起授权(任何 handler 都可以调,内部用 continuation 唯一对应本次调用)
    public func authorize(scope: String = kAuthScope) async throws -> WXAuthCode {
        let state = "wx_" + UUID().uuidString
        try await Task.checkCancellation()

        return try await withCheckedThrowingContinuation { cont in
            pendingAuth[state] = cont

            let req = SendAuthReq()
            req.scope = scope
            req.state = state                     // 配对 key,会被微信原样回传
            WXApi.send(req)
        }
    }

    /// 发起分享(串行化 — 同时只能有一个 pending,新请求等上一个完成)
    public func share(_ message: WXMediaMessage, scene: WXScene) async throws {
        // 如已有 pending,抛 .busy 让上层 H5 自行重试;不允许并发避免回包错配
        guard pendingShare == nil else { throw WeChatError.busy }

        return try await withCheckedThrowingContinuation { cont in
            pendingShare = cont

            let req = SendMessageToWXReq()
            req.message = message
            req.bText = false
            req.scene = Int32(scene.rawValue)
            WXApi.send(req)
        }
    }

    // MARK: - WXApiDelegate(全局唯一 delegate)

    public func onResp(_ resp: BaseResp) {
        switch resp {
        case let auth as SendAuthResp:
            handleAuthResponse(auth)
        case let share as SendMessageToWXResp:
            handleShareResponse(share)
        default:
            Log.warn("Unhandled WeChat response: \(type(of: resp))")
        }
    }

    private func handleAuthResponse(_ resp: SendAuthResp) {
        guard let state = resp.state,
              let cont = pendingAuth.removeValue(forKey: state) else {
            Log.warn("Stale or unknown WeChat auth response: state=\(resp.state ?? "nil")")
            return
        }
        if resp.errCode == 0, let code = resp.code {
            cont.resume(returning: WXAuthCode(code: code))
        } else if resp.errCode == -2 {
            cont.resume(throwing: .userCancelled)
        } else {
            cont.resume(throwing: .authFailed(code: Int(resp.errCode)))
        }
    }

    private func handleShareResponse(_ resp: SendMessageToWXResp) {
        guard let cont = pendingShare else {
            Log.warn("Stale WeChat share response with no pending continuation")
            return
        }
        pendingShare = nil
        if resp.errCode == 0 {
            cont.resume()
        } else if resp.errCode == -2 {
            cont.resume(throwing: .userCancelled)
        } else {
            cont.resume(throwing: .shareFailed(code: Int(resp.errCode)))
        }
    }
}
```

#### 调用方代码 — 大厅 / 子游戏完全一致

**契约要求**:同一个 H5 容器内发起的回调,必须回到同一个 H5 容器:

- 大厅 H5 调 `accreditlogin` → `sharelogin` 必须回大厅 H5,**不能跨派到子游戏**
- 大厅 H5 调 `friendsShare...` → `sharesuccess` 必须回大厅 H5
- 子游戏 H5 调 `accreditlogin` → `sharelogin` 必须回子游戏 H5
- 子游戏 H5 调 `friendsShare...` → `sharesuccess` 必须回子游戏 H5

实现层面,**每个 Handler 持有自己的 `bridge`**(LobbyHandlers 持有 LobbyBridge,SubGameHandlers 持有 SubGameBridge)。回调时调用的是**自己的** bridge,与全局状态无关:

```swift
// LobbyHandlers.accreditLogin —— 持有大厅自己的 bridge
private let bridge: BridgeProtocol   // = LobbyBridge

private func accreditLogin(_ data: BridgeData?, _ cb: BridgeCallback?) async {
    defer { cb?(.string("Response from accreditlogin")) }
    do {
        let authCode = try await WeChatManager.shared.authorize()
        let user = try await loginAPI.exchangeForUser(code: authCode.code)
        bridge.call("sharelogin", data: user.bridgePayload, callback: nil)
        //  ↑ self.bridge 是 LobbyBridge,sharelogin 派到大厅 H5
    } catch WeChatError.userCancelled {
        Log.info("User cancelled WeChat auth in Lobby")
    } catch {
        Log.error("Lobby WeChat auth failed: \(error)")
    }
}

// LobbyHandlers.friendsShare —— 同样持有大厅 bridge
private func friendsShare(_ data: BridgeData?, _ cb: BridgeCallback?) async {
    defer { cb?(.string("sharefriend")) }
    guard let req = ShareRequest(data: data) else { return }
    do {
        try await ShareCenter.shared.dispatch(req)
        bridge.call("sharesuccess", data: .object([
            "success": .string("2"),
            "type":    .string(req.sharefriend)
        ]), callback: nil)
        //  ↑ 大厅发起,大厅 H5 收到 sharesuccess
    } catch WeChatError.userCancelled {
        Log.info("User cancelled WeChat share in Lobby")
    } catch {
        Log.error("Lobby share failed: \(error)")
    }
}

// SubGameHandlers.accreditLogin —— 代码一字不差,但 self.bridge 是 SubGameBridge
private let bridge: BridgeProtocol   // = SubGameBridge

private func accreditLogin(_ data: BridgeData?, _ cb: BridgeCallback?) async {
    defer { cb?(.string("Response from accreditlogin")) }
    do {
        let authCode = try await WeChatManager.shared.authorize()
        let user = try await loginAPI.exchangeForUser(code: authCode.code)
        bridge.call("sharelogin", data: user.bridgePayload, callback: nil)
        //  ↑ 子游戏发起,sharelogin 派到子游戏 H5
    } catch {
        Log.error("SubGame WeChat auth failed: \(error)")
    }
}

// SubGameHandlers.friendsShare —— 同样
private func friendsShare(_ data: BridgeData?, _ cb: BridgeCallback?) async {
    defer { cb?(.string("sharefriend")) }
    guard let req = ShareRequest(data: data) else { return }
    do {
        try await ShareCenter.shared.dispatch(req)
        bridge.call("sharesuccess", data: .object([
            "success": .string("2"),
            "type":    .string(req.sharefriend)
        ]), callback: nil)
        //  ↑ self.bridge 是 SubGameBridge,sharesuccess 派到子游戏 H5
    } catch {
        Log.error("SubGame share failed: \(error)")
    }
}
```

#### 为什么这套机制天然满足契约

回调归属的关键是 **"谁 await,谁拿到结果,然后谁 call 自己的 bridge"**,链路上没有任何"全局状态判断当前是谁"的环节:

```
H5 大厅 → bridge.callHandler('accreditlogin')
        ↓
LobbyHandlers.accreditLogin (持有 LobbyBridge)
        ↓
try await WeChatManager.shared.authorize()   ← 进入 pendingAuth[state]
                                              ← 大厅 await 在这里挂起
                                              
... 用户在微信里授权,可能切去子游戏再切回...

← 微信回包 SendAuthResp(state=同一个 UUID)
↓
WeChatManager.onResp → handleAuthResponse
↓
pendingAuth[state] 取出 continuation → resume
↓
LobbyHandlers.accreditLogin 函数从 await 处恢复执行    ← 仍在大厅的 handler 内
↓
self.bridge.call("sharelogin", ...)                  ← self.bridge 是 LobbyBridge
↓
H5 大厅收到 sharelogin                                ← 契约满足 ✓
```

**关键事实**:Swift 的 async/await 是基于 continuation 的,await 恢复后**回到的是同一个调用栈**(同一个 LobbyHandlers 实例的同一个方法),所以 `self.bridge` 自然还是 LobbyBridge,不存在"派错"的可能。

→ **大厅发起大厅回调、子游戏发起子游戏回调** 是这套设计的**结构保证**(structural guarantee),而不是靠运行时判断,所以不会因为 VC 切换、网络延迟、并发顺序而失效。

#### 边界场景验证(分享)

| 场景 | 行为 |
|------|------|
| 大厅 H5 调分享,等待中切去子游戏再切回 | WeChat 回包 → WeChatManager.pendingShare 是大厅的 continuation → resume → LobbyHandlers.friendsShare 从 await 恢复 → LobbyBridge.call(sharesuccess) → 大厅 H5 收到 ✓ |
| 大厅 H5 调分享后,子游戏 H5 立刻也调分享 | 子游戏的 ShareCenter.dispatch → WeChatManager.share 检测 pendingShare ≠ nil → 抛 `WeChatError.busy` → SubGameHandlers.friendsShare 捕获后选择重试或忽略;**不会**让子游戏的 continuation 覆盖大厅的 |
| 大厅 H5 调分享 ,然后调 backgameData(返回大厅) | 大厅本就在,无变化;continuation 仍归大厅 |
| 子游戏 H5 调分享后立刻 backgameData | 子游戏 VC pop 但 SubGameHandlers 实例仍被 `pendingShare.cont` 持有,直到回包到来 resume 后才会释放;回包派到 SubGameBridge,但子游戏 H5 已经销毁,bridge 的 evaluateJavaScript 在已销毁的 WebView 上 no-op(不崩溃,只是回包丢失) |

最后一条是**唯一可能丢回包的场景**(子游戏发起分享后立刻退出),实务上极少发生。新架构的对策:在 `SubGameHandlers.backgameData` 内主动检查 `WeChatManager.shared.hasPendingShare`,若有 pending 则延迟 pop 或弹提示"还有分享待完成"。这是可选优化,默认实现不强制。

#### SceneDelegate 接入 WXApi 入口

```swift
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    for ctx in URLContexts {
        if QQShareManager.shared.handle(url: ctx.url) { continue }
        // WXApi 的 handleOpenURL: 会调用其 delegate 的 onResp(也就是 WeChatManager.shared)
        WXApi.handleOpenURL(ctx.url, delegate: WeChatManager.shared)
    }
}
```

#### QQ SDK 同样模式

QQ SDK 也是全局 delegate,设计完全镜像:

```swift
@MainActor
public final class QQShareManager: NSObject {
    public static let shared = QQShareManager()
    private var pendingShare: CheckedContinuation<Void, QQError>?
    // 与 WeChatManager 相同的 continuation 模式
}
```

#### 为什么这套机制是对的

| 维度 | 旧架构(delegate 传球) | 新架构(continuation map by state) |
|------|---------------------|-----------------------------------|
| 发起方记录 | 隐式 — 当前 delegate 持有者 | 显式 — pendingAuth map 的 key,pendingShare 的 continuation |
| 大厅发起回包归属 | 取决于回包时 delegate 是谁 → 易错 | 结构保证:LobbyHandlers.bridge = LobbyBridge,sharelogin / sharesuccess 必派大厅 |
| 子游戏发起回包归属 | 同上,易错 | 结构保证:SubGameHandlers.bridge = SubGameBridge,sharelogin / sharesuccess 必派子游戏 |
| 切换 VC 后是否丢回包 | 丢(delegate 已换) | 不丢(map / continuation 不受 VC 切换影响) |
| 并发发起 | 串扰(后发起的覆盖前 delegate) | 授权按 state 自然隔离;分享串行化(后发起者抛 `.busy`,不覆盖) |
| 测试 | 难(全局状态) | 易(WeChatManager / ShareCenter 可注入 mock) |
| 代码可读性 | 散落 `delegate = self / nil` | 集中在 WeChatManager,调用方只看到 `try await` |

#### 微信 / QQ 派发机制清单

| 操作 | 配对方式 | 并发约束 |
|------|----------|---------|
| 微信授权 `authorize()` | `state` UUID(微信回传) | 允许多并发(每个 state 独立) |
| 微信分享 `share()` | 全局 FIFO | 串行(同时只能一个 pending,新请求抛 `.busy`) |
| QQ 登录 / 分享 | 全局 FIFO + 操作类型标识 | 同上 |

→ 上层 `accreditLogin` / `friendsShare...` handler 看不到这些细节,只看到 `try await`,代码大厅 / 子游戏完全一致。

### 8.6 LocationKit

覆盖契约 §F §3.1 ［20］`startlocation` + §3.2 ［6］`getlocationinfo` 反向 callback 共 2 项接口。

#### 8.6.1 Module 骨架

依赖高德 `AMapLocationKit.xcframework`（ADR-006 走 Vendor 手动接入），用 Swift wrapper 隔离 Objective-C SDK，避免业务层接触 AMap 类型。

```swift
// Source/LocationKit/LocationService.swift
import AMapLocationKit

@MainActor
public final class LocationService: NSObject, AMapLocationManagerDelegate {

    /// 启动期完成 AMap privacy + key 配置（详见 §4.2 启动并行任务）
    public static func bootstrap() {
        AMapServices.shared().enableHTTPS = true
        AMapServices.shared().apiKey = BundleConfig.shared.amapKey
        AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
        AMapLocationManager.updatePrivacyAgree(.didAgree)
    }

    private let manager: AMapLocationManager = {
        let m = AMapLocationManager()
        m.desiredAccuracy = kCLLocationAccuracyHundredMeters
        m.locationTimeout = 6
        m.reGeocodeTimeout = 4
        m.locatingWithReGeocode = true
        return m
    }()

    private var continuousActive = false

    public override init() {
        super.init()
        manager.delegate = self
    }

    // ─── 一次性定位（startlocation 入参 ≠ 1）──────────────
    public func locateOnce() async throws -> LocationInfo {
        try await withCheckedThrowingContinuation { cont in
            manager.requestLocation(withReGeocode: true) { loc, reGeo, error in
                if let error = error {
                    cont.resume(throwing: error)
                    return
                }
                guard let loc = loc, let reGeo = reGeo else {
                    cont.resume(throwing: LocationError.empty)
                    return
                }
                cont.resume(returning: LocationInfo(coord: loc, reGeo: reGeo))
            }
        }
    }

    // ─── 持续定位（startlocation 入参 == 1）─────────────────
    public var onContinuousUpdate: ((LocationInfo) -> Void)?

    public func startContinuous() {
        guard !continuousActive else { return }
        continuousActive = true
        manager.startUpdatingLocation()
    }

    public func stopContinuous() {
        guard continuousActive else { return }
        continuousActive = false
        manager.stopUpdatingLocation()
    }

    public func amapLocationManager(_ manager: AMapLocationManager!,
                                    didUpdate location: CLLocation!,
                                    reGeocode: AMapLocationReGeocode!) {
        guard let loc = location, let reGeo = reGeocode else { return }
        onContinuousUpdate?(LocationInfo(coord: loc, reGeo: reGeo))
    }
}

public struct LocationInfo: Sendable {
    public let address:   String
    public let city:      String
    public let cityCode:  String
    public let country:   String
    public let district:  String
    public let latitude:  String     // ⚠️ string，契约硬约束（stringWithFormat:@"%f"）
    public let longitude: String     // ⚠️ string
    public let province:  String     // ⚠️ 小写 p，与 sharelogin 大写 P 不一致，沿用历史
    public let street:    String

    init(coord: CLLocation, reGeo: AMapLocationReGeocode) {
        address  = reGeo.formattedAddress ?? ""
        city     = reGeo.city ?? ""
        cityCode = reGeo.citycode ?? ""
        country  = reGeo.country ?? ""
        district = reGeo.district ?? ""
        latitude  = String(format: "%f", coord.coordinate.latitude)
        longitude = String(format: "%f", coord.coordinate.longitude)
        province = reGeo.province ?? ""
        street   = reGeo.street ?? ""
    }
}

public enum LocationError: Error, Sendable {
    case empty
    case permissionDenied(code: Int = 12)
}
```

#### 8.6.2 H5 location handler 实现骨架

```swift
// Source/Bridge/Handlers/LocationHandlers.swift
@MainActor
public struct LocationHandlers {

    let bridge: BridgeProtocol
    let service: LocationService

    public func register() {
        bridge.register("startlocation", handler: startLocation)
    }

    // MARK: - 【20】 startlocation
    //
    // 契约 §3.1 ［20］：
    //   入参 data : int   1=持续 startUpdatingLocation
    //                    其他=一次性 reGeocodeAction
    //   responseCallback: "startlocation"
    //   反向 callback   : getlocationinfo（§3.2 ［6］）
    //                    成功: 表 B 9 字段（latitude/longitude 是 string，
    //                          province 小写 p）
    //                    失败: { errorCode: 12, errorMsg: "缺少定位权限" }
    //                          ⚠️ errorCode 是数字（NSNumber）不是 string
    //
    // 持续模式下，service.onContinuousUpdate 在 register() 时挂钩；
    // 一次性模式直接 await + 推一次

    private func startLocation(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("startlocation")) }
        let continuous = (data?.asInt ?? 0) == 1

        // 持续模式：挂钩 + 启动 → 每次位置变化都推 getlocationinfo
        if continuous {
            service.onContinuousUpdate = { [weak bridge] info in
                bridge?.call("getlocationinfo", data: info.bridgeData, callback: nil)
            }
            service.startContinuous()
            return
        }

        // 一次性：await + 单次推 getlocationinfo
        do {
            let info = try await service.locateOnce()
            bridge.call("getlocationinfo", data: info.bridgeData, callback: nil)
        } catch {
            // 契约 §3.2 表 B 失败结构：errorCode 是数字（不是 string）
            bridge.call("getlocationinfo",
                        data: .object([
                            "errorCode": .number(12),
                            "errorMsg":  .string("缺少定位权限")
                        ]),
                        callback: nil)
        }
    }
}

extension LocationInfo {
    /// 转成契约 §3.2 表 B 的 BridgeData 结构（所有字段 string）
    var bridgeData: BridgeData {
        .object([
            "address":   .string(address),
            "city":      .string(city),
            "cityCode":  .string(cityCode),
            "country":   .string(country),
            "district":  .string(district),
            "latitude":  .string(latitude),     // stringified
            "longitude": .string(longitude),    // stringified
            "province":  .string(province),     // 小写 p
            "street":    .string(street)
        ])
    }
}
```

#### 8.6.3 与原 msext 的差异

| 维度 | msext 现状（不要照抄） | 新外壳决策 |
|------|--------------------|----------|
| SDK 接入 | CocoaPods `AMapLocation` 包含的 framework + Reachability headers 多重耦合 | Vendor 手动 `AMapLocationKit.xcframework`（ADR-006），SPM/CocoaPods 双关 |
| 隐私合规 | `updatePrivacyShow:.didShow` / `updatePrivacyAgree:.didAgree` 在 AppDelegate 散落 | `LocationService.bootstrap()` 集中调用 |
| 回调风格 | `delegate amapLocationManager:didUpdateLocation:reGeocode:` 散落赋值给 self | `async/await` + `onContinuousUpdate` 闭包，actor 安全 |
| 持续/一次性入参 | 入参 string 类型，`[data intValue]` 转 int 后判断 | BridgeData.asInt 直接拿 |
| latitude 字段类型 | `stringWithFormat:@"%f"` 字符串 | `String(format: "%f", ...)`（等价） |
| province 大小写 | 小写 p（与 sharelogin 大写 P 不一致）| 严格保持 |

### 8.7 ShakeKit

覆盖契约 §C §3.1 ［7］[8] [9] 摇一摇 3 项 + §3.2 ［5］`shakeEnd` 反向 callback，共 4 项接口。

设备摇动的真正事件源是 `UIResponder.motionEnded(_:with:)`（系统检测晃动后回调），ShakeKit 把它包装为：开关位（canshake / canvoice）+ 摇一摇音效播放 + 触发 shakeEnd callback。

#### 8.7.1 Module 骨架

```swift
// Source/ShakeKit/ShakeKit.swift
@MainActor
public final class ShakeKit {

    /// 摇一摇总开关（startshake / stopshake 控制）
    public private(set) var canShake: Bool = false
    /// 摇一摇音效开关（SwitchShake 控制）
    public private(set) var canVoice: Bool = true

    public var onShakeEnded: (() -> Void)?

    private let audioPlayer: AudioPlayer
    private let shakeSoundURL: URL    // 拷贝自 docs/res/Res/shake_sound_male.mp3 → Bundle

    public init(audioPlayer: AudioPlayer, shakeSoundURL: URL) {
        self.audioPlayer = audioPlayer
        self.shakeSoundURL = shakeSoundURL
    }

    public func setShakeEnabled(_ on: Bool)  { canShake = on }
    public func setVoiceEnabled(_ on: Bool)  { canVoice = on }

    /// 由 WebContainerViewController.motionEnded(_:with:) 转发调用
    public func handleMotionEnded(_ motion: UIEvent.EventSubtype) {
        guard motion == .motionShake, canShake else { return }
        if canVoice { audioPlayer.playOnce(shakeSoundURL) }
        onShakeEnded?()
    }
}
```

**接入 VC 层**（在 §4.2.1「启动期容易遗漏的契约点」已声明 `UIApplication.shared.applicationSupportsShakeToEdit = true`，本节补 motionEnded 转发）：

```swift
extension WebContainerViewController {
    public override var canBecomeFirstResponder: Bool { true }

    public override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // 必须主动 becomeFirstResponder，否则 motionEnded: 不会触发
        becomeFirstResponder()
    }

    public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
        shakeKit.handleMotionEnded(motion)
    }
}
```

#### 8.7.2 H5 shake handlers + 反向 callback 骨架

```swift
// Source/Bridge/Handlers/ShakeHandlers.swift
@MainActor
public struct ShakeHandlers {

    let bridge: BridgeProtocol
    let shakeKit: ShakeKit

    public func register() {
        bridge.register("startshake",    handler: startShake)
        bridge.register("stopshake",     handler: stopShake)
        bridge.register("SwitchShake",   handler: switchShake)
        bindReverseCallback()
    }

    // MARK: - 【7】 startshake
    //
    // 契约 §3.1 ［7］：
    //   入参 : 忽略 → canshake = YES
    //   responseCallback: "startshake from accreditlogin"
    //                    （字面字符串带 from accreditlogin 后缀，沿用历史；
    //                     H5 不依赖此值，但严格拷贝保留契约等价）

    private func startShake(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        shakeKit.setShakeEnabled(true)
        cb?(.string("startshake from accreditlogin"))
    }

    // MARK: - 【8】 stopshake
    //
    // 契约 §3.1 ［8］：
    //   入参 : 忽略 → canshake = NO
    //   responseCallback: 同 startshake（"startshake from accreditlogin"）

    private func stopShake(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        shakeKit.setShakeEnabled(false)
        cb?(.string("startshake from accreditlogin"))
    }

    // MARK: - 【9】 SwitchShake — 摇一摇音效开关
    //
    // 契约 §3.1 ［9］：
    //   入参 : int 1 → canvoice = YES，其他 → canvoice = NO
    //   responseCallback: "SwitchShake"

    private func switchShake(_ data: BridgeData?, _ cb: BridgeCallback?) async {
        defer { cb?(.string("SwitchShake")) }
        let on = (data?.asInt ?? 0) == 1
        shakeKit.setVoiceEnabled(on)
    }

    // MARK: - §3.2 ［5］ shakeEnd 反向 callback
    //
    // 触发时机：UIResponder.motionEnded(.motionShake, with:) 且 canshake == YES
    // 数据：nil（契约规定 data 为 nil，不传额外字段）

    private func bindReverseCallback() {
        shakeKit.onShakeEnded = { [weak bridge] in
            bridge?.call("shakeEnd", data: nil, callback: nil)
        }
    }
}
```

#### 8.7.3 与原 msext 的差异

| 维度 | msext 现状（不要照抄） | 新外壳决策 |
|------|--------------------|----------|
| 开关状态 | `static BOOL canshake` / `static BOOL canvoice` 全局变量 | `@MainActor ShakeKit` 实例属性，VC 注入 |
| 摇一摇音效 | `[FuncPublic playWithFileName:@"shake_sound_male.mp3"]` 散落 | 复用 AudioKit 的 `AudioPlayer.playOnce`，统一路径 |
| 摇动事件 | 大厅 / 子游戏 VC 各自实现 `motionEnded:` 各自判断 | ShakeKit 集中判断 + 单点 callback 触发 |
| canBecomeFirstResponder | 大厅 VC override 为 YES，子游戏沿用默认 NO（潜在漏点） | 统一在 WebContainerViewController 基类内 override = true |
| responseCallback 字面 | `"startshake from accreditlogin"` 字面奇怪但沿用 | 严格保留（H5 不读，仅保契约不破） |
| shakeEnd 数据 | `nil` | 严格保留（Bridge.call data: nil） |

---

## 9. 并发模型

### 9.1 Actor 隔离

- `BridgeBus` — `@MainActor`(WKWebView 必须主线程)
- `HTTPClient` / `SGGateway` / `ConfigService` / `ResourceUnzipper` / `AudioRecorder` — `actor`(各自串行)
- 数据模型 — `Sendable struct`(值类型,自由跨 actor)
- 跨 actor 通信 — 全用 `await`,**不允许 GCD 直接 `dispatch_async`**

### 9.2 严禁的反模式

```swift
// ❌ 不要
DispatchQueue.main.async { self.bridge.call("foo", data: ...) }

// ✅ 要
Task { @MainActor in bridge.call("foo", data: ...) }
```

### 9.3 启用 Swift 6 严格并发

`Package.swift`:
```swift
swiftSettings: [
    .enableUpcomingFeature("StrictConcurrency"),
    .enableUpcomingFeature("ExistentialAny"),
    .enableUpcomingFeature("ConciseMagicFile")
]
```

---

## 10. 持久化

只用两种存储,**不引入数据库**:

| 数据 | 存储 | 备注 |
|------|------|------|
| 用户偏好(everLaunched / VersionInfo / getcompareCode 等) | `UserDefaults` | key 名是 H5 契约(通过 app_data.js 暴露给 H5),不允许变更 |
| 游戏 zip 解压物 | `Library/Caches/{gamedir}/` | 系统空间不足会清,需重新解压 |
| 录音临时文件 | `Library/Caches/recording/` | 上传完即删 |
| 下载中间产物 | `tmp/` | 系统自动清 |

```swift
// Foundation+/UserDefaults+TypedKey.swift
public enum DefaultsKey {
    static let everLaunched = "everLaunched"          // ⚠️ 旧 key,不改名
    static let versionInfo = "VersionInfo"
    static let compareCode = "getcompareCode"
    static let firstBool = "FirstBOOL"
    static let secondBool = "SecondBOOL"
    static let thirdBool = "ThirdBOOL"
}
```

---

## 11. 错误处理与日志

### 11.1 错误分层

```swift
// Foundation+/AppError.swift
public enum AppError: Error {
    case bridge(BridgeError)
    case network(NetworkError)
    case resource(ResourceError)
    case audio(AudioError)
    case auth(AuthError)
    case sdk(String, underlying: Error)
}
```

### 11.2 统一日志

```swift
// Logger/Log.swift
import os

public enum Log {
    private static let bridge = Logger(subsystem: "cn.daoqi", category: "Bridge")
    private static let net = Logger(subsystem: "cn.daoqi", category: "Net")
    private static let game = Logger(subsystem: "cn.daoqi", category: "Game")

    public static func debug(_ msg: String, file: String = #file, line: Int = #line) {
        #if DEBUG
        bridge.debug("\(msg) [\(file):\(line)]")
        #endif
    }
    public static func info(_ msg: String) { bridge.info("\(msg)") }
    public static func warn(_ msg: String) { bridge.warning("\(msg)") }
    public static func error(_ msg: String) {
        bridge.error("\(msg)")
        crashReporter.captureMessage(msg, level: .error)  // Sentry by default
    }
}
```

### 11.3 崩溃监控(默认接入 Sentry)

新项目自第一天起接入崩溃 / 性能监控。**默认选择:Sentry**(SaaS 版或自建实例均可)。

Sentry 选型理由:
- 社区主流、长期维护、Swift SDK 一等支持(SPM 直接装)
- 单一入口同时覆盖 Crash / 性能 transaction / breadcrumb / 用户行为
- 支持自建实例,可满足数据不出境的合规要求
- 提供编译开关 `SENTRY_ENABLED`,如未来某次决定退回无监控,关闭开关即可,业务代码不动

监控账号运维责任写入团队 onboarding 文档,避免后续运维断档导致监控形同虚设。

```swift
// AnalyticsKit/CrashReporter.swift
public protocol CrashReporter: Sendable {
    func start()
    func captureMessage(_ msg: String, level: Severity)
    func captureError(_ error: Error, context: [String: Any]?)
    func setUserContext(channel: String, gameId: String)
}

#if SENTRY_ENABLED       // 编译开关,默认 ON
public struct SentryCrashReporter: CrashReporter {
    public func start() {
        SentrySDK.start { options in
            options.dsn = AppIDs.sentryDSN
            options.enableAutoPerformanceTracing = true
            options.tracesSampleRate = 0.1
            options.releaseName = "Daoqi@\(Bundle.main.shortVersion)+\(BundleConfig.shared.appVersion)"
            options.environment = AppEnvironment.current.rawValue
            options.attachScreenshot = false   // 不上传截图,避免误传 H5 业务内容
        }
    }
    public func setUserContext(channel: String, gameId: String) {
        SentrySDK.setUser(User(userId: channel))
        SentrySDK.setContext(value: ["gameId": gameId], key: "game")
    }
    public func captureMessage(_ msg: String, level: Severity) {
        SentrySDK.capture(message: msg)
    }
    public func captureError(_ error: Error, context: [String: Any]?) {
        SentrySDK.capture(error: error)
    }
}
#else
public struct NoopCrashReporter: CrashReporter {
    public func start() {}
    public func setUserContext(channel: String, gameId: String) {}
    public func captureMessage(_ msg: String, level: Severity) {}
    public func captureError(_ error: Error, context: [String: Any]?) {}
}
#endif
```

**运维责任**:Sentry 后端账号必须有团队中至少 2 人持有访问凭证、季度检查存活性,避免重蹈 Bugly 覆辙(账号断档 = 监控失效)。

### 11.4 H5 错误回流通道

原生 Sentry 接住的是原生崩溃,但 **H5 端的 JS 错误、unhandled promise rejection、网络请求失败**,原生默认无感知。线上一旦 H5 业务出 bug,只能靠用户反馈,等同盲飞。

新外壳作为 H5 容器,**必须主动提供 JS 错误回流通道**。这是新外壳额外提供的能力,**不修改 H5 契约**(H5 端可选用,不强制),不影响原契约的任何行为。

#### 设计

注入一段 polyfill,在 H5 端劫持 `window.onerror` 与 `window.onunhandledrejection`,通过新桥事件 `reportH5Error` 回流到原生 Sentry:

```swift
// WebViewKit/H5ErrorRelay.swift
public enum H5ErrorRelay {
    public static let jsPolyfill = """
    (function() {
        function relay(payload) {
            try {
                if (window.WebViewJavascriptBridge) {
                    window.WebViewJavascriptBridge.callHandler('reportH5Error', payload, null);
                }
            } catch (e) { /* swallow */ }
        }
        window.addEventListener('error', function(e) {
            relay({
                kind: 'error',
                message: String(e.message || ''),
                source: String(e.filename || ''),
                line: e.lineno || 0,
                col: e.colno || 0,
                stack: (e.error && e.error.stack) ? String(e.error.stack) : ''
            });
        }, true);
        window.addEventListener('unhandledrejection', function(e) {
            relay({
                kind: 'unhandledrejection',
                message: String((e.reason && e.reason.message) || e.reason || ''),
                stack: (e.reason && e.reason.stack) ? String(e.reason.stack) : ''
            });
        });
    })();
    """
}
```

WebContainerViewController 用 `WKUserScript` 在 `atDocumentStart` 注入,确保在 H5 业务代码执行之前生效。

#### Handler 注册(共享 handler,大厅 / 子游戏 / 弹层都注册)

```swift
// BridgeHandlers/Common/H5ErrorHandler.swift
extension BridgeProtocol {
    func registerH5ErrorRelay(role: String, crashReporter: CrashReporter) {
        self.register("reportH5Error") { data, cb in
            defer { cb?(.string("ok")) }
            guard let payload = data,
                  let message = payload["message"]?.asString else { return }
            let kind = payload["kind"]?.asString ?? "unknown"
            let stack = payload["stack"]?.asString ?? ""
            let source = payload["source"]?.asString ?? ""

            crashReporter.captureMessage(
                "[H5/\(role)/\(kind)] \(message)",
                level: .error,
                context: [
                    "h5_source": source,
                    "h5_stack": String(stack.prefix(2000)),  // 截断,避免 payload 过大
                    "container_role": role
                ]
            )
        }
    }
}
```

#### 上下文标签

每条 H5 错误上报时携带:
- `container_role` — lobby / subgame / overlay,定位错误归属容器
- `h5_source` — 出错的 JS 文件 URL(file:// 路径)
- `h5_stack` — 截断后的调用栈(2000 字符上限,避免 Sentry payload 过大)
- `channel` / `gameId`(从 `BundleConfig` 全局上下文自动带上)

#### 与契约的关系

`reportH5Error` 是**新增**的桥 handler,**不在 39 个契约接口列表里**:
- 现有 H5 业务代码不调用它 → 行为完全不变
- 未来 H5 团队若主动接入(在 H5 端加几行调用即可),则错误自动上报
- 即使原生注册了但 H5 永不调用,也不影响任何业务

→ 这是**契约兼容的纯增强**,可独立上线、可独立回滚。

---

## 12. 测试策略

### 12.1 单元测试矩阵

| 模块 | 关键用例 | 覆盖率目标 |
|------|---------|-----------|
| `BridgeCore` | handler 注册/分发/callback、错误隔离、JSON 序列化双向 | ≥ 90% |
| `ResourceKit` | filename: 等价行为、解压幂等、PathType 路径正确 | ≥ 85% |
| `NetworkKit` | SGGateway 签名算法、特殊接口包装、超时 | ≥ 80% |
| `AudioKit` | AMR↔WAV 转换正确、录音边界、播放生命周期 | ≥ 70% |
| `ShareKit` | type=1/2/3 与 sharetype=1/2/3 9 种组合路由 | 100% |
| 其余 Kit | 路径覆盖 | ≥ 60% |
| Coordinator | 不写单测(全集成测) | — |

### 12.2 契约测试(关键)

写一组黑盒测试,**直接模拟 H5 发起 bridge 消息**,验证 native 行为与 `H5-Native-Contract.md` 一致:

```swift
// Tests/Contract/SharingContractTests.swift
final class SharingContractTests: XCTestCase {
    func test_friendsShare_dispatchesToWeChat_whenSharetype1() async {
        let mockWeChat = MockSharePlatform()
        let center = ShareCenter(weChat: mockWeChat, xianliao: MockSharePlatform())
        let req = BridgeData.object([
            "sharefriend": .string("1"),
            "sharetype": .string("1"),
            "type": .string("1"),
            "webpageUrl": .string("https://test"),
            "title": .string("T"),                   // ⚠️ 不是 "title "
            "description": .string("D")
        ])
        // simulate H5 → native bridge message
        let bridge = TestBridge(handlers: [
            "friendsSharetypeUrlToptitleDescript": center.handle
        ])
        await bridge.receive(handler: "friendsSharetypeUrlToptitleDescript", data: req)
        XCTAssertEqual(mockWeChat.calls.count, 1)
        XCTAssertEqual(mockWeChat.calls[0].title, "T")
    }
}
```

### 12.3 UI 集成测

用 XCUI 跑 §10 验收清单 26 项,在 CI 上每次 commit 自动执行。

---

## 13. 工程化

### 13.1 Xcode 配置

| 设置 | 值 |
|------|-----|
| Deployment Target | iOS 14.0 |
| Architectures | arm64(去掉 armv7) |
| Swift Language Version | 6 |
| Strict Concurrency Checking | Complete |
| Enable Modules | YES |
| Bitcode | NO(已废弃) |
| Code Signing | 自动 + 手动 provisioning(企业证书) |
| LLVM Optimization | -O(Release)、-Onone(Debug) |
| Dead Code Stripping | YES |
| Whole Module Optimization | YES(Release) |

### 13.2 CI 流水线(GitHub Actions / Jenkins)

```yaml
# .github/workflows/build.yml
name: Build & Test
on: [push, pull_request]
jobs:
  test:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4
      - name: Resolve SPM
        run: xcodebuild -resolvePackageDependencies
      - name: Lint
        run: swiftlint
      - name: Unit Tests
        run: xcodebuild test -scheme Daoqi -destination 'platform=iOS Simulator,name=iPhone 16'
      - name: Contract Tests
        run: xcodebuild test -scheme DaoqiContract -destination '...'
      - name: Archive (smoke)
        run: xcodebuild archive -scheme Daoqi -archivePath build/Daoqi.xcarchive
```

### 13.3 多渠道发包

```bash
# Scripts/release.sh
CHANNELS="xianliao_001 douyin_002 inhouse_003"
for ch in $CHANNELS; do
    source channels/$ch.env
    ./Scripts/inject_channel.sh "$CHANNEL" "$GAMEDIR" "$GAMEID" \
        "$MARKET" "$AGENT" "$APPVERSION" "$QINIUDOMAIN" \
        "$GAMESTART" "$GAMECONFIG"
    xcodebuild archive ...
    xcodebuild -exportArchive ...
    mv build/Daoqi.ipa dist/Daoqi-$ch.ipa
done
```

### 13.4 SwiftLint / SwiftFormat

强制规则:
- 行长 ≤ 120
- 文件长度 ≤ 400 行(超出即模块/类拆分)
- 类型长度 ≤ 200 行
- 函数长度 ≤ 40 行
- 禁止 `force_unwrapping`(只允许在 `@testable` 中)
- 禁止 `force_cast`

---

## 14. 第三方 SDK 集成清单

### 14.1 依赖管理策略:SPM 优先 + Vendor `.xcframework` 手动

新项目采用**两层混合策略**,**完全不引入 CocoaPods 工具链**:

1. **首选 SPM** —— Apple 官方,Xcode 原生集成,无第三方工具依赖,无衍生工程文件污染,新人 clone 即用。适用于:Sentry、ZIPFoundation、七牛 v8.9+ 等**已发布官方 Swift Package** 的库
2. **Vendor 手动** —— `Vendor/` 目录直接放闭源 `.xcframework` / `.framework` / `.a`,target Build Phase 加入 `Link Binary With Libraries` + `Embed Frameworks`(动态库需要)。适用于:微信 OpenSDK、QQ OpenSDK、AMap 高德定位、opencore-amr 等**官方不提供 Swift Package** 的二进制

#### 为什么不引入 CocoaPods

ADR-006 决策记录(见 `docs/Development-Plan.md` §9)详细背景。简短理由:

- **唯一会触发 Pods 的 SDK 是 AMap 高德定位**(其它 SDK 要么 SPM,要么本就是 Vendor 手动);为 1 个 SDK 引入完整 Ruby + Bundler + CocoaPods 工具链性价比低
- **Xcode 26 与 CocoaPods 的兼容性问题**:Xcode 26 默认 `PBXFileSystemSynchronizedRootGroup`,Homebrew 的 CocoaPods 1.15.2 自带 `xcodeproj` gem 1.24.0 不识别,需绕道 Bundler + Gemfile 锁定 1.16+
- **生态趋势**:Google 已宣布 2026 Q2 后停止 iOS SDK 的 CocoaPods 支持,Firebase / GoogleMaps 转 SPM
- **AMap 手动接入成本可控**:闭源 XCFramework 升级频率年度级,每次 30 分钟手动操作,对比 CocoaPods 工具链长期维护更低成本

#### 为什么不选纯 SPM

微信 / QQ / 闲聊 / JAnalytics / AMap 等闭源 SDK 官方至今(2026)未提供 Swift Package,强行纯 SPM 化需要自己包一层私有 Wrapper Package,维护成本反而上升。

#### 决策方法

新增第三方依赖时,**按 SPM → Vendor 顺序尝试**,前一项无法满足才退到下一项。任何团队成员把已 SPM 化的 SDK 改成 Vendor,需在 PR 描述里写明理由。**禁止引入 CocoaPods**(违反 ADR-006)。

#### Vendor 接入标准流程

闭源 SDK Vendor 化按以下顺序操作,保持工程一致性:

1. SDK 二进制放 `Vendor/<SDKName>/<SDKName>.xcframework`(动态库)或 `Vendor/<SDKName>/lib<name>.a` + 头文件目录(静态库)
2. Xcode → Target → General → Frameworks, Libraries, and Embedded Content → `+` 加入,动态库选 "Embed & Sign",静态库选 "Do Not Embed"
3. 静态库 `Library Search Paths` / 头文件 `Header Search Paths` 写到 `$(PROJECT_DIR)/Vendor/<SDKName>` 相对路径,**不写绝对路径**
4. 新增 module bridging header(若是 Swift 调 OC SDK)
5. `Vendor/<SDKName>/README.md` 写明版本号 / 下载日期 / 官方更新页 URL
6. SDK 二进制入 git(配合 git-lfs 处理大文件;当前项目不强制 lfs,因为 SDK 体积可控)

### 14.2 SDK 详细清单

| SDK | 接入方式 | 版本 | 状态 / 启动时机 |
|-----|---------|------|--------------|
| WeChat OpenSDK | Vendor `.framework` | latest 2.x | ✅ 启用,启动注册 |
| QQShare | Vendor | latest | ✅ 启用,启动注册 |
| **Xianliao** | — | — | ⏸️ **暂不集成**(`ShareCenter.xianliao = NoopSharePlatform`,§8.2)。当 H5 调 `friendsShare...({sharetype:"3"})` 时,Noop 立即回 `sharesuccess`,业务流不中断。启用时把 Vendor `.framework` 放入 + 把 `NoopSharePlatform` 换为 `XianliaoShare` |
| **Agora RTC** | — | — | ⏸️ **暂不集成**(`VideoRoom = NoopVideoRoom`,§8.3)。子游戏视频房间 3 个 handler 当前 stub 实现。启用时按 §8.3 末尾的 4 步说明切换 |
| AMap Location | Vendor `.xcframework`(官方暂无 SPM,ADR-006 决策不引 CocoaPods) | latest 2.x | ✅ 启用,启动注册;下载页 `https://lbs.amap.com/api/ios-location-sdk/download` |
| Qiniu SDK | **SPM** `https://github.com/qiniu/objc-sdk` | **8.9.x** | ✅ 启用,录音上传时初始化;import `QiniuSDK` |
| opencore-amr | 静态 `.a`(直接接入 — H5 端通过此库收发的 AMR 音频是契约边界) | 2014 版本 | ✅ 启用 |
| ZIPFoundation | **SPM** | latest | ✅ 启用 |
| Sentry-Cocoa | **SPM**(默认 ON,编译开关 `SENTRY_ENABLED` 提供逃生口) | latest | ✅ 启用,启动并发注册 |
| **JAnalytics(极光)** | — | — | ⏸️ **暂不集成**(`AnalyticsKit.tracker = NoopAnalytics`)。新外壳首版不接用户统计 — 现网 msext 的极光后端运维状态不可知,且 Sentry 的 breadcrumb / transaction 可兜一部分行为指标。启用时:① Vendor `.framework` 放入 ② `NoopAnalytics` 换为 `JAnalyticsTracker` ③ Info.plist 加 AppKey ④ Bootstrapper 注册 |
| **Bugly** | — | — | ⏸️ **不集成**。Sentry 已覆盖崩溃 / 性能监控,无需 Bugly。未来若有强需求(如旧外壳数据迁移),再单独评估 |

> **微信 OAuth 架构决策**:新项目走**后台中转** —— 客户端把 `code` 发给后台 `/wechat/login` 接口,由后台完成 `access_token` + `userinfo` 拉取,再回传 7 个字段给客户端,客户端最终 `callHandler:@"sharelogin"` 给 H5。
> - 优势:secret 不进 IPA,无客户端泄露风险
> - 前置依赖:后台需提供 `/wechat/login` 接口(项目方协调)
> - Fallback:若后台暂未就绪,临时由客户端完成 OAuth,但 `LoginKit/WeChatAuth.swift` 内必须标 `// FIXME: 待后台 /wechat/login 就绪后切换` 注释,作为 P0 技术债项跟踪
> - 无论实现路径如何切换,`sharelogin` 7 字段(H5 契约)保持不变

---

## 15. 性能指标 & SLO

| 指标 | 目标 | 测量方式 |
|------|------|---------|
| 冷启动 → 大厅 H5 可点击 | < 1.5 s(P90) | Sentry transaction + Xcode Instruments + os_signpost |
| 桥接消息 RTT(简单 handler) | < 16 ms(P95) | 单测 + 真机 profiling |
| 进入子游戏 → 视频房间建立 | < 800 ms | Sentry transaction |
| AMR → WAV 转换 | < 100 ms(P95) | 单测 baseline |
| 内存峰值 | < 200 MB | Memory gauge |
| 录音上传 30s 文件 | < 3 s(P95) | Sentry transaction |
| Crash-free session rate | ≥ 99.5% | Sentry |

---

## 16. 里程碑 / 排期(假设 2-3 人团队)

| 里程碑 | 内容 | 估时 |
|-------|------|------|
| **M0 立项** | Xcode 工程骨架、SPM 切分、CI 跑通、Lint 落地 | 1 周 |
| **M1 桥核心** | BridgeCore / WebViewKit / 所有桥协议、Contract 单测 | 2 周 |
| **M2 基础能力** | ResourceKit / NetworkKit / DeviceKit / Logger / Sentry | 2 周 |
| **M3 大厅 happy path** | Lobby + 20 个 handler 全联通,跑 §10 A/B/C 节验收 | 3 周 |
| **M4 子游戏 + 视频房间** | SubGame + Agora 包装 + 视频 4 handler,跑 §10 D 验收 | 3 周 |
| **M5 弹层 + 分享 + 登录** | Overlay + ShareKit + LoginKit,§10 全部通过 | 2 周 |
| **M6 真机回归 + 多渠道打包** | 真机覆盖 iOS 14-26,5 个渠道出包,UAT | 2 周 |
| **M7 灰度 + 上线** | 内部 50 人 → 100 人 → 全量切流 | 2 周 |
| **合计** | | **17 周(约 4 个月)** |

### 16.1 切流策略

- 旧 `msext` IPA 与新 `Daoqi` IPA 共存(不同 Bundle ID),用户手动覆盖安装
- 灰度按渠道:小渠道先切,大渠道(主市场)最后切
- 灰度期内,旧 msext 外壳保持线上不动,避免双线推送破坏现网用户(防御性约束)

---

## 17. 风险与缓解

| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| H5 端隐藏依赖未在契约中描述 | 中 | 高 | M3 起开始真机回归,用旧外壳的全部 H5 通路对比;§10 验收清单逐项过 |
| WVJB JS 协议不完全兼容 | 中 | 高 | 直接复用 marcuswestin/WebViewJavascriptBridge 的 JS 端原文,不重写;Bridge 单元测试 ≥ 90% |
| Agora SDK 版本升级破坏视频房间 | 低 | 中 | 锁版本,Agora 用 protocol 抽象,后续升级一处替换 |
| opencore-amr fat 库后续 Xcode 版本无法链接 | 低 | 高 | 已知方案:`lipo -thin` 拆分各 slice 后用 `lipo -create -segalign <arch> 8` 重新打包(根因是 2014 年 fat header 的 architecture slice `align=2^2`,新链接器 `ld_prime` 要求 ≥2^3);最坏从源码重编 |
| Sentry DSN 泄露 | 低 | 低 | 通过 Xcode build setting 注入,不入 Git |
| 微信 Appsecret 客户端泄露 | 高 | 高 | 走后台 `/wechat/login` 中转,secret 永不进 IPA;若后台未就绪,临时由客户端完成 OAuth 并标 P0 TODO,后台到位后立刻切换 |

---

## 18. 开发规约

1. **commit message** 中文短句,主题+原因,参考 `git log` 现有风格
2. **PR 单一职责**:重构 PR 不混 bugfix;桥接 handler 修改单独 PR,要附契约验收截图
3. **新增 H5 handler 流程**:① 在 `H5-Native-Contract.md` 加描述 ② 写 contract 单测 ③ 实现 handler ④ 真机 H5 端联调
4. **删除/重命名任何已存在的 handler 名禁止**(契约违反),除非 H5 同步发版
5. **代码评审强制**:任何接触 `BridgeCore` / `BundleConfig` / `SandboxPaths` 的 PR 必须 2 人 +1
6. **文档与代码同步**:`BridgeProtocol` 协议变动必须同步更新本文档与 Contract 文档

---

## 19. 与既有项目的关系

- 本项目独立目录(`/Daoqi/`),不与 `/msext/` 共用任何文件
- `gamehall.zip` 直接复用现网产物,放进 `Resources/`
- `Vendor/` 下的 SDK 二进制可参照 msext 的 `Pods/` / `Frameworks/` 找到对应版本,但**不要走 CocoaPods 流程**(ADR-006);从官方下载页或 msext 仓库直接拷贝 `.xcframework` / `.framework` / `.a` 到 `Vendor/`
- 渠道配置脚本可与现 `msext` 共享同一 Jenkins job 参数表
- 灰度期间旧 msext 外壳保持线上不动(避免双线推送);新外壳全量切流稳定后再评估是否归档 msext target

---

## 附录:启动文件最小骨架

```swift
// App/AppDelegate.swift
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions opts: ...) -> Bool { true }

    func application(_ app: UIApplication,
                     configurationForConnecting connectingSceneSession: UISceneSession,
                     options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        UISceneConfiguration(name: "Main", sessionRole: connectingSceneSession.role)
    }
}

// App/SceneDelegate.swift
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    private var coordinator: AppCoordinator?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options: UIScene.ConnectionOptions) {
        guard let scene = scene as? UIWindowScene else { return }
        window = UIWindow(windowScene: scene)
        coordinator = AppCoordinator(window: window!)
        Task { @MainActor in
            await coordinator?.start()
        }
    }

    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        for ctx in URLContexts {
            if QQShareSDK.handle(url: ctx.url) { continue }
            WeChatSDK.handle(url: ctx.url)
        }
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        NotificationCenter.default.post(name: .appDidEnterBackground, object: nil)
    }

    func sceneDidBecomeActive(_ scene: UIScene) {
        NotificationCenter.default.post(name: .appDidBecomeActive, object: nil)
    }
}

// AppCoordinator/AppCoordinator.swift
@MainActor
final class AppCoordinator {
    private let window: UIWindow
    private let nav = NavigationController()

    init(window: UIWindow) {
        self.window = window
        window.rootViewController = nav
        window.makeKeyAndVisible()
    }

    func start() async {
        await Bootstrapper.shared.run()
        let lobby = LobbyAssembly.assemble(coordinator: self)
        nav.setViewControllers([lobby], animated: false)
    }

    func showSubGame(_ params: SwitchGameParams) {
        let vc = SubGameAssembly.assemble(params: params, coordinator: self)
        nav.pushViewController(vc, animated: true)
    }

    func showOverlay(_ params: OverlayParams) {
        let vc = OverlayAssembly.assemble(params: params, coordinator: self)
        nav.pushViewController(vc, animated: true)
    }
}
```

---

## 20. 附录:完整 handler / callback / JS 注入清单

> 本附录是实施时的"清单核对表",必须 1:1 落地。任何字段差异都视为契约违反。
> 与 `H5-Native-Contract.md` §3 / §4.2 一一对应,**以契约为准**;本附录是契约的 Swift 落地骨架。

### 20.1 LobbyHandlers — 大厅注册 20 个 handler

> **架构说明**（与 §8.x 各 module 章节同步）：本节早期版本把 20 项 handler 全列在单一 `LobbyHandlers struct.register()` 内，便于一眼看全。实际实施时（§8.1.1 / §8.4.1 / §8.6 / §8.7 / §3.4.2）按 module 维度拆为多个 sub-struct（`AudioHandlers` / `DeviceHandlers` / `LocationHandlers` / `ShakeHandlers` / `OpenurlTitleDataHandler` 等），`LobbyHandlers` 成为聚合点：
>
> ```swift
> public struct LobbyHandlers {
>     let bridge: BridgeProtocol
>     let audioHandlers:     AudioHandlers           // §8.1.1
>     let deviceHandlers:    DeviceHandlers          // §8.4.1
>     let locationHandlers:  LocationHandlers        // §8.6
>     let shakeHandlers:     ShakeHandlers           // §8.7
>     let openurlHandler:    OpenurlTitleDataHandler // §3.4.2
>     // 其它单点：accreditLogin / friendsShare / browser / SwitchOverGameData /
>     //          opensaoma stub 留在 LobbyHandlers 本体
>
>     public func register() {
>         audioHandlers.register()
>         deviceHandlers.register()
>         locationHandlers.register()
>         shakeHandlers.register()
>         openurlHandler.register()
>         registerLobbyOnly()  // accreditLogin / friendsShare / browser / SwitchOverGameData / opensaoma
>     }
> }
> ```
>
> 下方 monolithic register 调用清单**作为 20 项接口的速查表保留**（与 Contract §3.1 编号对账用），实际实施请按上述 sub-struct 拆分。

```swift
// LobbyHandlers 聚合视角的 20 项接口速查表（与 Contract §3.1 编号一一对应）
func register() {
    // 登录 / 分享                                      （留在 LobbyHandlers 本体）
    bridge.register("accreditlogin",                          handler: accreditLogin)             // §3.1 ［1］
    bridge.register("friendsSharetypeUrlToptitleDescript",    handler: friendsShare)              // §3.1 ［2］
    // 音频 / 录音                                      （AudioHandlers, §8.1.1）
    bridge.register("srcIsloop",                              handler: srcIsLoop)                 // §3.1 ［3］
    bridge.register("prepareaudio",                           handler: prepareAudio)              // §3.1 ［4］
    bridge.register("mediaTypeAudio",                         handler: mediaTypeAudio)            // §3.1 ［5］
    bridge.register("voicePlaying",                           handler: voicePlaying)              // §3.1 ［6］
    // 摇一摇                                            （ShakeHandlers, §8.7）
    bridge.register("startshake",                             handler: startShake)                // §3.1 ［7］
    bridge.register("stopshake",                              handler: stopShake)                 // §3.1 ［8］
    bridge.register("SwitchShake",                            handler: switchShake)               // §3.1 ［9］
    // 振动 + 剪贴板 + 设备                              （DeviceHandlers, §8.4.1）
    bridge.register("vibrator",                               handler: vibrator)                  // §3.1 ［10］
    bridge.register("repeatvibrator",                         handler: repeatVibrator)            // §3.1 ［11］
    bridge.register("canclevibrator",                         handler: cancelVibrator)            // §3.1 ［12］
    bridge.register("gamepastetext",                          handler: gamePasteText)             // §3.1 ［13］
    bridge.register("gameCopytext",                           handler: gameCopyText)              // §3.1 ［14］
    bridge.register("getphoneInfo",                           handler: getPhoneInfo)              // §3.1 ［21］
    // 网页 / 浏览器 / 跳子游戏                          （OpenurlTitleDataHandler §3.4.2 + LobbyHandlers 本体）
    bridge.register("OpenurlTitleData",                       handler: openUrlTitleData)          // §3.1 ［15］
    bridge.register("browser",                                handler: browser)                   // §3.1 ［16］
    bridge.register("SwitchOverGameData",                     handler: switchOverGameData)        // §3.1 ［17］
    // 定位                                              （LocationHandlers, §8.6）
    bridge.register("startlocation",                          handler: startLocation)             // §3.1 ［20］
    // 扫码 stub                                         （LobbyHandlers 本体）
    bridge.register("opensaoma",                              handler: openSaoma)                 // §3.1 ［22］ ⚠️ 空实现仍要注册
}
```

### 20.2 SubGameHandlers — 子游戏注册 23 个 handler

```swift
func register() {
    // 共享 19 个(SwitchOverGameData 除外)
    registerLobbyCommon()      // accreditlogin / friendsSharetypeUrlToptitleDescript / ...
                               // ... vibrator / repeatvibrator / canclevibrator /
                               // gamepastetext / gameCopytext / OpenurlTitleData /
                               // browser / startlocation / getphoneInfo / opensaoma /
                               // srcIsloop / prepareaudio / mediaTypeAudio / voicePlaying /
                               // startshake / stopshake / SwitchShake
                               // = 19 个

    // 子游戏独有 4 个
    bridge.register("backgameData",  handler: backgameData)
    bridge.register("createRoom",    handler: createRoom)
    bridge.register("getVideoinfo",  handler: getVideoInfo)        // 注意:同名反向也有
    bridge.register("exitRoom",      handler: exitRoom)
}
```

### 20.3 OverlayHandlers — 弹层 3 个(走 settings.* polyfill)

```swift
// WKScriptMessageHandler 注册到 userContentController
let names = ["overlayBackgameData", "overlayBrowser", "overlayFinishweb"]
for name in names {
    cfg.userContentController.add(self, name: name)
}

func userContentController(_: WKUserContentController,
                            didReceive msg: WKScriptMessage) {
    switch msg.name {
    case "overlayBackgameData":
        NotificationCenter.default.post(name: .overlayBackgameData,
                                        object: msg.body as? String ?? "")
        coordinator.popOverlay()
    case "overlayBrowser":
        if let url = URL(string: msg.body as? String ?? "") {
            UIApplication.shared.open(url)
        }
    case "overlayFinishweb":
        coordinator.popOverlay()
    default: break
    }
}
```

### 20.4 反向 callback(LobbyBridge 共 12 + SubGame 额外 3 = 15 个)

```swift
extension BridgeProtocol {
    // 大厅 + 子游戏共享 12 个
    func callGetPhoneInfo(_ info: [String: String]) { call("getphoneinfo", data: .object(info.mapValues(BridgeData.string)), callback: nil) }
    func callGetBattery(_ level: Float)             { call("getBattery", data: .string(String(format: "%.2f", level)), callback: nil) }
    func callGetNetwork(_ state: NetworkState)      { call("getnetwork", data: .string(state.rawValue), callback: nil) }     // "1"/"2"/"3"
    func callAppService(_ s: AppLifecycleState)     { call("appservice", data: .string(s == .background ? "2" : "1"), callback: nil) }     // 值错位沿用 msext WKWebView 路径
    func callShakeEnd()                              { call("shakeEnd", data: nil, callback: nil) }
    func callGetLocationInfo(_ d: LocationPayload)  { call("getlocationinfo", data: .object(d.dict), callback: nil) }     // 字段见契约 §3.2 表B
    func callGameUIPlayVoice(_ user: String)        { call("gameui_play_voice", data: .string(user), callback: nil) }
    func callGameUIStopVoice(_ user: String)        { call("gameui_stop_voice", data: .string(user), callback: nil) }
    func callGetAudioUrl(_ url: String, time: Int)  { call("getaudiourl", data: .object([
                                                        "audiourl": .string(url),
                                                        "time": .string(String(time))
                                                    ]), callback: nil) }
    func callShareLogin(_ user: WeChatUser)          { call("sharelogin", data: .object([
                                                        "openid":     .string(user.openid),
                                                        "headimgurl": .string(user.headimgurl),
                                                        "nickname":   .string(user.nickname),
                                                        "sex":        .string(String(user.sex)),
                                                        "city":       .string(user.city),
                                                        "Province":   .string(user.province),    // ⚠️ 大写 P
                                                        "unionid":    .string(user.unionid)
                                                    ]), callback: nil) }
    func callShareSuccess(type: String)              { call("sharesuccess", data: .object([
                                                        "success": .string("2"),
                                                        "type":    .string(type)
                                                    ]), callback: nil) }
    func callGetWebData(_ data: String)              { call("getWebdata", data: .string(data), callback: nil) }

    // 子游戏独有 3 个(LobbyBridge 不要注册)
    func callGetVideoInfo(_ uid: UInt)               { call("getVideoinfo", data: .string(String(uid)), callback: nil) }
    func callPhoneState(_ state: CallState)          { call("phonestate", data: .string(state == .incoming ? "2" : "0"), callback: nil) }
    func callRecordSuccess(_ file: UploadedFile)     { call("recordSuccess", data: .object([
                                                        "fileUrl":  .string(file.publicUrl),
                                                        "fileName": .string(file.fileName),
                                                        "fileKey":  .string(file.qiniuKey)
                                                    ]), callback: nil) }
}
```

### 20.5 JS 注入文件(契约 §4.2)

**每次 loadFileURL 之前**写入 3 个 JS 文件到 `Library/Caches/{gamedir}/{gamestart}/` 下,内容由 BundleConfig + DeviceKit + NetworkMonitor 提供:

```swift
// WebViewKit/JSInjector.swift
public enum JSInjector {
    public static func writeStartupJS() throws {
        let dir = SandboxPaths.caches
            .appendingPathComponent(BundleConfig.shared.gameDir)
            .appendingPathComponent(BundleConfig.shared.gameStart)
        try writeAppData(into: dir)
        try writeAppBattery(into: dir)
        try writeAppNetwork(into: dir)
    }

    private static func writeAppData(into dir: URL) throws {
        let cfg = BundleConfig.shared
        let compareCode = UserDefaults.standard.string(forKey: DefaultsKey.compareCode) ?? "0"
        let js = """
        var app_gameconfig  = "\(cfg.gameConfig.escapedForJS)";
        var app_gamedir     = "\(cfg.gameDir)";
        var app_gamestart   = "\(cfg.gameStart)";
        var app_agent       = "\(cfg.agent)";
        var app_appversion  = "\(cfg.appVersion)";
        var app_market      = "\(cfg.market)";
        var app_channel     = "\(cfg.channel)";
        var app_gameid      = "\(cfg.gameId)";
        var app_compareCode = "\(compareCode)";
        """
        try js.write(to: dir.appendingPathComponent("app_data.js"),
                     atomically: true, encoding: .utf8)
    }

    private static func writeAppBattery(into dir: URL) throws {
        UIDevice.current.isBatteryMonitoringEnabled = true
        let js = "var app_battery = \"\(UIDevice.current.batteryLevel)\";"
        try js.write(to: dir.appendingPathComponent("app_battery.js"),
                     atomically: true, encoding: .utf8)
    }

    private static func writeAppNetwork(into dir: URL) throws {
        let state = NetworkMonitor.shared.currentState.rawValue   // "1"/"2"/"3"
        let js = "var app_network = \"\(state)\";"
        try js.write(to: dir.appendingPathComponent("app_network.js"),
                     atomically: true, encoding: .utf8)
    }
}
```

H5 端 `index.html` 通过 `<script src="app_data.js"></script>` 等同步引入。新外壳如果不写这三个文件,H5 启动时变量未定义直接崩。

### 20.6 NSUserDefaults Key 清单(契约 §10 持久化)

| Key 名 | 类型 | 写入时机 | 说明 |
|--------|------|---------|------|
| `everLaunched` | Bool | 首次启动设 YES | 用于判断是否要执行首次初始化逻辑 |
| `getcompareCode` | String | 首次启动设 "0",H5 业务过程会改写 | H5 通过 app_data.js 读 |
| `FirstBOOL` | String | 首次启动设 "set0" | H5 业务标记 |
| `SecondBOOL` | String | 首次启动设 "set0" | H5 业务标记 |
| `ThirdBOOL` | String | 首次启动设 "set0" | H5 业务标记 |
| `VersionInfo` | String | 每次启动设 "1.0" | H5 读取版本号 |

**禁止重命名 key**,否则升级覆盖安装时 H5 业务状态全丢。

### 20.7 首次启动写 `app_gamesname.js`

```swift
// 仅 everLaunched=false 时执行一次
if !UserDefaults.standard.bool(forKey: DefaultsKey.everLaunched) {
    let dir = SandboxPaths.caches
        .appendingPathComponent(BundleConfig.shared.gameDir)
        .appendingPathComponent(BundleConfig.shared.gameStart)
    let js = "var  app_gamesname=new Array('\(BundleConfig.shared.gameStart)');"
    try? js.write(to: dir.appendingPathComponent("app_gamesname.js"),
                  atomically: true, encoding: .utf8)
    UserDefaults.standard.set(true, forKey: DefaultsKey.everLaunched)
}
```

> 注意 `var` 后**两个空格**:H5 静态引用此文件,字符级必须一致(契约边界,从现网 H5 代码反推得到)。

---

文档完成日期:2026-06-21
