# 进贤聚友棋牌 iOS 端 — 启动流程 & H5/原生通讯契约

> 适用版本：当前 master 分支线上版本（msext target）
> 受众：负责"用新技术栈重写一份等价 iOS 外壳"的开发者
> 目标：H5（gamehall.zip 内的 index.html 及子游戏）**零修改**即可在新 iOS 外壳上运行

---

## 契约边界（最重要）

新外壳与现网外壳之间，**只有以下三类内容是契约，必须 1:1 一致**：

1. **桥接接口名** — H5 调用的 handler 名字符串（如 `mediaTypeAudio`、`friendsSharetypeUrlToptitleDescript`、`OpenurlTitleData`），少一个字符 H5 就找不到。
2. **参数字段名 & 数据结构** — H5 传入的 `data` 字段名、Native 回调时的 payload 结构。**含历史包袱**：`sharelogin.Province`（大写 P）、`OpenurlTitleData` 的入参键名 `"title "`（末尾有空格）、`appservice` 用字符串 `"2"`=进入后台 / `"1"`=回到前台（值错位，沿用 msext WKWebView 路径历史，参 §3.2 第 4 行）、字段值约定（如 `isloop: -1/0/1` 三态），**必须照抄**。
3. **空实现接口也必须注册** — `opensaoma`、`getGameplay` 等当前为空实现的 handler，新外壳必须以同名空 handler 注册（响应 callback 但不做任何事），否则 H5 调用时会触发"bridge not found"错误分支。

**除以上三类之外，原生内部一切都可以重写**：技术栈（Swift/SwiftUI/Combine 都行）、WebView 接入方式（直接用 WKScriptMessageHandler 或继续用 WebViewJavascriptBridge）、网络层（URLSession/Alamofire）、解压库（ZIPFoundation）、状态机、控制器层级、命名风格——只要外部行为不变，怎么实现都行。本文档后续章节中描述的"现有实现细节"只是为了帮助你理解**为什么 H5 期待某种行为**，不是要求你照抄实现方式。

判断某项内容属不属于契约的方法：**问自己"H5 代码能不能感知到？"**。能感知（接口名、字段名、数据值、副作用）→ 是契约；不能感知（用什么 SDK、用什么线程模型、Bugly 接不接、代码注释怎么写）→ 不是契约，自由决定。

---

## 0. 阅读须知 / 关键概念

### 0.1 三套桥并存的事实

本项目 H5 ↔ 原生实际有**三套通讯通道**同时存在（不是双桥）：

| 通道 | 触发条件 | WebView | 桥接技术 | 在哪些 VC 用 |
|------|---------|---------|---------|---------|
| 旧大厅桥 (JSExport / Bridge) | iOS < 9.0 主大厅路径 | `UIWebView` | JavaScriptCore + JSExport，`window.settings.方法名()` | `RootVC` / `fourviewVC` |
| 新大厅桥 (WVJB) | iOS ≥ 9.0 主大厅路径 | `WKWebView` | WebViewJavascriptBridge，`bridge.callHandler('方法名', data, cb)` | `NewRootVC` / `gameController` |
| 弹层桥 (JSExport / Bridgetwo) | 任何系统打开外链/公告页 | `UIWebView` | JSExport（轻量 3 接口），`settings.方法名()` | `threeView`（无论系统版本始终用这个） |

实际系统版本分支由 `AppDelegate.m didFinishLaunchingWithOptions:` 中 `if([UIDevice currentDevice].systemVersion.floatValue >= 9.0f)` 决定。**注意 threeView 不分支**——iOS 9+ 路径下主大厅是 WKWebView，但只要弹出 threeView 就降级回 UIWebView + JSExport。这是文档里最容易踩坑的地方。

iOS 26 时代 iOS 9 路径再也跑不到了（最低支持 iOS 9.0，但所有现役设备都 ≥ 12），新外壳**可以只实现"新大厅桥 + 弹层桥"**：

- **新大厅桥的接口名 = 旧桥 JSExport 方法转换为 JS 后的方法名**（H5 端字符串一致），差别只在调用风格
- **弹层桥的接口名是固定的 3 个**：`backgameData(data)` / `browser(url)` / `finishweb()`，H5 在外链页内用 `settings.xxx(...)` 调用。新外壳如果把 threeView 升级到 WKWebView，必须自己提供同名的 JS 注入（`window.settings = { backgameData, browser, finishweb }`），否则外链页内的关闭/回传链路断开。

→ **下面 §3 主大厅桥接表以新桥为准**，弹层桥单列于 §3.4，旧大厅桥 ObjC 签名作为附录列出。

### 0.2 H5 入口与资源分发

- H5 源码以 `gamehall.zip`（≈ 11 MB）形式打入 Bundle
- 首次启动解压到沙盒 `Library/Caches/{gamedir}/`（`gamedir` 是渠道注入值）
- 启动入口固定为：`Library/Caches/{gamedir}/{gamestart}/index.html`
- **加载方式（msext 现网行为）**：`loadFileURL:` 走 `file://` 协议，不依赖 HTTP 服务器
- **新外壳（Phase 1.F 起）**：迁到自定义 scheme `ylgame://h5/lobby/<gameStart>/index.html` + `WKURLSchemeHandler`，反向映射回相同沙盒物理路径。H5 可观察差异 = `location.protocol` 由 `"file:"` 变为 `"ylgame:"`；项目方已 grep 确认现网 H5 不依赖此字面。详见 `docs/H5-Native-Implementation-Design.md` §7.6 / Plan ADR-010
- 启动 query 参数：旧桥附 `?Launchtype=0`，新桥未附；新桥额外注入 `app_data.js`/`app_battery.js`/`app_network.js`

### 0.3 渠道注入机制（"空目录名注入"）

11 个 Bundle 根目录是"空容器"：目录里只有一个唯一子目录，其**目录名本身**就是配置值。运行时通过 `FuncPublic filename:` 读出。

```
msext/qiniudomain/iosaudio.daoqi88.cn/                ← 七牛 CDN 域名
msext/gameid/G2hw0ubng0zcoI0r4mx3H2yr4GejidwO/        ← 游戏 ID（混淆串）
msext/channel/FtJf073aa0d6rI1xD8J1Y42fINTm0ziK/       ← 渠道 ID
msext/gamedir/FtJf073aa0d6rI1xD8J1Y42fINTm0ziK/       ← 解压后顶层目录名（同 channel）
msext/gamestart/gamehall/                              ← 启动子目录名
msext/gameconfig/tsgames.daoqi88.cn-config_test-update_jsonv2_test/  ← 配置服 URL 编码
msext/market/2/                                        ← 应用市场 ID
msext/agent/veRa0qrBf0df2K1G4de2tgfmVxB2jxpv/          ← 代理 ID
msext/appversion/43/                                   ← App 版本号
msext/other/                                           ← 其他（当前为空目录）
msext/appleconfig/                                     ← 苹果配置（当前为空目录）
```

读取实现：
```objc
+(NSString *)filename:(NSString *)file {
    NSString *path = [FuncPublic getFilePath:file PathType:3];  // Bundle/{file}
    NSArray *files = [fm subpathsAtPath:path];
    for (NSString *name in files) {
        if (![name hasPrefix:@"."] && ![name isEqualToString:@".DS_Store"]) return name;
    }
    return @"";
}
```

`PathType` 取值：
- `1` → `Documents/`
- `2` → `Library/Caches/`
- `3` → Bundle 根目录

→ 新外壳必须保留这套机制（或用等价的"扫描 Bundle 同名空目录"实现），否则换渠道时整套打包脚本都失效。

### 0.4 全局常量（PCH 暴露，全工程可用）

`SGDefineInfo.h` 中通过 PCH 暴露：

```
SERVER         = http://niuniuapi.0791ts.cn/        // 旧主 API
SERVERNew      = http://ylyxservice3.0791ts.cn/config/   // 新配置服
SERVERtest     = https://www.tsysjw.com/niuniu/updata_json.txt
SERVERShop     = http://ylyx.0791ts.cn/version_shop.json
SERVERTwo      = http://bookstore.eobook.com/post/
SERVERSUB      = http://ylyxservice2.0791ts.cn
appstore       = "0"                                // 0=企业签，1=AppStore
gamehallname   = @"进贤聚友棋牌"
kAuthOpenID    = @"wx586a9b321e56efb7"              // 微信 AppID
kAuthScope     = @"snsapi_message,snsapi_userinfo,snsapi_friend,snsapi_contact"
kAuthState     = @"wechat_sdk"
Appsecret      = @"b2792724b9565be23e8f5ba548f117cf"  // 微信 AppSecret（旧外壳客户端硬编码 — 已识别为安全风险）
DEVW / DEVH    = 屏幕宽高（宏）
```

第三方 SDK Key：
```
APIKey  = @"b0d4a8e3fcbbcc0dd96283b7df6a4494"   // 高德地图
appID   = @"ad97e1945b1e4fb5ad5b5246c8ca21e4"   // 声网 Agora（gameController 视频房间用）
极光 appKey = @"3752badf07677981decdb7c2"
闲聊 appID = @"U1jJq3wgWluyB660"
Bugly appID = @"222f47a4ea"
```

---

## 1. 启动流程（AppDelegate）

### 1.1 完整流程图

```
application:didFinishLaunchingWithOptions:
  │
  ├─ ① 读 qiniudomain 注入目录 → 写入全局 kQiniuDomain
  ├─ ② configureAPIKey
  │    • 检查 APIKey 非空
  │    • AMapLocationManager updatePrivacyShow / updatePrivacyAgree
  │    • [AMapServices sharedServices].apiKey = APIKey
  ├─ ③ setupBugly
  │    • blockMonitorEnable=YES, blockMonitorTimeout=1.5s
  │    • [Bugly startWithAppId:@"222f47a4ea" config:...]
  │    • 注：Bugly 在旧外壳中仍然集成，但后台账号已废弃、不再上报、实际不起作用。新外壳不再集成 Bugly
  ├─ ④ [XianliaoApiManager registerApp:@"U1jJq3wgWluyB660"]
  ├─ ⑤ 极光统计 JANALYTICSService.setupWithConfig (appKey=3752badf07677981decdb7c2, channel=tongji)
  ├─ ⑥ [NSURLProtocol registerClass:[RNCachingURLProtocol class]]   // HTTP 资源本地缓存代理
  ├─ ⑦ 读 gamedir / gamestart 注入目录
  ├─ ⑧ 检查 Library/Caches/{gamedir}/{gamestart}/version.xml
  │    • 不存在 → 同步解压 Bundle/gamehall.zip → Library/Caches/{gamedir}/
  │      （≈0.2–0.5s，仅首次安装命中，远低于 Watchdog 20s）
  │    • 存在 → 跳过
  ├─ ⑨ 创建 zips/images/caches 目录（如果 {gamedir} 不存在）
  ├─ ⑩ 首次启动标记 (everLaunched=NO):
  │    • setBool:YES forKey:@"everLaunched"
  │    • SaveDefaultInfo:@"0"  Key:@"getcompareCode"
  │    • SaveDefaultInfo:@"set0" Key:@"FirstBOOL"
  │    • SaveDefaultInfo:@"set0" Key:@"SecondBOOL"
  │    • SaveDefaultInfo:@"set0" Key:@"ThirdBOOL"
  │    • 生成 app_gamesname.js: var app_gamesname=new Array('{gamestart}');
  ├─ ⑪ SaveDefaultInfo:@"1.0" Key:@"VersionInfo"
  ├─ ⑫ 创建 CustomWindow，按 iOS 版本分支：
  │    • iOS ≥ 9.0  → NewRootVC + NavgationController (NavgationTypeMaskPortrait)
  │    • iOS < 9.0  → RootVC + NavgationController
  │    • navigationBarHidden = NO（但页面 viewWillAppear 内会 setNavigationBarHidden:YES）
  ├─ ⑬ status bar 显示，statusBarStyle = Default
  ├─ ⑭ window makeKeyAndVisible
  ├─ ⑮ 微信 SDK 注册:
  │    • [WXApi registerApp:@"wx586a9b321e56efb7" enableMTA:YES]
  │    • registerAppSupportContentFlag: 文本/图片/位置/视频/音频/网页/DOC/DOCX/PPT/PPTX/XLS/XLSX/PDF
  └─ return YES
```

### 1.2 URL Scheme 回调（微信 / QQ 分享）

`AppDelegate.m` 同时实现 3 个 openURL 入口（兼容 iOS 8 / 9+）：

```objc
- (BOOL)application:handleOpenURL:url                         // iOS 8 及更早
- (BOOL)application:openURL:sourceApplication:annotation:     // iOS 9
- (BOOL)application:openURL:options:                          // iOS 9+
```

三个入口的实现完全一致，处理顺序固定：

```
1. [QQShareManager handleOpenURL:url]      → 命中即 return YES
2. [WXApi handleOpenURL:url delegate:[WXApiManager sharedManager]]
```

**注意**：QQ 必须在微信之前判断（QQ 分享回调如果走到 WXApi 会被吞掉）。新外壳的 SceneDelegate / UIApplicationDelegate 实现必须保留这一顺序。

涉及到的 URL Scheme（来自微信/QQ SDK 注册需求）：
- 微信：`wx586a9b321e56efb7`
- QQ：由 `QQShareManager` 管理（具体 scheme 见 Info.plist）

### 1.3 生命周期通知

| 系统事件 | 内部行为 |
|---------|---------|
| `applicationDidEnterBackground` | 发 `enterForeground` 通知（命名反人类，沿用历史）|
| `applicationDidBecomeActive` | 节流 0.3s 后发 `applicationWillResignActive` 通知 |
| `applicationDidReceiveMemoryWarning` | 仅 NSLog，不弹窗 |

两个通知最终被 RootVC/NewRootVC 监听，转发为 `appservice` 桥事件（见 §3.2）。

### 1.4 UIApplication openURL: 兼容性 swizzle

`AppDelegate.m` 末尾对 `UIApplication.openURL:` 做了 method swizzle，在 iOS 10+ 自动转发到 `openURL:options:completionHandler:`。新外壳如果用 Swift 重写，建议直接使用 `UIApplication.shared.open(_:options:completionHandler:)` 而无需 swizzle。

---

## 2. 主控制器生命周期 & 资源加载

### 2.1 NewRootVC（iOS 9+）

```
init
  ↓ filename: 读取所有渠道注入值（gamedir/gamestart/gameconfig/channel/agent/iosNumber/gameinfo/market/...）
  ↓
viewDidLoad
  ├─ 创建 WKWebView（frame = 全屏）
  ├─ _bridge = [WebViewJavascriptBridge bridgeForWebView:_webView]
  ├─ [_bridge setWebViewDelegate:self]
  ├─ [_bridge enableLogging]
  ├─ registerHandler × 20+  （见 §3.1）
  ├─ 监听 NSNotification:
  │   - "backgameDatatwo"        → 转发为 getWebdata
  │   - "enterForeground"        → 转发为 appservice("2")
  │   - "applicationWillResignActive" → 转发为 appservice("1")
  │   - UIDeviceBatteryLevelDidChangeNotification → 转发为 getBattery
  ├─ AFNetworkReachabilityManager 监听 → 转发为 getnetwork
  ├─ 启动 gonet 计时器（4s 轮询 gameconfig 服）
  └─ navigationController setNavigationBarHidden:YES
  ↓
viewWillAppear
  ├─ 下载 SERVERNew/{gameconfig}.json 配置
  ├─ 解析 agent/game/channel/market 版本号
  ├─ 比较远端 vs 本地，必要时 uplevel() → 下载 game.zip + 解压
  └─ initView() → 注入 app_data.js / app_battery.js / app_network.js
                  → loadFileURL:Library/Caches/{gamedir}/{gamestart}/index.html
  ↓
dealloc → 移除所有通知，释放 AVAudioPlayer / locationManager / bridge
```

### 2.2 RootVC（iOS < 9）

差异：
- 使用 `UIWebView`，桥接走 `documentView.webView.mainFrame.javaScriptContext`
- `context[@"settings"] = jo` 暴露 `Bridge` 实例
- Native→H5 用 `[context evaluateScript:@"funcName(args)"]`
- 启动 URL 附 `?Launchtype=0` 查询参数
- 注入 JS 仅靠 `webViewDidFinishLoad` 时 `stringByEvaluatingJavaScriptFromString`
- 接口契约与 NewRootVC 对外名字一致（见 0.1 节关于 JSExport 默认转换规则）

### 2.3 三个子页面控制器

| 控制器 | WebView | 桥 | 推入时机 | 入口路径 |
|--------|---------|-----|---------|---------|
| `threeView` | UIWebView | Bridgetwo（轻量，3 接口）| `OpenurlTitleData` / `Openurl:Title:Data:` 桥接调用 | 外部 URL（H5 传入）|
| `fourviewVC` | UIWebView | Bridge（与 RootVC 同协议）| iOS<9 子游戏切换 | Library/Caches/{newgamedir}/{newgamestart}/index.html?Launchtype=1 |
| `gameController` | WKWebView | WebViewJavascriptBridge + 声网 Agora | iOS≥9 子游戏切换 / `SwitchOverGameData` | Library/Caches/{newgamedir}/{newgamestart}/index.html |

- `threeView` 用途：H5 调 `OpenurlTitleData` 时打开一个浮层 WebView 显示外部网页（公告/活动页/客服）
- `gameController` 比 NewRootVC 多 3 个**声网视频房间**接口（exitRoom / getVideoinfo / createRoom）
- `fourviewVC`、`threeView` 是 iOS<9 旧路径产物，新外壳可以合并为一个"通用 WebView 容器 + 视频房间扩展"

### 2.4 控制器层级关系

```
window.rootViewController = NavgationController
   └─ NewRootVC（大厅）
          ├─ push threeView（H5 OpenurlTitleData：打开外链/公告）
          └─ push gameController（H5 SwitchOverGameData：进入子游戏）
                 └─ push threeView（子游戏内也可以打开外链）
```

返回门控变量：`canbackone` / `one_Time` / `first_Time` / `Nothere`，用于防止抖动重复 push 和阻塞返回。新外壳推荐用 `UINavigationController` 内置 push 节流（disable 一段时间）+ DispatchWorkItem 实现，不要照搬这些散落 BOOL。

---

## 3. H5 ↔ 原生 桥接契约（**新外壳必须严格落地**）

> 以下接口名 = H5 端书写的字符串。新外壳必须使用相同的 Handler 名，相同的 data 字段、相同的 callback 数据结构、相同的副作用。
>
> 表中"入参"对应 `bridge.callHandler('xxx', data, responseCallback)` 中的 `data`；"responseCallback"对应同步 ack；"Native→H5 callback"指 `bridge.callHandler('xxx', payload)` 反向通知 H5。
>
> 对于 iOS<9 的 H5 调用风格 `settings.xxx(...)`，参数会按 ObjC selector 切分成位置参数（详见附录 A），新外壳如果不再支持 iOS<9 可以忽略；H5 适配层通常会自动二选一。

### 3.1 H5 → Native（registerHandler）

**真实分布**（以源码为准）：

- `NewRootVC` 注册 **20 个** handler（大厅页用）
- `gameController` 注册 **23 个** handler（子游戏页用）= 与 NewRootVC 共享 **19 个** + 子游戏独有 **4 个**（`backgameData` / `createRoom` / `getVideoinfo` / `exitRoom`）
- 两者唯一差异：`SwitchOverGameData` 只在 NewRootVC（大厅 → 子游戏跳转），`backgameData` 只在 gameController（子游戏 → 大厅返回）
- `threeView` 不在此列，弹层桥见 §3.4

#### § A. 登录 / 分享

##### 【1】 `accreditlogin` — 微信授权登录

| 字段 | 说明 |
|------|------|
| **入参 data** | 无（H5 传任意值，原生忽略）|
| **responseCallback** | 字符串 `"Response from accreditlogin"` |
| **副作用** | 调起微信 OAuth2 授权 |
| **后续回调** | Native→H5 调用 `sharelogin`（见 §3.2 ［10］）|

**旧外壳实现路径**（已识别为安全风险）：
1. `[WXApiRequestHandler sendAuthRequestScope:kAuthScope State:kAuthState OpenID:kAuthOpenID InViewController:self]`
2. 微信回包 `managerDidRecvAuthResponse:` 拿到 code
3. **客户端直接拼**：`api.weixin.qq.com/sns/oauth2/access_token?code=...&secret=Appsecret&appid=...&grant_type=authorization_code` — secret 永久驻留 IPA
4. 再拉 `api.weixin.qq.com/sns/userinfo?access_token=...&openid=...`
5. 把 userinfo 通过 `callHandler:@"sharelogin"` 回给 H5

> **新外壳实现建议**（按 daoqi/CLAUDE.md 原则 B —— 内部自由重构、追求专业）：
> - 步骤 1 / 5 保持不变（微信原生 SDK 调起 + sharelogin 回包字段，H5 契约边界）
> - 步骤 3 / 4 改为：客户端把 code 发给项目自有后台的 `/wechat/login` 接口，由后台完成 access_token + userinfo 拉取，再回传 7 个字段给客户端 → 客户端再 `callHandler:@"sharelogin"` 给 H5
> - **H5 视角完全无感**：仍然收到 `sharelogin({openid, headimgurl, nickname, sex, city, Province, unionid})` 7 字段
> - 收益：新外壳的 IPA 不再携带 secret，根除"二次泄露"风险
> - 前置依赖：后台需提供 `/wechat/login` 中转接口（项目方协调，超出本文档范围）；若后台暂不提供，新外壳可临时沿用客户端直拼方案，但应在代码里标注 TODO 并优先推动后台改造

##### 【2】 `friendsSharetypeUrlToptitleDescript` — 分享

| 字段 | 类型 | 说明 |
|------|------|------|
| `sharefriend` | string `"1"` / `"2"` | 1=好友/私聊，2=朋友圈/动态 |
| `sharetype` | string `"1"` / `"2"` / `"3"` | 3=闲聊；否则微信 |
| `type` | string `"1"` / `"2"` / 其他 | 1=链接分享，2=截图分享，其他=远程图片 URL 分享 |
| `webpageUrl` | string | 链接 URL 或图片 URL |
| `title` | string | 分享标题（**注意此键没有末尾空格**，与 §3.1 ［15］ OpenurlTitleData 的 `"title "` 不同）|
| `description` | string | 分享描述 |

**responseCallback**：字符串 `"sharefriend"`
**Native→H5 callback**：`sharesuccess`（成功后），数据见 §3.2 ［11］

实现分发（外层按 `type`，内层按 `sharetype`）：
- `type == 1` → 链接分享；`sharetype==3` 走 XianliaoShareLinkObject，否则走 `WXApiRequestHandler sendLinkURL:`；缩略图用 `sharelogo.png`
- `type == 2` → 截图分享；用 `FuncPublic getImageWithFullScreenshot` + `UIImageJPEGRepresentation(0.6)`；`sharetype==3` 走 XianliaoShareImageObject，否则 `WXApiRequestHandler sendImageData:`
- `type` 取其他值（含 3）→ 远程图片 URL 分享；从 `webpageUrl` 下载 NSData 再分享；分支同上
- 场景：`sharefriend==1` → `WXSceneSession`；`sharefriend==2` → `WXSceneTimeline`

#### § B. 音频 / 录音

##### 【3】 `srcIsloop` — 本地音频播放

| 字段 | 类型 | 说明 |
|------|------|------|
| `src` | string | 音频文件名（位于 `Library/Caches/{gamedir}/{gamestart}/assets/wav/{src}`）|
| `isloop` | int `0` / `1` / `-1` | 0=单次按钮音；1=循环背景音；-1=停当前同类背景音 |

**responseCallback**：`"Response from srcIsloop"`
**实现**：
- `isloop == 0` → 单次 `buttunPlayer` 播放
- `isloop == 1` → `backgroundPlayer numberOfLoops = -1`，记录 `backgroundType = src`
- `isloop == -1` → 如果当前 `backgroundType == src`，则停止 backgroundPlayer

##### 【4】 `prepareaudio` — 启动麦克风录音

| 字段 | 类型 | 说明 |
|------|------|------|
| 入参 | (忽略) | |
| responseCallback | `"Response from prepareaudio"` | |

副作用：
1. `FuncPublic ifauth` 检查麦克风权限
   - `0`（denied/restricted）：通过 H5 alert 提示"{gamehallname}需要访问您的麦克风"
   - `1`（not determined）：开始录音时系统自动弹询问框
   - `2`（authorized）：直接录音
2. 用时间戳生成文件名（`yyyyMMddHHmmss`）
3. `[recorderVC beginRecordByFileName:fileName]`
4. 录音完成回调流程：
   - 触发 `VoiceRecorderBaseVCRecordFinish:fileName:` 代理
   - WAV → AMR 转换（`VoiceConverter ConvertWavToAmr:`）
   - AMR 上传到七牛云（新版）或 `http://gameapi.0791ts.cn/api/UpLoad/PostFile`（旧版）
   - 通过 `getaudiourl` callback 把 URL + 时长回给 H5（见 §3.2 ［9］）

##### 【5】 `mediaTypeAudio` — 远程语音回放

| 字段 | 类型 | 说明 |
|------|------|------|
| `audiourl` | string | 远程 AMR 文件 URL（通常是七牛 CDN）|
| `user` | string | 发声方的用户 ID |

**responseCallback**：`"Response from mediaTypeAudio"`
**流程**：
1. 下载 AMR 到本地
2. `VoiceConverter ConvertAmrToWav` 转 WAV
3. `AVAudioPlayer` 播放（若当前已有语音在播，先停旧、给 H5 发 `gameui_stop_voice(被打断 user)` 再起新播放）
4. 开始播放时 callback H5：`gameui_play_voice(user)` （见 §3.2 ［7］）
5. 播放结束 / 被打断 callback H5：`gameui_stop_voice(user)` （见 §3.2 ［8］）

##### 【6】 `voicePlaying` — 语音播放总开关

| 入参 | 类型 | 说明 |
|------|------|------|
| data | int `1` / `0` | 1=允许 mediaTypeAudio 实际播放；其他=静默不播 |

**responseCallback**：`"voicePlaying"`

#### § C. 摇一摇 / 振动

##### 【7】 `startshake`
- 入参：(忽略) → `canshake = YES`
- responseCallback：`"startshake from accreditlogin"`（字面字符串，沿用历史；H5 不依赖此值，但拷贝保留以减少差异）

##### 【8】 `stopshake`
- 入参：(忽略) → `canshake = NO`
- responseCallback：同上

##### 【9】 `SwitchShake` — 摇一摇音效开关
- 入参：int `1`/`其他` → `canvoice = (1==YES)`
- responseCallback：`"SwitchShake"`

##### 【10】 `vibrator` — 单次振动
- 入参：(忽略) → `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`
- responseCallback：`"vibrator"`

##### 【11】 `repeatvibrator` — 重复振动（实际同 vibrator）
- 入参：int
- responseCallback：`"repeatvibrator"`
- 实现就是单次 vibrate，不真的重复，沿用历史

##### 【12】 `canclevibrator` — 释放振动
- 入参：(忽略) → `AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)`
- responseCallback：`"canclevibrator"`

> 摇一摇结束事件 → §3.2 ［5］ `shakeEnd`

#### § D. 剪贴板

##### 【13】 `gamepastetext` — 读剪贴板
- 入参：(忽略)
- responseCallback：`UIPasteboard.generalPasteboard.string`（字符串本体）

##### 【14】 `gameCopytext` — 写剪贴板
- 入参：string → `UIPasteboard.generalPasteboard.string = data`
- responseCallback：`"gameCopytext"`

#### § E. 网页 / 浏览器 / 子游戏切换

##### 【15】 `OpenurlTitleData` — 打开内嵌 WebView 弹层（threeView）

| 字段 | 类型 | 说明 |
|------|------|------|
| `url` | string | 待加载 URL（HTTP）|
| `title ` | string | **键名末尾有空格**，沿用历史，新外壳必须用 `@"title "` 取值 |
| `data` | string | 业务数据，传给 threeView 内 H5 |
| `orientation` | int `0`/`1` | 0=竖屏（实际逻辑见实现），1=横屏 |

**responseCallback**：`"OpenurlTitleData"`
**节流**：第一次调用后 3 秒内重复调用被吞掉（`first_Time` 标志）

##### 【16】 `browser` — 系统 Safari 打开外链
- 入参：string（URL，需要 URL 编码后传给 `UIApplication openURL:`）
- responseCallback：`"browser"`

##### 【17】 `SwitchOverGameData` — 切换到子游戏（push gameController）

| 字段 | 类型 | 说明 |
|------|------|------|
| `Gamedirectory` | string | 子游戏 gamedir 名（解压目录）|
| `gamedownloadurl` | string | 子游戏 zip 包 CDN URL |
| `data` | string | 父大厅传给子游戏的数据（H5 自由 JSON）|

**responseCallback**：`"SwitchOverGameData"`
**副作用**：
1. 停止 backgroundPlayer
2. push 一个新的 `gameController`（WKWebView + 视频房间能力）
3. 子游戏内首次进入会拉取 zip → 解压到 `Library/Caches/{Gamedirectory}/`
4. 2 秒内重复调用被忽略（`one_Time` 节流）
5. 子游戏返回大厅时调用 `backgameData`（H5→Native，由 threeView/gameController 实现），通过 `NSNotification "backgameDatatwo"` 通知大厅，大厅再 callback `getWebdata`

##### 【18】 `backgameData` — 子游戏退出并回传数据
> ⚠️ **仅在 `gameController` 注册，NewRootVC 没有**
- 入参：string（业务数据 JSON）
- responseCallback：`"backgameData"`
- 副作用：
  1. 停止 backgroundPlayer
  2. cleanUpAction（清理 Agora 房间等子游戏副作用）
  3. 移除 backgameDatatwo / enterForeground / applicationWillResignActive 通知监听
  4. `[FuncPublic PopAnimation:self]` 回大厅
  5. `[NSNotificationCenter postNotificationName:@"backgameDatatwo" object:data]`
- 大厅 NewRootVC 监听 `backgameDatatwo` 后会执行 `[_bridge callHandler:@"getWebdata" data:data]` 通知 H5

> ❌ `finishweb` 不在大厅桥/子游戏桥注册，它是**弹层桥**的接口。详见 §3.4。

#### § F. 定位

##### 【20】 `startlocation`
| 入参 | 类型 | 说明 |
|------|------|------|
| data | int `1`/其他 | 1=持续 `startUpdatingLocation`；其他=一次性 `reGeocodeAction` |

**responseCallback**：`"startlocation"`
**Native→H5 callback**：`getlocationinfo`（见 §3.2 ［6］）
**实现**：基于 `AMapLocationManager`，精度 `kCLLocationAccuracyHundredMeters`

#### § G. 系统 / 设备

##### 【21】 `getphoneInfo` — 请求设备信息
- 入参：(忽略)
- responseCallback：`"getphoneInfo"`
- 真实数据通过 `getphoneinfo` callback（注意是小写 i！）返回，见 §3.2 ［1］

##### 【22】 `opensaoma` — 二维码扫描（未实现）
- 入参：(忽略)
- responseCallback：`"opensaoma"`
- 当前为空实现，H5 调了不会出错也不会有动作。新外壳可以保留空实现以保持契约。

#### § H. 视频房间 / 子游戏返回（仅 `gameController` 注册）

##### 【19】 `createRoom` — 创建/加入声网音视频房间，并放置自己画面

| 字段 | 类型 | 说明 |
|------|------|------|
| `pmw` | int | H5 端设计稿宽（用于把 H5 坐标换算到原生屏幕坐标）|
| `pmh` | int | H5 端设计稿高 |
| `top` | float | 视频窗 top（H5 设计稿坐标系）|
| `left` | float | 视频窗 left |
| `width` | float | 视频窗 width |
| `height` | float | 视频窗 height |
| `playerid` | string（数字串）| 本地用户 ID（自己，转 NSUInteger 作 Agora uid）|
| `roomid` | string | 频道源串；原生会执行 `md5(agentinfo + gameinfo + roomid)` 作为 Agora channel name |

**responseCallback**：`"createRoom"`
**坐标变换公式**：`x_pix = x_h5 * (DEVW / pmw)`，`y_pix = y_h5 * (DEVH / pmh)`
**副作用**：
- 内部创建 `localVideoBG`（214×129 容器）+ `localVideo`（136×109 自画像）
- 远端画面容器 `remoteVideo1` 按上述变换定位
- 默认 `localVideoBG.hidden=YES`
- `setEnableSpeakerphone:YES` + `idleTimerDisabled=YES`
- `agoraKit joinChannelByKey:nil channelName:md5(...) info:nil uid:playerid`
- **不会立刻触发任何 Native→H5 callback**；当远端用户加入并首帧解码后，触发 `getVideoinfo`（见 §3.2 ［13］）

##### 【20】 `getVideoinfo` — 添加/排版一个新的远端视频窗

入参与 `createRoom` **几乎相同**，但缺少 `roomid`（已在房间内），多一个隐含约定：

| 字段 | 类型 | 说明 |
|------|------|------|
| `pmw` / `pmh` | int | 设计稿宽高（同上）|
| `top` / `left` / `width` / `height` | float | 远端视频窗位置（H5 设计稿坐标）|
| `playerid` | string（数字串）| 远端用户 ID，等同 Agora 的 uid |

**responseCallback**：`"getVideoinfo"`
**节流**：内部 `players` 数组按 playerid 去重，已存在则跳过
**副作用**：创建 `remoteVideo` 子视图 + Agora `setupRemoteVideo` 绑定远端 uid

> 注意：H5 调用的 `getVideoinfo` 和 Native 反向触发的 `getVideoinfo`（见 §3.2 ［13］）**是同名两个方向**——bridge.callHandler 在 WVJB 里对同名是分通道的：Native 端 callHandler 是 Native→H5，H5 端调 callHandler 是 H5→Native，互不干扰。新外壳实现时不要把这二者合并。

##### 【21】 `exitRoom` — 离开房间
- 入参：(忽略)
- responseCallback：`"exitRoom"`
- 副作用：`[self leaveChannel]`（Agora `leaveChannel`，清理本地/远端 view）

##### 【22】 `backgameData` — 子游戏退出回大厅（同 §3.1 ［18］）
（见上方 ［18］，是同一个 handler，在 gameController 这一类下也算）

> Agora SDK appID 见 §0.4。新外壳如果不上视频可以把这 3 个视频 handler 注册为空响应——但只有 H5 子游戏不依赖语音/视频时才允许，否则 H5 会等不到 `getVideoinfo` 排版而画面空白。

### 3.2 Native → H5（callHandler 反向通知）

| # | Handler 名 | 触发时机 | 数据结构 | NewRootVC | gameController |
|---|-----------|---------|---------|:--------:|:--------:|
| 1 | `getphoneinfo` | 注册 `getphoneInfo` handler 收到调用后 | 见下方表 A | ✓ | ✓ |
| 2 | `getBattery` | UIDeviceBatteryLevelDidChangeNotification | 字符串 `"%.2f"` 形式的小数电量（0~1）| ✓ | ✓ |
| 3 | `getnetwork` | AFNetworkReachability 变化 | 字符串 `"1"`/`"2"`/`"3"` （1=无网，2=WiFi，3=蜂窝）| ✓ | ✓ |
| 4 | `appservice` | App 前后台切换 | 字符串 `"2"`=进入后台，`"1"`=回到前台（值错位，沿用 msext WKWebView 路径历史。详见下方注释）| ✓ | ✓ |
| 5 | `shakeEnd` | motionEnded 摇一摇结束 | `nil` | ✓ | ✓ |
| 6 | `getlocationinfo` | 定位成功/失败 | 见下方表 B | ✓ | ✓ |
| 7 | `gameui_play_voice` | mediaTypeAudio 开始播放（且 voicePlaying=1 时）| 字符串 user_id | ✓ | ✓ |
| 8 | `gameui_stop_voice` | mediaTypeAudio AVAudioPlayer 播放结束 / 被新一次 mediaTypeAudio 打断 / 解码失败 | 字符串 user_id（**该次结束 / 被打断的语音**所绑定的 user，不是入参里最新的 user — 与 daoqi 旧实现语义不同，详见下方 `gameui_stop_voice` 行为说明）| ✓ | ✓ |
| 9 | `getaudiourl` | 录音 + 上传成功（七牛或旧 PostFile）| `{ "audiourl": "<七牛 URL>", "time": "<秒数字符串>" }` | ✓ | ✓ |
| 10 | `sharelogin` | 微信授权 OAuth 拿到 userinfo 后 | 见下方表 C | ✓ | ✓ |
| 11 | `sharesuccess` | 分享完成（微信 SendMessageToWXResp / 闲聊 finishBlock）| `{ "success": "2", "type": "<sharefriend 原值如 '1' 或 '2'>" }` | ✓ | ✓ |
| 12 | `getWebdata` | 收到 `backgameDatatwo` 通知（来自子页面/子游戏）| 字符串（透传 backgameData 入参）| ✓ | ✓ |
| 13 | `getVideoinfo` | 远端用户 Agora `didDecodedRemoteVideo` | 字符串 uid（数字串）| ✗ | ✓ |
| 14 | `phonestate` | CTCallCenter 检测到电话状态变化 | 字符串 `"2"`=来电，`"0"`=挂断 | ✗（已注释）| ✓ |
| 15 | `recordSuccess` | 七牛云上传完成后**额外**触发（与 getaudiourl 同时发）| `{ "fileUrl": "<完整 URL>", "fileName": "<7n key 文件名>", "fileKey": "<七牛 key>" }` | ✗ | ✓ |

**差异提示**：第 13–15 项是 gameController 独有的 callback。新外壳如果要让子游戏 H5 行为完全一致，必须实现这 3 项。其中 `phonestate` 与 `recordSuccess` 比较容易忽视：
- `phonestate`：H5 子游戏会在收到 `"2"` 时主动哑麦/暂停，收到 `"0"` 恢复，跟视频房间体验强相关
- `recordSuccess`：H5 收到后会拿 `fileUrl` 而非 `audiourl` 走业务（早期接口与新接口并存的兼容产物）；新外壳两个 handler 都得发，data 字段不同

**`appservice` 值错位说明**（极容易看反，新人务必照抄不要"修正"）：

原 msext WKWebView 路径（`NewRootVC.m:1769-1786` / `gameController.m:1319-1336`）的真实代码是：
- iOS `applicationDidEnterBackground`（**真·进入后台**）→ `AppDelegate` post 一个**名为 `enterForeground` 的 NSNotification**（NSNotification 名错位，沿用历史）→ VC 的 `-enterForeground` 方法触发 → `[_bridge callHandler:@"appservice" data:@"2"]`
- iOS `applicationDidBecomeActive`（**真·回到前台**）→ `AppDelegate` post 一个**名为 `applicationWillResignActive` 的 NSNotification**（NSNotification 名错位）→ VC 的 `-applicationWillResignActive` 方法触发 → `[_bridge callHandler:@"appservice" data:@"1"]`

H5 端 N 年来收到的实际语义就是：**`"2"` = 进入后台、`"1"` = 回到前台**。新外壳不论 NSNotification 内部命名怎么改、用 `willEnterForegroundNotification` 还是 `didBecomeActiveNotification`，桥发出的字符串值必须按上面这套对应关系发，**不要被 NSNotification 名误导而把值发反**。

（顺带：原 msext 还有一条 iOS<9 UIWebView 路径 `RootVC.m:516-543` / `fourviewVC.m:1254-1282`，发的值反过来：进入后台 → `"1"` / 回到前台 → `"2"`。该路径在新外壳最低 iOS 15.6 下完全不可达，**不能作为对齐依据**，请忽略。）

**`gameui_stop_voice` 行为说明**（与 daoqi 旧实现的有意差异，用户反馈驱动）：

daoqi `NewRootVC.m:1736-1748` / `gameController.m:1288-1308` 把 `voicePlayer` / `self.User_id` 都作为 VC 成员变量。覆盖式播放（前一条还在播，第二条到来）的代码路径：
1. `self.User_id = userid;` 先覆盖最新 user
2. `voicePlayer = [[AVAudioPlayer alloc]...]` 直接换 ivar（MRC 下旧 player 实际泄漏继续在播）
3. 旧 player 自然播完触发 `audioPlayerDidFinishPlaying:` → 调 `stopradio` → `callHandler:@"gameui_stop_voice" data:self.User_id`

结果：旧消息对应的 `gameui_stop_voice` 发出去的 user_id **实际是新消息的 user_id**；H5 收不到旧消息的停止动画信号，旧动画一直转，新消息那条反而停了。

新外壳实现（`VoicePlayer.swift`）把 user 与 player 一一绑定（`currentUser`）：
- 覆盖式 play 时**主动**停旧 player，并用旧 player 绑定的 user 触发一次 `gameui_stop_voice`，再起新播放 + `gameui_play_voice(新 user)`
- 自然结束 / 解码失败：用结束那个 player 绑定的 user 发 `gameui_stop_voice`，不再读"最近一次入参"

H5 端无需任何改动：仍然按照"收到 `gameui_stop_voice(user)` 就停这个 user 的动画"处理即可，只是信号现在会更准。验证场景：H5 连发两条 `mediaTypeAudio`（user A → user B，A 还没播完 B 就来），现在 H5 会先收 `gameui_stop_voice(A)` 再收 `gameui_play_voice(B)`，B 播完再收 `gameui_stop_voice(B)`。daoqi 旧路径下 A 那次停止信号永远收不到。

**表 A — `getphoneinfo`**：
```json
{
  "PhoneAdresseMAC":    "<UUID idForVendor>",
  "PhoneDeviceBrand":   "<UIDevice.model>",     // "iPhone" / "iPad"
  "PhoneIMEI":          "<IDFA>",               // ASIdentifierManager.advertisingIdentifier
  "PhoneModel":         "<UIDevice.localizedModel>",
  "PhoneProvidersName": "<carrier name>",
  "PhoneVersion":       "<systemVersion>"
}
```

**表 B — `getlocationinfo`** （成功）：
```jsonc
{
  "errorCode": 0,             // ⚠️ 新外壳补齐项，msext 遗漏，见下方说明
  "address":   "<完整地址>",
  "city":      "<市>",
  "cityCode":  "<城市编码>",
  "country":   "<国>",
  "district":  "<区>",
  "latitude":  30.567890,     // ⚠️ 数字（非字符串），6 位小数。新外壳修正项，见下方说明
  "longitude": 104.123456,    // ⚠️ 数字（非字符串），6 位小数
  "province":  "<省>",        // ⚠️ 小写 p，与 sharelogin 的 "Province" 大写形成不一致，沿用历史
  "street":    "<街道>"
}
```
失败：
```jsonc
{ "errorCode": 12, "errorMsg": "缺少定位权限" }   // errorCode 是 NSNumber，JSON 看是数字 12
```

**`errorCode: 0`（成功状态码）—— 唯一一处有意偏离 msext 的定位字段**

msext 全工程（`grep -rn errorCode msext/Class/`）只在**失败**分支发 `errorCode: 12`
（`gameController.m:2507` / `NewRootVC.m:2113` / 老 UIWebView 路径
`RootVC.m:1976` / `fourviewVC.m:1518`），成功分支的 9 个字段里**没有** errorCode。
**这是原工程的遗漏**，不是刻意设计。

H5 侧以 `errorCode == 0` 作为"这份定位数据有效"的判据。证据（2026-08-07 从真机沙盒
取回的现网 H5 逐字核对）：

| H5 包 | `05_Func.js` 日期 | 全文 `errorCode` |
|---|---|---|
| 子游戏 `jinxianmahjong` | 2026-07-19 | 有 2 处 —— `Func.startlocation` / `Func.getlocation` 的 `catch` 兜底桩自造定位对象时带 `"errorCode":0` |
| 大厅 `gamehall` | 2026-02-04 | **0 处** |

即：较新的子游戏 H5 依赖成功包里的 `errorCode`，较老的大厅 H5 不依赖。这正是
「大厅定位正常、子游戏拿不到定位」这一现象的成因 —— 原生成功包缺 `errorCode`
时子游戏 H5 不认这份数据。

因此新外壳在成功包里补 `errorCode: 0`（JSON 数字，与失败包的 `12` 同类型）。
对不检查该字段的大厅 H5 是多一个无害字段，不改变其行为。

**`latitude` / `longitude` 是数字，不是字符串 —— 第二处修正**

msext 的两条路径自己就不一致：

| msext 路径 | 代码 | JSON 形态 |
|---|---|---|
| WVJB / WKWebView（`gameController.m:2528`、`NewRootVC.m:2132`）| `[NSString stringWithFormat:@"%f", …]` 放进 `@{}` | **字符串** `"28.636486"` |
| 老 UIWebView / JSContext（`RootVC.m:1998`、`fourviewVC.m:1541`）| 手拼 `\"latitude\":%f` | **数字** `28.636486` |

现网子游戏 H5（`jinxianmahjong` `05_Func.js:2029-2031`）兜底桩自造的定位对象写的是
`"latitude":28.623546` —— **数字**形态。与 `errorCode` 是同一类问题：WVJB 路径当年
把它字符串化了。新外壳按数字发。

实现细节：用 `BridgeData.decimal(Decimal)`（不是 `.number(Double)`）。原因是
`JSONSerialization` 写 `Double` 会输出 17 位有效数字，`28.636486` 变成
`28.636486000000001`；虽然 JS `JSON.parse` 出来是同一个 IEEE754 double、H5 不可分辨，
但 JSON 文本与原工程不一致。`Decimal` 由 `String(format: "%f", …)` 构造，序列化成
`NSDecimalNumber` → 文本 `28.636486`，与老路径 `%f` 拼串逐字节一致。

**这两处（`errorCode: 0`、经纬度数字）是定位链路上唯一有意偏离 msext WVJB 路径的地方**，
其余（9 个字段名、`cityCode` 大写 C、`province` 小写 p、`street` 不带 `路` 后缀、
失败包 `{errorCode:12, errorMsg:"缺少定位权限"}`、error 与 success 两段式发送顺序、
任一逆地理字段为 nil 时整条不发）全部严格对齐。

实现：`Source/Bridge/Handlers/StartLocationHandler.swift`、`Source/Location/LocationService.swift`。

**表 C — `sharelogin`**：
```jsonc
{
  "openid":     "...",
  "headimgurl": "...",
  "nickname":   "...",     // 直接来自微信 userinfo（部分版本会带单引号包裹，由 H5 自行 trim 或原生在更早处理）
  "sex":        "1|2|0",   // 1=男，2=女，0=未知
  "city":       "...",     // 经 FuncPublic danbian: 去除单引号
  "Province":   "...",     // ⚠️ 大写 P；经 FuncPublic danbian: 去除单引号
  "unionid":    "..."
}
```

### 3.3 关于 iOS<9 旧桥的精确签名（附录用，新外壳可不实现）

旧桥 `Bridge.h` 的 `JSProtocol`（共 **28** 方法）和 JS 调用方式见 **附录 A**。这些 ObjC selector 在 JSExport 默认转换规则下产生的 JS 方法名，已经和新桥的 handler 名一一对应（例如 `media:Type:Audio:` → JS `mediaTypeAudio`），所以 H5 上层调用是一致的。

### 3.4 弹层桥（threeView 内）

threeView 用 **UIWebView + JSExport (Bridgetwo)**，**与系统版本无关**——iOS 9+ 也走这条。`webViewDidFinishLoad:` 内执行：

```objc
JSContext *ctx = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
Bridgetwo *jo = [[Bridgetwo alloc] init];
ctx[@"settings"] = jo;
jo.delegate = self;
```

故 H5（弹层内的外链/公告页）的可用 JS API 是 `window.settings.方法名(...)`，**不是** `bridge.callHandler`。共 3 个方法：

| JS 调用 | 入参 | 行为 |
|--------|------|------|
| `settings.backgameData(data)` | string | pop 自己 + `[NSNotificationCenter postNotificationName:@"backgameDatatwo" object:data]`；大厅监听后 callback H5 `getWebdata` |
| `settings.browser(url)` | string | URL 编码后 `[UIApplication openURL:]` 调起 Safari |
| `settings.finishweb()` | (无) | pop 自己 |

弹层桥**没有** Native→H5 callback。

**新外壳实现建议**：threeView 可以升级到 WKWebView，但需要在 `webView didFinishNavigation:` 时注入：

```js
window.settings = {
  backgameData: function(data) { window.webkit.messageHandlers.backgameData.postMessage(data); },
  browser:     function(url)  { window.webkit.messageHandlers.browser.postMessage(url); },
  finishweb:   function()     { window.webkit.messageHandlers.finishweb.postMessage(""); }
};
```
并注册对应 3 个 `WKScriptMessageHandler`。这样 H5 外链页代码完全不用动。

> **同名注意**：`backgameData` / `browser` 在弹层桥与大厅桥都有，但语义略不同（弹层 backgameData 会 pop 自己；大厅 backgameData 在 NewRootVC 不存在、在 gameController 是回大厅）。H5 在弹层内调 `settings.backgameData` 走的是弹层桥，不会跟主大厅 WVJB 撞车，因为后者用的是 `bridge.callHandler('backgameData', ...)`。

---

## 4. WebView 详细配置（必须照抄）

### 4.1 WKWebView 配置（NewRootVC / gameController）

```objc
WKWebViewConfiguration *cfg = [[WKWebViewConfiguration alloc] init];
WKPreferences *pref = [[WKPreferences alloc] init];
pref.javaScriptEnabled = YES;
pref.javaScriptCanOpenWindowsAutomatically = NO;  // ⚠️ 注意是 NO
pref.minimumFontSize = 10;
cfg.preferences = pref;

_webView = [[WKWebView alloc] initWithFrame:CGRectMake(0,0,DEVW,DEVH) configuration:cfg];
_webView.navigationDelegate = self;
_webView.UIDelegate = self;

if (@available(iOS 11.0, *)) {
    _webView.scrollView.contentInsetAdjustmentBehavior =
        UIScrollViewContentInsetAdjustmentNever;
}
_webView.scrollView.bounces = NO;
_webView.scrollView.scrollEnabled = NO;
_webView.scrollView.showsVerticalScrollIndicator = NO;
_webView.scrollView.showsHorizontalScrollIndicator = NO;
```

- 不设置自定义 User-Agent
- 不显式管理 Cookie（业务确认现网 H5 不用 cookies）
- LocalStorage 由 H5 自管（WKWebView 默认走 `WKWebsiteDataStore.default()` 持久化）；**跨 H5 共享语义**：大厅 + 所有子游戏共享同一 localStorage 命名空间（业务依赖）。msext 现网 file:// 下因 null origin 天然共享；新外壳 Phase 1.F 起用 `ylgame://h5/...` 单虚拟 host same-origin 保留此语义
- 加载方式：
  - **msext 现网**：`loadFileURL:fileURL allowingReadAccessToURL:dirURL`（file://），不走 HTTP
  - **新外壳（Phase 1.F 起）**：`webView.load(URLRequest(url: ylgame://h5/...))` + `WKURLSchemeHandler` 反向映射；行为对 H5 等价（详见 Design §7.6.5 等价性表 / Plan ADR-010）

### 4.2 JS 注入（最终路径：原生 `writeToFile` 写文件，与 msext 1:1 等价）

H5 业务通过 `<script src="app_*.js">` 同步引入 4 个 `.js` 文件，文件内是 `var app_xxx = "..."` 形式的全局变量声明。**原生在 `loadFileURL` 之前用 `writeToFile`/ `String.write(to:)` 把这 4 个文件写到 `{gamedir}/{gamestart}/`，H5 启动时 `<script src>` 同步引入直接读到实际值**。

> ⚠️ **2026-06-22 路径决策最终落定**（中间曾尝试改为"H5 自带 + 原生 `evaluateJavaScript` 覆盖 `window.app_xxx`"路径，commit `246f215`，已撤销）。撤销原因：`WKUserScript(.atDocumentStart)` 注入会被 H5 自带 `var app_xxx` 声明覆盖回退；`.atDocumentEnd` 又太晚，H5 业务顶层 `<script>` 已经读过默认值。任何"H5 配合改动"的妥协方案违反 CLAUDE.md 原则 A 第一准则。正确解法：回到 msext 老路写文件，时序 100% 等价。详细分析见 CLAUDE.md「典型案例 3」。

原生侧的工作（与原 msext `NewRootVC.initJSdata` 等价）：
1. **`loadFileURL` 之前**用 `String.write(to:)` 写 4 个 `.js` 文件到 `{gamedir}/{gamestart}/` 沙盒目录
2. battery / network 变化时**重写**对应 `.js` 文件（H5 下次 reload 读到新值；业务期间的实时变化由 §3.2 ［2］［3］反向 callback 推送）
3. 文件内容用单引号包裹字符串（与 msext 字面对齐），数值字段无引号

**⚠️ 命名约束：文件名 ≠ 变量名**（沿用 msext 历史）

| 文件名（不带 get） | 文件内变量名（带 get），原生需覆盖的 `window.xxx` |
|---|---|
| `app_battery.js` | `app_getbattery` |
| `app_network.js` | `app_getnetwork` |

H5 业务读 `app_getbattery`（带 get 前缀），**不是** `app_battery`。任何拼写错误都会让 H5 业务读到 H5 端的默认占位值（不是实际值）。

#### `app_data.js`（写到 `{gamedir}/{gamestart}/app_data.js`）

12 个变量（**没有** `app_gameid` / `app_compareCode`，那两个是 §4.2 早期文档错误记录，原项目代码 `NewRootVC.m:1204` / `gameController.m:2160` 字面字符串里**根本不写**它们）：

```js
var app_version            = 1;          // 硬编码，沿用历史，无引号数字
var app_gameconfig         = '<动态>';   // ⚠️ 动态切换，见下方 result 计算
var app_gamedir            = '<gamedir>';
var app_gamestart          = '<gamestart>';
var app_agent              = '<agent>';
var app_appversion         = '<0或1>';   // ⚠️ 不是版本号，是审核切换标志！见下方说明
var app_market             = '<market>';
var app_channel            = '<channel>';
var app_Launchtype         = 0;          // ⚠️ L 大写！大厅=0 / 子游戏=1
var app_getwifisignalLevel = 1;          // ⚠️ wifi 小写、signal/Level 区分大小写，硬编码 1
var app_gamename           = '<gamename>';
var app_invitationcode     = '<tuiguang_id>';   // 推广码 / 邀请码
```

> **⚠️ `app_appversion` 真实业务语义（msext `NewRootVC.m:1190-1208` initJSdata）**：
>
> 不是 "App 的版本号"，是**审核切换标志**：
>
> ```objc
> int result;
> NSString *str;
> if ([self.version_ios intValue] >= [self.iosNumber intValue])   // 远端 >= 本地
> {
>     str    = [FuncPublic filename:@"gameconfig"];   // app_gameconfig 取 gameconfig
>     result = 0;                                       // app_appversion = '0'
> }
> else {                                                            // 远端 < 本地
>     str    = [FuncPublic filename:@"appleconfig"];   // app_gameconfig 取 appleconfig
>     result = 1;                                       // app_appversion = '1'
> }
> ```
>
> 含义：
> - `result = 0`（远端 ResolvedVersion.appVersion ≥ 本地 BundleConfig.appVersion）：**正常业务模式**，H5 用 `gameconfig` 字段配置接口
> - `result = 1`（远端 < 本地，罕见，可能审核期 / IPA 已升远端未跟上）：**审核期模式**，H5 用 `appleconfig` 字段配置接口（无害业务展示给审核员）
>
> H5 读到 `app_appversion === '1'` 时切到苹果审核期分支；99% 时间下都是 `'0'`。
>
> 因此 `app_gameconfig` 不是简单读 `BundleConfig.gameConfig` 硬编码，而是根据 `result` 动态在 `gameConfig` / `appleConfig` 两个 plist 字段间切换。

> 大厅 / 子游戏的字面差异（详见 §7.5 Design）：
> - `app_Launchtype`：大厅 `0` / 子游戏 `1`
> - `app_gamedir`：大厅 = `self.gamedir`（即 BundleConfig.gameDir）/ 子游戏 = `self.gamefilepath`（子游戏目录）
> - `app_gamestart` + `app_gamename`：大厅 = `self.gamestart` / 子游戏 = `self.game_name`（子游戏名）

#### `app_battery.js`（写到 `{gamedir}/{gamestart}/app_battery.js`）

```js
var app_getbattery = 0.85;   // ⚠️ 变量名带 get！msext "var app_getbattery=%lf" double 字面
```

更新时机（启动期写文件 + 业务期 evaluateJavaScript 重新赋值，**不重写文件**）：
- 启动期：`loadFileURL` 之前写一次首次值（保证 H5 启动同步读到当前电量）
- 业务期：`UIDevice.batteryLevelDidChangeNotification` 触发 → 原生执行
  `webView.evaluateJavaScript("window.app_getbattery=N;")` 重新赋值 H5 全局变量
  **不重写文件**（H5 已加载完，`<script src>` 不会再 fetch，重写文件对当前页面无影响）
- 同时触发 §3.2 ［2］反向 callback `getBattery` 给注册了 handler 的 H5 业务

#### `app_network.js`（写到 `{gamedir}/{gamestart}/app_network.js`）

```js
var app_getnetwork = 2;   // ⚠️ 变量名带 get！msext "var app_getnetwork=%d" int 字面：1 无网 / 2 WiFi / 3 蜂窝
```

更新时机（同 app_battery.js）：
- 启动期：`loadFileURL` 之前写首次值
- 业务期：`NWPathMonitor` 状态变化 → `webView.evaluateJavaScript("window.app_getnetwork=N;")` 重新赋值（不重写文件）+ §3.2 ［3］反向 callback `getnetwork`

#### `app_gamesname.js`（写到 `{gamedir}/{gamestart}/app_gamesname.js`）

```js
var  app_gamesname = new Array('game1','game2',...);   // ⚠️ var 后面两个空格，沿用 msext AppDelegate.m:259 字面
```

更新时机：
- `AppDelegate.applicationDidFinishLaunching`（首次启动写入）
- 子游戏 `gameController.viewInit`（新装一个子游戏后追加；msext `gameController.m:197` 历史上注释掉了实际写入逻辑，仅 `AppDelegate` 首次写入是活的）

> H5 模板里 `index.html` 通过 `<script src="app_data.js">` 等同步引入这 4 个文件。新外壳必须保证这些文件**在 loadFileURL 之前**就写好，否则 H5 启动时变量未定义直接崩。

#### 修订记录

| 日期 | 修订点 | 依据 |
|------|---|---|
| 2026-06-22 | 删 `app_gameid` / `app_compareCode`：原项目 grep `var app_` 在 `NewRootVC.m:1204` / `gameController.m:2160` 字面字符串里没有这两项 | daoqi 原项目代码全面 grep 验证 |
| 2026-06-22 | `app_battery` 改为 `app_getbattery`、`app_network` 改为 `app_getnetwork`：变量名带 `get` 前缀，与文件名（不带 get）不一致 | daoqi `gameController.m:1191/1198`、`NewRootVC.m:1706/1714` |
| 2026-06-22 | 新增 `app_version` / `app_Launchtype` / `app_getwifisignalLevel` / `app_gamename` / `app_invitationcode` / `app_gamesname` 共 6 项 | daoqi `NewRootVC.m:1204`、`gameController.m:2160`、`AppDelegate.m:259` |
| 2026-06-22 | **路径调整**：从"原生 `writeToFile` 写 4 个 `.js` 文件"改为"H5 团队 zip 包自带 + 原生 `WKUserScript(.atDocumentEnd)` / `evaluateJavaScript` 覆盖 `window.app_xxx` 全局变量值" | H5 团队 / 项目方约定 |
| 2026-06-22 | **路径再次调整（最终）**：撤销上一行的 evaluateJavaScript 路径，回到原 msext "原生 `writeToFile` 写文件"路径。原因：WKUserScript 时序无法 1:1 等价 msext（`.atDocumentStart` 被 H5 自带 var 覆盖，`.atDocumentEnd` 太晚 H5 业务已读默认值），任何要求 H5 配合改动的方案违反 CLAUDE.md 原则 A 第一准则。Design §7.5 + 本节同步回退 | CLAUDE.md 原则 A 升级为第一准则后的重新评估 |
| 2026-06-22 | **`app_appversion` 业务语义重大纠正**：早期文档把 `app_appversion` 描述为"App 的版本号字符串"，实际是 msext `NewRootVC.m:1190-1208` 的审核切换标志（`'0'` 或 `'1'`）。当远端 < 本地时切到 `'1'` + `app_gameconfig` 切到 `BundleConfig.appleConfig`（审核期接口）；其它情况都是 `'0'` + `gameConfig`（正常业务）。`app_gameconfig` 数据源同步从"硬编码 gameConfig"改为"动态切换 gameConfig/appleConfig"。Design §7.5.1 同步、代码 AppDataWriter.writeAppData 修复 | daoqi `NewRootVC.m:1190-1208` initJSdata 全面 grep 重读 |
| 2026-06-22 | **业务期 `app_getbattery` / `app_getnetwork` 改为 evaluateJavaScript 重新赋值（不重写文件）**：启动期保持写文件（H5 同步 `<script src>` 需要文件存在）；业务期变化时不再重写 `app_battery.js` / `app_network.js`（H5 已加载完 `<script src>` 不会再 fetch，重写无效），改为 `webView.evaluateJavaScript("window.app_xxx=N;")` 直接重新赋值全局变量。与 §3.2 反向 callback 双轨同时执行 | WebContainerViewController.writeAppDataFiles 优化（用户指示） |

### 4.3 WKUIDelegate（H5 alert / confirm）

`NewRootVC` 实现：
- `webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:` → 弹 UIAlertController 确认框
- `webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:` → 确定/取消

`alert(msg)`：标题用 `{gamehallname}`，按钮"确定"。新外壳必须实现，否则 H5 调 `alert()` 没响应。

---

## 5. 网络层（SGGateway / SGGatewayApi）

### 5.1 调用约定

```objc
NSMutableDictionary *obj_do   = [[NSMutableDictionary alloc] init];
NSMutableDictionary *obj_post = [[NSMutableDictionary alloc] init];
[obj_post setObject:@"get_group_pic" forKey:@"t"];   // 接口名
[obj_post setObject:@"123"           forKey:@"i"];   // 业务参数
[obj_post setObject:@"<待算>"        forKey:@"code"]; // 签名占位（有此键自动签名）

[[SGGateway defaultGateway]
    gateway:self
    func:@selector(ReturnInfo:)
    obj:obj_do
    obj_post:obj_post
    STR:@"<path>"           // 拼到 baseURL 后
    HTTPMethod:@"POST"];    // POST / POSTtwo / GET
```

### 5.2 签名算法

当 `obj_post` 含 `code` 键时：
1. 把所有键按字典序排序（实现里是按插入顺序遍历——这里历史实现有歧义，沿用现状）
2. 拼接所有 value 为字符串
3. 追加密钥 `"WERTY#$&(HJKfghjWERTYUIFGJFGHadf2222"`
4. MD5
5. 设回 `obj_post[@"code"]`

### 5.3 BaseURL 选择

| HTTPMethod 入参 | baseURL |
|----------------|---------|
| `"POST"` | `SERVER` = `http://niuniuapi.0791ts.cn/` |
| `"POSTtwo"` | `SERVERTwo` = `http://bookstore.eobook.com/post/` |
| 其他（`"GET"` 等）| `SERVER`，参数走 query string |

### 5.4 响应规约

- 20s 超时，超时 UI 提示 `"无法与服务器通讯..."`，不回调
- 响应体 trim 空格 → JSON 解析
- 三个接口特殊包装为 `{"data":[{"result": value}]}`：
  - `InsertSuggestInfo.aspx`
  - `UpdateAPNSDevice.aspx`
  - `InsertBookComment.aspx`
- 解析后写到 `obj_do[@"apiReturnData"]`
- `[_mvReturn performSelector:_returnFunction withObject:obj_do]` 回调

### 5.5 大厅配置 / 版本检查走另一套（不经 SGGateway）

`NewRootVC viewWillAppear` 自己用 NSURLSession 拉：
- `{SERVERNew}/{gameconfig}.json`（注意 SERVERNew 末尾有 `/config/`）
- 解析 `{ agent:[...], game:[...], channel:[...], market:[...] }` 等数组
- 比较版本号决定是否下载 game.zip

zip 包下载 + 解压由 `ASIHTTPRequest`（旧）/ `AFNetworking`（新）+ `ZipArchive` 完成。新外壳推荐用 `URLSession + ZIPFoundation`。

---

## 6. NSNotification 名清单（跨模块通讯，必须保留）

| 通知名 | 发送方 | 接收方 | 用途 |
|--------|--------|--------|------|
| `enterForeground` | AppDelegate `applicationDidEnterBackground`（真·进入后台）| Root/NewRootVC/fourview/game/three | 转 `appservice("2")`（即 H5 收到 "2" 才是真·进入后台）|
| `applicationWillResignActive` | AppDelegate `applicationDidBecomeActive`（真·回到前台，节流 0.3s） | 同上 | 转 `appservice("1")`（即 H5 收到 "1" 才是真·回到前台）|
| `backgameDatatwo` | threeView / 子游戏 / RootVC.backgameData: | 父大厅 | 转 `getWebdata(data)` |
| `stopradio` | 任意 | 主 VC | 暂停 backgroundPlayer |
| `canbackye` | 任意（防抖恢复）| 主 VC | 允许返回手势 |
| `UIDeviceBatteryLevelDidChangeNotification` | 系统 | 主 VC | 转 `getBattery(level)` |

**命名注意**：旧 msext 中 `enterForeground` 实际表示"进入后台"，`applicationWillResignActive` 实际表示"回到前台"（命名被换反，历史包袱）。**这个命名错位顺势把桥的字符串值也带反了**——H5 收到 `appservice("2")` 才表示进入后台、收到 `appservice("1")` 才表示回到前台。

这些 NSNotification 是**模块内通讯**，**不是 H5 可见的契约边界** —— H5 只能看到桥事件 `appservice("2")`（后台）/`appservice("1")`（前台）。

新项目自由命名（推荐用 `.appDidEnterBackground` / `.appDidBecomeActive` 等清晰命名），但桥发出的值**必须按上面错位关系**对应：`didEnterBackground → "2"` / `willEnterForeground → "1"`，**不要按 NSNotification 字面语义改回去**。详见 `H5-Native-Implementation-Design.md` §4.4 与 §3.2 `appservice` 值错位说明。

---

## 7. 沙盒目录布局（运行时）

```
{App Bundle}/
  gamehall.zip                                    ← 初装资源包
  {qiniudomain/<域名>}/                            ← 渠道注入
  {gameid/<ID>}/
  {channel/<ID>}/
  {market/<num>}/
  {agent/<ID>}/
  {appversion/<num>}/
  {gamedir/<dir>}/
  {gamestart/<dir>}/
  {gameconfig/<URL编码字符串>}/
  Class/Common/VoiceConvert/lib/libopencore-amr*.a  ← AMR 编解码闭源静态库（业界标准实现）

Documents/
  (用户级持久化，目前空，未使用)

Library/Caches/
  {gamedir}/                                       ← gamehall.zip 解压目标
    {gamestart}/
      index.html                                   ← 大厅入口
      version.xml                                  ← 版本号
      app_data.js                                  ← 启动时写入
      app_battery.js
      app_network.js
      app_gamesname.js                             ← 首次启动写入
      assets/wav/*.wav                             ← srcIsloop 播的本地音效
      ...其他 H5 资源
  {子游戏 gamedir}/
    {子游戏 gamestart}/
      index.html
      ...
  zips/                                            ← 下载 zip 暂存
  images/                                          ← 缩略图等
  caches/                                          ← 其他缓存
  录音临时文件 (yyyyMMddHHmmss.wav / .amr)
```

---

## 8. 第三方 SDK 依赖矩阵

| SDK | 用途 | 关键 API | 替换难度 |
|------|------|---------|---------|
| **WebViewJavascriptBridge** | H5 ↔ Native 通讯 | `bridgeForWebView:` / `registerHandler:handler:` / `callHandler:data:` | 高（H5 强依赖 `WebViewJavascriptBridge_setup` JS 初始化协议）。新外壳必须实现等价 JS 协议 |
| **微信 SDK** | 登录、分享 | `WXApi registerApp:` / `sendReq:` / `handleOpenURL:delegate:` | 中（替换需保留 sharelogin/sharesuccess 字段）|
| **QQShareManager**（项目内自封装）| QQ 分享 | `handleOpenURL:` | 中 |
| **闲聊 SDK** | 闲聊分享 | `XianliaoApiManager registerApp:` / `share:fininshBlock:` | 低 |
| **抖音 SDK**（如有）| 抖音分享 | 同上 | 低 |
| **高德定位 SDK** | 定位 + 逆地理 | `AMapLocationManager` | 中（要保留 getlocationinfo 字段 9 个）|
| **Agora SDK** | 子游戏语音/视频房间 | `AgoraRtcEngineKit` | 高（只在 gameController 用，新外壳如果不上视频功能可桩掉）|
| **七牛 SDK** | 音频上传 CDN | `QNUploadManager` | 低 |
| **AFNetworking** | 网络监听 + HTTP | `AFNetworkReachabilityManager` | 低（可换 Network framework + URLSession）|
| **ASIHTTPRequest** | 老的 HTTP | (旧依赖)| 低（已不推荐用，新外壳全替 URLSession）|
| **ZipArchive** | 解压 | `UnzipFileTo:overWrite:` | 低（替成 ZIPFoundation）|
| **GDataXML** | 解析 version.xml | `nodesForXPath:` | 低 |
| **VoiceConverter** | AMR ↔ WAV | `ConvertAmrToWav:` / `ConvertWavToAmr:` | 高（依赖 opencore-amr fat 库，2014 年打包，新链接器要求 architecture slice align ≥ 2^3，已用 `lipo -segalign 8` 重新打包）|
| **Bugly** | 崩溃监控 | 旧外壳集成但后台账号已废弃实际不起作用 | **新外壳不集成**，改用 Sentry（详见实施设计文档）|
| **极光统计** | 用户统计 | `JANALYTICSService setupWithConfig:` | 低 |
| **RNCachingURLProtocol** | HTTP 资源本地缓存代理 | `NSURLProtocol registerClass:` | 中（新外壳建议改 URLProtocol 或 WKURLSchemeHandler）|

---

## 9. 新外壳实现路线（建议骨架）

> 这一节是建议，不是强制。**必须照抄的契约部分已在 §3–§7。**

### 9.1 推荐技术栈

| 层 | 旧 | 新建议 |
|----|----|--------|
| 语言 | Objective-C MRC | Swift（启 Strict Concurrency / async-await）|
| 最低系统 | iOS 9.0 | iOS 14.0（覆盖 99% 设备，去掉所有 iOS<9 兼容代码）|
| WebView | UIWebView + WKWebView 双轨 | **只用 WKWebView**，旧路径删掉 |
| H5 桥 | WebViewJavascriptBridge（OC 版）| 自实现 WKScriptMessageHandler + 等价 JS 协议；或继续用 WebViewJavascriptBridge Swift 版 |
| 网络 | ASIHTTPRequest + AFNetworking | URLSession + async/await |
| 解压 | ZipArchive | ZIPFoundation |
| 音频转码 | opencore-amr fat 库 | 保留（接收端兼容现网格式）|
| 路由 | UINavigationController + 手写 push | UINavigationController + 单状态机管理 push 节流 |
| 监控 | 无 | 建议接 Sentry（一次性集成，对线上稳定有意义） |

### 9.2 兼容性关卡（按重要性排序）

1. **桥接 JS API 初始化协议**：H5 进入页面后会调用 `WebViewJavascriptBridge` 的 `_setupWebViewJavascriptBridge` 协议 —— 包括 iframe 注入和 console.log 风格的握手。新外壳如果不用 WebViewJavascriptBridge 原版库，**必须 100% 兼容**这套握手。简单做法：直接引入 [marcuswestin/WebViewJavascriptBridge](https://github.com/marcuswestin/WebViewJavascriptBridge) Swift 分支。

2. **handler 名字面一致**：§3 表中所有 handler 名（如 `mediaTypeAudio`、`friendsSharetypeUrlToptitleDescript`）必须 1:1 注册，差一个字符 H5 就失败。

3. **callback 数据结构 1:1**：尤其是 `sharelogin`（7 字段、`Province` 大写 P）、`getphoneinfo`（小写 i）、`getlocationinfo`（9 字段）。

4. **responseCallback 字面字符串**：H5 是否真依赖 `"Response from accreditlogin"` 这类字符串未知。保留以减少风险。

5. **渠道注入目录**：新外壳必须读 11 个空目录名注入值。如果改用 plist 必须等价同步更新打包脚本。

6. **NSNotification 名**：仅 msext 旧外壳内部使用 `enterForeground`/`applicationWillResignActive`（命名错位）。新项目**不是契约边界**，可自由命名（推荐 `.appDidEnterBackground` 等清晰名），只要最终桥事件 `appservice("1"|"2")` 正确即可。

7. **URL Scheme 处理顺序**：QQ → 微信。

8. **首次启动同步解压**：必须落地，否则 H5 进不去（version.xml 不在）。

### 9.3 可以重构的地方

- 旧的 `Bridge.h` JSExport 路径整套删掉
- `RootVC` / `fourviewVC` 删掉
- `threeView` 合并到通用 WebView 容器
- 散落的 `canback_` / `one_Time` / `first_Time` / `Nothere` BOOL 改为单一节流状态机
- `SGGateway` 整套换成 URLSession，签名算法保留（服务端在校验）
- Bugly + 极光的混合监控统一为一种（或都去掉）

### 9.4 不可重构的地方（线上稳定优先）

- 微信 Appsecret 客户端直拼 OAuth：**按 daoqi/CLAUDE.md 原则 B，新外壳应改为后台中转**（`/wechat/login`）。前置依赖：后台需提供该接口（项目方协调）；若后台暂未就绪，新外壳可临时沿用客户端直拼，但应在代码标注 TODO。无论实现路径如何切换，`sharelogin` 7 字段（H5 契约）必须保持一致
- 视频房间 Agora appID 不能换（线上历史用户依赖）
- 七牛 CDN 域名通过 qiniudomain 目录注入：保留机制
- 桥接 handler 名、字段名、字面字符串

---

## 10. 验收检查表

> 验收口径：**只看 H5 表现**。原生内部代码长什么样、用了什么 SDK、用了多少行 Swift——都不审。下表中任意一项表现与现网不一致，即视为契约违反，需要排查；表现一致即视为通过，原生内部实现自由。

新外壳完成后，**在不修改 H5 任何一行代码**的前提下，逐项验证：

### A. 启动
- [ ] 首次安装：1 秒内进入大厅 H5（gamehall.zip 已解压）
- [ ] 重装 + 热启动：< 1 秒进入大厅
- [ ] 渠道注入：把 `msext/channel/xxx` 目录名改成 `yyy`，重打包后大厅显示的渠道 ID 变为 `yyy`
- [ ] iOS 18 / 19 / 26 上启动不卡死
- [ ] **`app_*.js` 4 个文件在沙盒**：`{gamedir}/{gamestart}/` 下 `app_data.js` / `app_battery.js` / `app_network.js` / `app_gamesname.js` 全部存在、`webView.load(...)` 前已落盘（`xcrun simctl get_app_container booted <bundle>` + `cat` 验证）
- [ ] **`window.location` 切到自定义 scheme**（Phase 1.F 起）：H5 console `location.protocol` === `"ylgame:"`、`location.host` === `"h5"`；同样地大厅 + 子游戏 host 必须完全相同（保证 same-origin）
- [ ] **跨页 localStorage 共享**：大厅 H5 `localStorage.setItem('k','v')` → push 子游戏 → 子游戏 `localStorage.getItem('k')` === `'v'`；pop 回大厅数据保留
- [ ] **`app_*` 12 + 1 + 1 + 1 = 15 个全局变量命名 100% 正确**（参 §4.2）：H5 console 检查 `app_version` / `app_gameconfig` / `app_gamedir` / `app_gamestart` / `app_agent` / `app_appversion` / `app_market` / `app_channel` / `app_Launchtype` / `app_getwifisignalLevel` / `app_gamename` / `app_invitationcode` / `app_getbattery` / `app_getnetwork` / `app_gamesname` 全部 `!== undefined`；尤其大小写 `app_Launchtype`（L 大写）/ `app_getwifisignalLevel`（wifi 小写 + signal/Level 区分）/ `app_getbattery`、`app_getnetwork`（带 get 前缀）必须精确
- [ ] **值是实际渠道值而非占位**：H5 console 读 `app_channel` 应为 `ChannelConfig.plist` 的 channel 值（不是 H5 团队 zip 内可能预留的占位字符串）
- [ ] **大厅 vs 子游戏字面差异**：大厅 H5 内 `app_Launchtype === 0`（数字 0，非字符串 `"0"`）、子游戏 H5 内 `app_Launchtype === 1`
- [ ] **不要存在已撤销的变量**：H5 console 检查 `app_gameid` / `app_compareCode` 应为 `undefined`（早期 Contract §4.2 误列，原项目代码字面字符串里不写）
- [ ] **battery / network 变化重写**：模拟器 Features → Battery State 切换电量；reload 后 H5 读 `app_getbattery` 应跟随；同样切换 Network Link Conditioner 验证 `app_getnetwork`（业务期间实时变化由 §3.2 ［2］［3］反向 callback 推送，不依赖文件重写）

### B. 桥接
- [ ] H5 调 `accreditlogin` → 微信弹授权页 → 同意后 H5 收到 `sharelogin` 回调，7 字段完整
- [ ] H5 调 `friendsSharetypeUrlToptitleDescript` （type=1/2/3 各一次）→ 微信分享/朋友圈分享成功 → H5 收到 `sharesuccess({success:"2", type:"1"|"2"})`
- [ ] H5 调 `getphoneInfo` → 收到 `getphoneinfo`，6 字段完整
- [ ] H5 调 `startlocation` → 收到 `getlocationinfo`，9 字段完整
- [ ] H5 调 `srcIsloop({src:"bg.wav", isloop:1})` → 听到背景音；再调 `isloop:-1` 停止
- [ ] H5 调 `mediaTypeAudio({audiourl:"<远程amr>", user:"u123"})` → 听到声音；H5 收到 `gameui_play_voice("u123")` 和 `gameui_stop_voice("u123")`
- [ ] H5 调 `prepareaudio` → 录音 → 放手 → H5 收到 `getaudiourl({audiourl:"<七牛 URL>", time:"<秒数>"})`；在 gameController 内还**额外**收到 `recordSuccess({fileUrl, fileName, fileKey})`
- [ ] H5 调 `gameCopytext` + `gamepastetext` → 正确复制/读取
- [ ] H5 调 `vibrator` / `repeatvibrator` → 设备震动
- [ ] H5 调 `OpenurlTitleData`（大厅 WVJB）→ 打开内嵌弹层；弹层内 H5 调 `settings.finishweb()`（弹层 JSExport）关闭，且弹层内 `settings.backgameData(data)` 能把 data 传回大厅，大厅 H5 收到 `getWebdata`
- [ ] H5 调 `browser` → Safari 打开外链
- [ ] H5 调 `SwitchOverGameData` → push 到子游戏；子游戏内调 `backgameData` → 回到大厅；H5 收到 `getWebdata`
- [ ] **H5 调 `alert("登录失败")`** → 弹出原生 UIAlertController，标题 = appDisplayName，单"确定"按钮（不实现 WKUIDelegate 则 H5 提示完全不弹）
- [ ] **H5 调 `confirm("是否退出")`** → 弹"取消"/"确定"两按钮 → 取消返回 false / 确定返回 true，H5 业务正常分支

### C. 系统事件
- [ ] 切后台 → H5 收到 `appservice("2")`；回前台 → 收到 `appservice("1")` ⚠️ 值错位沿用历史，参 §3.2 `appservice` 值错位说明
- [ ] 电量变化 → H5 收到 `getBattery("0.XX")`
- [ ] 飞行模式开关 → H5 收到 `getnetwork("1")` / `getnetwork("2"|"3")`
- [ ] 摇一摇（startshake==YES 时）→ H5 收到 `shakeEnd`

### D. 视频房间（仅 gameController 子游戏页）
- [ ] H5 `createRoom({pmw, pmh, top, left, width, height, playerid, roomid})` → 多个真机进入同一 `md5(agentinfo+gameinfo+roomid)` 频道，看到本地画像
- [ ] 远端用户首帧解码后，**Native → H5 callback `getVideoinfo(uid 字符串)`** 触发；H5 收到后再调 H5→Native `getVideoinfo({...})` 放置远端窗 → 看到对方画面
- [ ] 接电话 → H5 收到 `phonestate("2")`；挂电话 → 收到 `phonestate("0")`
- [ ] H5 `exitRoom` → Agora `leaveChannel`，房间内其他人看到对方离开

### E. 回归
- [ ] 微信回调命中
- [ ] QQ 回调命中（QQ 必须先判，否则被 WXApi 吞）
- [ ] 截屏分享真实截屏（FuncPublic getImageWithFullScreenshot 等价实现）

---

## 附录 A — 旧桥 JSExport 协议精确签名（仅供参考）

> 这是 iOS<9 路径使用的旧协议，新外壳如最低系统 ≥ iOS 14 可不实现。
> JSExport 默认转换规则把 ObjC selector → JS 方法名，结果与新桥 handler 名一致。

### Bridge.h / JSProtocol（28 方法）

```objc
-(void)backgameData:(NSString *)data;                                            // → JS: backgameData(data)
-(void)opensaoma;                                                                // → JS: opensaoma()
-(void)startlocation:(NSString *)Type;                                           // → JS: startlocation(type)
-(void)prepareaudio;                                                             // → JS: prepareaudio()
-(void)startshake;                                                               // → JS: startshake()
-(void)stopshake;                                                                // → JS: stopshake()
-(int)getcompareCode;                                                            // → JS: getcompareCode()
-(NSString *)getchannelName;                                                     // → JS: getchannelName()
-(double)getbattery;                                                             // → JS: getbattery()
-(int)getnetwork;                                                                // → JS: getnetwork()
-(void)SwitchShake:(NSString *)voice;                                            // → JS: SwitchShake(voice)
-(void)media:(NSString *)url Type:(NSString *)history Audio:(NSString *)userid;  // → JS: mediaTypeAudio(url, history, userid)
-(void)vibrator:(NSString *)time;                                                // → JS: vibrator(time)
-(void)repeatvibrator:(NSString *)repeat;                                        // → JS: repeatvibrator(repeat)
-(void)canclevibrator;                                                           // → JS: canclevibrator()
-(void)friends:(NSString *)one Sharetype:(NSString *)two Url:(NSString *)three Toptitle:(NSString *)four Descript:(NSString *)five;
                                                                                 // → JS: friendsSharetypeUrlToptitleDescript(one,two,three,four,five)
-(void)accreditlogin;                                                            // → JS: accreditlogin()
-(void)browser:(NSString *)url;                                                  // → JS: browser(url)
-(void)src:(NSString *)message Isloop:(NSString *)message2;                      // → JS: srcIsloop(message, message2)
-(void)Switch:(NSString *)type Over:(NSString *)directory Game:(NSString *)downurl Data:(NSString *)data;
                                                                                 // → JS: SwitchOverGameData(type, directory, downurl, data)
-(int)getGameinstall:(NSString *)gamename;                                       // → JS: getGameinstall(gamename)
-(void)getGameplay:(NSString *)jsondata;                                         // → JS: getGameplay(jsondata) [空实现]
-(void)Openurl:(NSString *)url Title:(NSString *)title Data:(NSString *)data;    // → JS: OpenurlTitleData(url, title, data)
-(NSString *)getmarketname;                                                      // → JS: getmarketname()
-(NSString *)gamepastetext;                                                      // → JS: gamepastetext()
-(void)gameCopytext:(NSString *)text;                                            // → JS: gameCopytext(text)
-(NSString *)getOther;                                                           // → JS: getOther()
-(NSString *)getothername:(NSString *)othername;                                 // → JS: getothername(othername)
-(void)voicePlaying:(NSString *)type;                                            // → JS: voicePlaying(type)
-(void)finishweb;                                                                // → JS: finishweb()
```

H5 在旧路径下调用形式：
```javascript
window.settings.mediaTypeAudio(url, type, userid);     // 同步阻塞调用
var network = window.settings.getnetwork();            // 同步返回 int
window.settings.friendsSharetypeUrlToptitleDescript(sharefriend, sharetype, type, webpageUrl, title, description);
```

Native → JS 走 `[JSContext evaluateScript:@"funcName('arg1','arg2')"]`，函数名见 §3.2。

### Bridgetwo.h / JSProtocol（3 方法）— threeView 弹层用

```objc
-(void)backgameData:(NSString *)data;    // 发 backgameDatatwo 通知给父大厅
-(void)browser:(NSString *)url;
-(void)finishweb;                         // pop self
```

---

## 附录 B — 关键文件索引

| 关注点 | 文件 |
|--------|------|
| 启动 | `msext/msext/AppDelegate.m:140-302` |
| 微信/QQ 回调 | `msext/msext/AppDelegate.m:38-64` |
| 大厅（新）| `msext/msext/Class/RootVC/NewRootVC.m` |
| 大厅（旧）| `msext/msext/Class/RootVC/RootVC.m` |
| 子游戏 | `msext/msext/Class/RootVC/gameController.m` |
| 子游戏（旧）| `msext/msext/Class/RootVC/fourviewVC.m` |
| 弹层 | `msext/msext/Class/RootVC/threeView.m` |
| 旧桥协议 | `msext/msext/Class/RootVC/Bridge.h` / `Bridgetwo.h` |
| 网络层 | `msext/msext/Class/SGGateway/SGGateway.m` / `SGGatewayApi.m` |
| 工具/渠道注入 | `msext/msext/Class/Common/FuncPublic.m:1269` (filename:) / `:1089` (getFilePath:) |
| 常量 | `msext/msext/Class/Common/SGDefineInfo.h` / `AppID.m` / `APIKey.h` |
| AMR ↔ WAV | `msext/msext/Class/Common/VoiceConvert/` |
| HTTP 缓存代理 | `msext/msext/Class/webCache/RNCachingURLProtocol.m` |

---

## 附录 C — 与 daoqi/CLAUDE.md 的关系

本文档与 `daoqi/CLAUDE.md` 配套：

- **daoqi/CLAUDE.md** 规定项目协作的两条平行原则（A. H5 适配契约 / B. 原生内部自由重构）
- **本文档（H5-Native-Contract.md）** 是原则 A 的细化：H5 ↔ 原生 所有黑盒可观察行为的精确契约
- **H5-Native-Implementation-Design.md** 是原则 B 的细化：原生内部架构、模块、技术选型

三份文档**自成体系，不依赖父目录或其它仓库的任何文件**。如在工作环境中看到上级目录另有 CLAUDE.md，请忽略，以本仓库的 CLAUDE.md 为唯一权威（详见 daoqi/CLAUDE.md "适用范围声明"）。

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