Files
youle_app_ios_v2/docs/Verification-Checklist.md
joywayerandClaude Opus 4.7 83d9502dee ADR-009 凭证集中化:AppSecrets.plist + 七牛运行参数走 RemoteConfig.audio_*(契约影响)
【数据流改造(三分制)】
- 微信 AppID  唯一源 = Info.plist CFBundleURLTypes (URLName=weixin first scheme)
- 应用级凭证 唯一源 = AppSecrets.plist (wxAppSecret/qiniuAccessKey/qiniuSecretKey)
- 七牛运行参数 唯一源 = RemoteConfig 顶层 audio_domain / audio_bucket(远端动态注入)

【契约影响】
- ChannelConfig.plist:11 key → 10 key,移除 qiniudomain(ADR-007 守护规则同步)
- BundleConfig:删除 qiniuDomain 属性
- RemoteConfig:顶层新增可选字段 audioDomain / audioBucket(JSON snake_case 自动归一化)
- 启动期:WebContainerViewController parsed 分支校验 audio_domain/audio_bucket 非空,
  缺失抛 BootError.audioConfigMissing 弹 modal 永停(与 showmessage 同等致命)
- 上线前置:测试 / 生产远端 .txt 配置必须先补 audio_domain / audio_bucket 两个顶层 key
- WeChatSDK.appID / WeChatAuth.appSecret / QiniuConfig.* 调用方零签名变化

【新增】
- ylgamehall/Resources/AppSecrets.plist(3 key)
- ylgamehall/Source/Resource/AppSecrets.swift(单例加载,对齐 BundleConfig 模式)
- QiniuConfig 改 actor:cdnDomain/bucketName 进 actor 状态 + update(...) async;
  accessKey/secretKey 仍 nonisolated(直接读 AppSecrets)
- QiniuTokenSigner.uploadToken() 改 async(bucketName 来自 actor)
- QiniuUploader 预取 cdnDomain 闭包外,SDK 同步 callback 内直接拼 URL

【删除】
- WeChatSDK.swift  static let appID  硬编码 → Info.plist 启动期解析
- WeChatAuth.swift static let appSecret 硬编码 → AppSecrets.shared.wxAppSecret
- QiniuConfig 中 accessKey / secretKey / bucketName / cdnDomain 四处硬编码
- ChannelConfig.plist 的 qiniudomain 字段(plist 与代码双源僵尸字段)

【文档同步】
- Plan:新增 ADR-009 + ADR-007 守护规则改 10 key + §236 BundleConfig 描述
- Design §3.4 多处 "11 项" → "10 项";§7.0.3 plist 示例 + BundleConfig 代码骨架
  + Scripts/inject_channel.sh 同步
- SDK-Integration-Guide §0 凭证位置改三分制 + §尾"七牛域名读取"加新外壳路径
- Verification-Checklist L69 "(11 项)" → "(10 项)"

参考契约章节:docs/Development-Plan.md ADR-009、docs/H5-Native-Implementation-Design.md §7.0.3
BuildProject 通过,Xcode 即时诊断 0 警告。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 21:07:55 +08:00

418 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 功能验证清单(开发期累积,统一验收)
> **定位**:各 Phase 实施期间陆续累积的验证项目,本文档**不是契约**(契约在 `H5-Native-Contract.md` §10),而是面向开发者的**指导步骤**——告诉验证者"应该做什么操作、看到什么现象、用什么工具"。
>
> **使用方式**
> - 每完成一个 Phase 子项 → 把对应验证步骤勾掉
> - **不要在 Phase 进行中频繁手动验证**,避免反复重装 app 浪费时间
> - 多个 Phase 累积后**统一跑一次**完整验证清单
> - 与 Contract §10 契约验收清单**并行**Contract §10 是黑盒契约边界("必须长这样"),本文档是**怎么验证它长这样**的操作手册
---
## 通用准备
### 环境
| 工具 | 用途 | 启用方式 |
|------|------|---------|
| Xcode console | 看原生 os.log 输出 | Xcode 底部输出窗口 |
| Safari Web Inspector | 看 H5 端 JS console / DOM / Network | macOS Safari → 开发菜单 → Simulator / 真机 → 当前 H5 页面 |
| 模拟器 Features 菜单 | 切换 Battery State / Network Link Conditioner / Toggle In-Call Status | Simulator → Features |
| 真机 Safari 调试 | 真机 H5 网页检查器 | iPhone → 设置 → Safari → 高级 → 网页检查器 = ON |
### 重装 app(每次跑完整验证前都做一遍)
按 CLAUDE.md「typical case 1launch snapshot 缓存粘滞」教训:
- 模拟器/真机:**长按 app → 删除** → Xcode 重新 Run
- 或:`xcrun simctl uninstall booted com.skyapp.ylgamehall` 然后 Xcode Run
- 这样可以清掉 iOS sandbox 内的 launch snapshot 缓存,避免改了 Info.plist / LaunchScreen 后还是看到旧版本
### 沙盒访问
```bash
xcrun simctl get_app_container booted com.skyapp.ylgamehall data
# 输出形如 /Users/.../Containers/Data/Application/<UUID>/
```
### 真机 vs 模拟器跑哪些
模拟器(M Mac 当前)能跑的:Phase 1 / 2 / 3.A / 6 / 7 / 9.2 / 部分 4QQ/抖音 URL Scheme 调起需要设备装 app 才有意义)。
**必须真机的章节**(M Mac 模拟器跑不起来或行为不可靠):
| 章节 | 原因 |
|---|---|
| §J / §K / §O 微信 | 微信 SDK 在模拟器无法真实拉起微信 app |
| §L / §M / §N SharePanel + QQ + 抖音 | 模拟器没装 QQ / 抖音 appURL Scheme 调起失败 |
| §P 高德定位 | AMap fat .framework 在 M Mac 模拟器链接失败(参 `Vendor/AMap/README.md`+ 模拟器定位精度低 |
| §T phonestate | CXCallObserver 在模拟器无效,必须真机 |
| §I.5.3 七牛上传真机 | 上传 URL 公网可达验证 |
建议路径:先在模拟器跑完 Phase 1 / 2 / 6 / 7 / 9.2 大头(覆盖率最高),再单独真机跑 §J~§T 一遍。
---
## Phase 1 — 启动闭环
### A. 启动图 + LaunchScreen
- [ ] **A.1 启动图方向正确**:横屏设备启动瞬间看到完整 logo + "快乐游戏 / 健康生活" 文字,画面正向(不是横躺)
- [ ] **A.2 状态栏方向**:电池/时间在画面顶部居中横向,**不在**画面左侧竖向(竖向 = iOS 错误地按 portrait 渲染 LaunchScreen,参 CLAUDE.md「典型案例 1」)
- [ ] **A.3 黑边合理**:比 16:9 更宽的 iPhone(如 16 Pro)启动图两侧补黑边可接受;iPad 4:3 上下补黑边可接受
### B. 启动流水线时序
预期 Xcode console 顺序:
```
[BundleConfig] 渠道注入读取结果(10 项)
[SandboxPaths] 路径计算结果
[ResourceUnzipper] ensureReady 完成
[RemoteConfigClient] 拉取成功 .parsed
[VersionResolver] 单链 4 层 fallback 结果
[LocalVersionReader] 本地 vs 远端对比
[LobbyZipUpgrader] 无需升级 / 升级完成 N → M
[lobby] write app_data.js → ... ← Phase 1.7 落地(AppDataWriter
[lobby] write app_gamesname.js → ...
[lobby] write app_battery.js: app_getbattery=0.XX → ...
[lobby] write app_network.js: app_getnetwork=2 → ...
```
- [ ] **B.1 启动一气呵成**splash 显示 → "拉取配置中" → ("下载更新中 XX%") → "加载大厅..." → splash 0.3s 淡出,看到 H5 大厅
- [ ] **B.2 16:9 letterbox 黑边**:H5 大厅在屏幕中间,左右(或上下)有黑边,画面比例 16:9
- [ ] **B.3 启动总耗时 < 2s**(无升级路径),有升级时进度条实时更新
### C. `app_*.js` 文件落盘 + 全局变量命名
```bash
# 进入沙盒看 4 个文件
xcrun simctl get_app_container booted com.skyapp.ylgamehall data
# 然后 cat 4 个文件
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
```
- [ ] **C.1 4 个文件全部存在**且内容非空
- [ ] **C.2 app_data.js 内 12 个 var 命名 100% 正确**(特别注意大小写:`app_Launchtype` L 大写、`app_getwifisignalLevel` wifi 小写 + signal/Level 区分)
- [ ] **C.3 app_battery.js 变量名带 get**`var app_getbattery=N;`(不是 `app_battery`
- [ ] **C.4 app_network.js 变量名带 get**`var app_getnetwork=N;`
- [ ] **C.5 app_gamesname.js 沿用 msext 字面**`var app_gamesname=new Array('xxx');`var 后两个空格 + new Array(...)
- [ ] **C.6 不存在已撤销变量**`app_data.js` 内没有 `app_gameid` / `app_compareCode`(早期 Contract 误列,原 msext 不写)
### D. `window.app_*` H5 console 对账
Safari Web Inspector → Console
```js
console.log("app_gameconfig =", app_gameconfig); // 期望 ChannelConfig.plist gameconfig 实际值
console.log("app_market =", app_market); // 期望 "2"
console.log("app_agent =", app_agent); // 期望 ChannelConfig.plist agent 实际值
console.log("app_channel =", app_channel);
console.log("app_Launchtype =", app_Launchtype); // 大厅期望 0
console.log("app_appversion =", app_appversion); // 期望 '0' 字符串(审核切换标志非版本号)
console.log("app_getbattery =", app_getbattery); // 期望 0.XX
console.log("app_getnetwork =", app_getnetwork); // 期望 1/2/3
console.log("app_gamesname =", app_gamesname); // 期望 Array
console.log("app_gameid =", app_gameid); // 期望 undefined ✓
console.log("app_compareCode =", app_compareCode); // 期望 undefined ✓
```
- [ ] **D.1 所有 15 个 app_* 全局变量都 `!== undefined`**
- [ ] **D.2 关键字段都是 ChannelConfig.plist 的实际值**(不是 H5 zip 默认占位)
- [ ] **D.3 `app_appversion === '0'`**(审核标志,正常业务);`app_gameconfig` 取 BundleConfig.gameConfig
---
## Phase 2 — 大厅 handler + 反向 callback
### E. H5 → Native handler 响应
每项的验证方式:在 H5 console 调对应 `bridge.callHandler('xxx', data, function(resp){console.log(resp);})`,看 Xcode console 有无副作用 + H5 收到 cb。
- [ ] **E.1 vibrator** → 真机震动 + H5 cb "vibrator"
- [ ] **E.2 repeatvibrator** → 真机震动 + cb "repeatvibrator"
- [ ] **E.3 canclevibrator** → cb "canclevibrator"(无可感知副作用,正常)
- [ ] **E.4 gameCopytext('test123')** → 系统剪贴板含 "test123";其他 app 粘贴可验证;iOS 14+ 会出"已粘贴自 ylgamehall" banner(平台行为非 bug
- [ ] **E.5 gamepastetext** → cb 是当前剪贴板内容(不是字面 "gamepastetext"
- [ ] **E.6 SwitchShake(1) + SwitchShake(0)** → 内部 canVoice 状态切换(无可视化副作用,cb "SwitchShake"
- [ ] **E.7 startshake → 摇一摇手机 → H5 收到 `shakeEnd` 反向 callback**`bridge.registerHandler('shakeEnd', fn)` 先注册)
- [ ] **E.8 stopshake → 摇一摇 → H5 不再收到 shakeEnd**
- [ ] **E.9 voicePlaying(1) / voicePlaying(0)** → 内部 actor VoiceCenter 开关位(cb "Response from voicePlaying");mediaTypeAudio 实现后效果可验证
- [ ] **E.10 getphoneInfo** → cb "getphoneInfo"**大写 I**+ 反向 callback `getphoneinfo`**小写 i**)含 6 字段(PhoneAdresseMAC / PhoneDeviceBrand / PhoneIMEI / PhoneModel / PhoneProvidersName / PhoneVersion
- [ ] **E.11 browser('https://www.apple.com')** → Safari 打开苹果首页
- [ ] **E.12 opensaoma** → cb "opensaoma"(空实现,无可感知副作用,符合契约)
- [ ] **E.13 startlocation** → cb "startlocation"Phase 2 stub,无定位真实业务;Phase 5 升级后再验真实定位)
### F. Native → H5 反向 callback
H5 需先用 `bridge.registerHandler('callbackName', fn)` 注册:
- [ ] **F.1 模拟器 Features → Battery State 切换** → H5 收到 `getBattery` "0.XX" + `window.app_getbattery` 实时跟随
- [ ] **F.2 模拟器 Features → Network Link Conditioner 切换 100% Loss** → H5 收到 `getnetwork` "1" + `window.app_getnetwork === 1`;切回 WiFi → 收 "2";切 3G → 收 "3"
- [ ] **F.3 App 切后台**(Home 键 / 上滑)→ H5 收到 `appservice` "2";回前台 → 收 "1" ⚠️ 值错位沿用 msext WKWebView 路径历史,参 Contract.md §3.2
- [ ] **F.4 摇一摇** → shakeEnd(见 E.7
- [ ] **F.5 getphoneInfo 触发 getphoneinfo**(见 E.10
### G. ExternalSubscriptions 生命周期
> Phase 2.C 已实现 setup/teardown 配对,但 Phase 6 子游戏容器才能完整验证"防双发"。当前只验证基本生命周期。
- [ ] **G.1 viewWillAppear setup**app 启动后 onChange 闭包都已挂钩(F.1-F.4 都生效)
- [ ] **G.2 viewWillDisappear teardown**:手动触发场景待 Phase 6 子游戏 push 上来时验证
---
## Phase 3 — 音频体系(部分实现:3.A 完成;3.B/C/D 待外部资源)
### H. 本地音频(srcIsloop3.A 完成)
- [ ] **E.14 srcIsloop({src:"xxx.wav", isloop:0})** → 听到单次按钮音;cb "Response from srcIsloop"
- [ ] **E.15 srcIsloop({src:"bg.wav", isloop:1})** → 听到背景循环音 + 同时调 srcIsloop({src:"另一首.wav", isloop:1}) → 替换为新背景循环
- [ ] **E.16 srcIsloop({src:"bg.wav", isloop:-1})** → 停止 bg.wav 背景;如果当前背景不是 bg.wav 则无效(与 msext 同名 type 校验)
> **依赖**:测试需要 H5 zip 内 `assets/wav/` 目录有可用音频文件。
> H5 团队 / 项目方提供后做。
### I. prepareaudio / mediaTypeAudio stub3.B/3.C/3.D 待 opencore-amr 接入)
- [ ] **E.17 prepareaudio** → cb "Response from prepareaudio"stub 仅 cb,无录音 / 无 getaudiourl 反向)
- [ ] **E.18 mediaTypeAudio** → cb "Response from mediaTypeAudio"stub 仅 cb,无下载 / 无播放 / 无 gameui_play_voice 反向)
> 3.B AMR 转码 + 3.C 录音 + 3.D 远端播放完整实现后升级(参 SDK-Integration-Guide.md §C,需用户手动配置 opencore-amr ObjC 源码)。
### I.5 七牛 token 自签 + 上传单元验证(3.6 已落地,依赖 SDK 接入)
- [ ] **I.5.1 token 字符串结构正确**:在 Xcode console 或 Swift Playground 调
```swift
let token = QiniuTokenSigner.uploadToken(key: "test/foo.amr")
print(token)
```
应输出 `AccessKey:encodedSign:encodedPolicy` 三段(用 `:` 分隔,无空格);中间段是 base64url(含 `-_`、无 `+/=`
- [ ] **I.5.2 putPolicy 反序列化合法**:取出第三段 base64url decode → JSON `{"scope": "iosaudio:test/foo.amr", "deadline": <future-unix-ts>}`deadline 应为约 1 小时后
- [ ] **I.5.3 真机上传**:临时写一段 await `QiniuUploader.shared.upload(<本地小文件>, timeSec: 3)` → 拿到 `UploadedFile.fileUrl` → 浏览器粘贴 URL 能直接下载内容
---
## Phase 4 — 分享 + 登录(已落地)
> **真机要求**:微信 / QQ / 抖音 SDK + URL Scheme 调起在模拟器都不可靠,必须真机。
### J. 微信授权登录(4.B 已落地)
H5 console 先注册 callback
```js
bridge.registerHandler('sharelogin', function(d){ console.log('sharelogin', d); });
```
然后调:
```js
bridge.callHandler('accreditlogin', null, function(r){ console.log('cb', r); });
```
- [ ] **E.19 accreditlogin** → 拉起微信授权 → 同意 → 大厅 H5 收到 `sharelogin({ openid, headimgurl, nickname, sex, city, Province, unionid })`
- [ ] **E.19.1 关键契约**payload 内 `Province` **大写 P**(不是 `province` 小写——与 Phase 5 startlocation 的 province 小写形成对照)
- [ ] **E.19.2 容错**:用户取消授权 → 不发 shareloginmsext 同款乐观策略)
- [ ] **E.19.3 未装微信**H5 alert 提示"请安装微信"(具体文案由 H5 决定,原生只保证不崩)
- [ ] **E.19.4 SceneDelegate 回调命中**app 切到微信 → 授权 → 切回 ylgamehall → 立刻收 sharelogin(不是过 N 秒)
### K. 微信链接分享 type=14.E 已落地)
H5 注册 + 调用:
```js
bridge.registerHandler('sharesuccess', function(d){ console.log('sharesuccess', d); });
bridge.callHandler('friendsSharetypeUrlToptitleDescript', {
sharefriend: "2", // "2" 直接走微信朋友圈,"1" 弹 SharePanel
sharetype: "1",
type: "1", // 链接
webpageUrl: "https://www.apple.com",
title: "测试标题",
description: "测试描述"
}, null);
```
- [ ] **E.20 sharefriend=2 type=1** → 直接拉起微信朋友圈分享面板,标题/描述/链接正确
- [ ] **E.20.1 sharesuccess 反向**:分享成功或取消都收 `sharesuccess({success:"2", type:"2"})`type 是原 sharefriend);notInstalled / failed 不发(乐观策略)
### L. SharePanel 三选一面板(4.B 已落地)
调用:
```js
bridge.callHandler('friendsSharetypeUrlToptitleDescript', { sharefriend:"1", ... }, null);
```
- [ ] **L.1** 底部弹出半透明面板,3 个按钮:微信 / QQ / 抖音
- [ ] **L.2** 0.25s 滑入/滑出动画
- [ ] **L.3** 半透明背景点击 → 关闭面板(不分享)
- [ ] **L.4** 选微信 → 走 §K 路径;选 QQ → 走 §M;选抖音 → 走 §N
- [ ] **L.5** 平台未安装 → 按钮置灰可点但触发后无副作用(与 msext 一致)
### M. QQ URL Scheme 分享 type=14.C 已落地,无 SDK
- [ ] **M.1 QQ 已安装** → `mqqapi://share/to_fri?...` 调起 QQ app 选好友页
- [ ] **M.2 朋友圈**sharefriend=2(朋友圈)→ 走 `mqqapi://share/to_qzone?...`
- [ ] **M.3 调起即 success**URL Scheme 单向无返回,不发 sharesuccessmsext 乐观策略)
- [ ] **M.4 encode 正确**:含中文 / 空格的 title / desc 经 RFC 3986 percent encoding(与 msext line 1262-1268 等价)
### N. 抖音 URL Scheme + Photos 分享(4.D 已落地,无 SDK
- [ ] **N.1 截图分享(type=1/2 退化为截屏)**:抖音已装 → 系统首次弹出相册写入权限弹窗 → 同意 → 截图存进相册 + 调起抖音 → 用户在抖音里选图发布
- [ ] **N.2 远端图(type≠1,2**:下载 URL → 存相册 + 调起抖音
- [ ] **N.3 PhotoLibrary 拒绝权限**H5 alert 提示(具体文案由 H5
- [ ] **N.4 抖音未安装**:尝试 4 个 URL Schemecamera / publish / share/image / 基础)都失败 → 不报错(乐观)
### O. 微信截图 / 远端图分享(4.F 已落地,commit 8e4c36d
H5 调用(截图 type=2):
```js
bridge.callHandler('friendsSharetypeUrlToptitleDescript', {
sharefriend:"2", sharetype:"1", type:"2",
webpageUrl:"", title:"", description:""
}, null);
```
- [ ] **O.1 type=2 截图**:当前 H5 整屏被截 → JPEG 0.6 大图 + 0.4x 缩略图 → 拉起微信朋友圈分享,看到截图缩略图 + 大图
- [ ] **O.2 type=3 远端图**:传 `type:"3", webpageUrl:"http://xxx/qr.png"` → 下载远端图 → 微信分享带远端图(msext 公众号二维码分享场景)
- [ ] **O.3 thumb ≤ 32KB 自动压缩**:分享调起时不报"缩略图过大"错(微信 SDK 内部 setThumbImage 自动压缩,外层保证 0.4x 尺寸够小)
- [ ] **O.4 errCode 处理**:用户在微信侧取消 → 收 cancellederrCode -2);其它 errCode → failed
- [ ] **O.5 关键回归**type=1 链接分享仍正常(O 不该影响 §K)
---
## Phase 5 — 高德定位(已落地)
> **真机要求**fat .framework 在 M Mac 模拟器链接失败(参 `Vendor/AMap/README.md` 「已知限制」),所有定位验证只能跑真机。
### P. startlocation 真实路径
H5 注册 + 调用:
```js
bridge.registerHandler('getlocationinfo', function(d){ console.log('loc', d); });
bridge.callHandler('startlocation', null, function(r){ console.log('cb', r); });
```
- [ ] **P.1 首次启动权限弹窗**:iOS 系统弹"是否允许访问位置",文案是中文 `进贤聚友棋牌需要访问您的位置以提供本地化服务`
- [ ] **P.2 同意权限** → H5 收到 `getlocationinfo` 包含 **9 字段**
- `latitude` string 类型,`String(format:"%f")` 产物)
- `longitude` string,同上)
- `altitude`
- `country` / `province` / `city` / `district` / `street` / `streetNumber`**province 小写 p**,与 §J sharelogin 的 Province 大写 P 对照)
- [ ] **P.3 拒绝权限** → H5 收到 `getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`
- [ ] **P.4 飞行模式 / 无网络** → 高德 SDK 返回错误 → H5 收 errorCode 12(或其它非 0
---
## Phase 6 — 子游戏(已落地)
### Q. SwitchOverGameData → 子游戏 push + backgameData → pop
H5 调用:
```js
bridge.callHandler('SwitchOverGameData', { gameid:"<sub_id>", ... }, null);
```
- [ ] **Q.1 push 子游戏**:大厅 → push 出新 VC 加载子游戏 H5;过程中 splash + "进入子游戏..." 进度
- [ ] **Q.2 2s 节流**:连续点 SwitchOverGameData 两次(间隔 < 2s)→ 第二次被忽略(AppCoordinator lastSwitchAt 守门)
- [ ] **Q.3 栈深约束**:连续 push 3 个子游戏 → 第 3 次被拒(viewControllers.count < 2 限制)
- [ ] **Q.4 backgameData 回大厅**:子游戏 H5 调 `bridge.callHandler('backgameData', "transfer-payload", null)` → pop 回大厅 + 大厅 H5 收到 `getWebdata("transfer-payload")`
- [ ] **Q.5 backgameData 对象入参**:传 `{x:1, y:"a"}` 而非字符串 → 大厅 getWebdata 收到 JSON 序列化字符串
- [ ] **Q.6 ExternalSubscriptions teardown 防双发**:子游戏 push 上来时大厅 viewWillDisappear 解绑 Monitor 闭包 → 模拟器切电池/网络 → H5 console 看大厅与子游戏各自只收一次(不是双发)
- [ ] **Q.7 SubGameDownloader 缓存命中**:第一次进子游戏看到下载进度;第二次进同一子游戏跳过下载(gameVersion 一致)
- [ ] **Q.8 SubGameDownloader 升级**:远端 gameVersion 提升 → 第二次进重新下载并原子 rename 替换
---
## Phase 7 — 弹层 Overlay(已落地)
### R. OpenurlTitleData 弹层 + 三个 settings
H5 调用:
```js
bridge.callHandler('OpenurlTitleData', { url:"https://www.apple.com", "title ":"标题(注意末尾空格)" }, null);
```
- [ ] **R.1 弹层 push**:大厅栈深 + 1,全屏加载外链页(黑底 + 标题栏)
- [ ] **R.2 入参 "title " 末尾空格**H5 console 调时 key 必须带空格(契约 §3.1 [27]);少空格则原生收不到 title fall back 默认值
- [ ] **R.3 3s 节流**:连续调 OpenurlTitleData 两次(< 3s)→ 第二次拒(AppCoordinator 守门)
- [ ] **R.4 window.settings.finishweb()**:在弹层 H5 console 调 → pop 回大厅,不带数据
- [ ] **R.5 window.settings.backgameData("payload")**pop 回大厅 + 大厅 H5 收 `getWebdata("payload")`(与 Q.4 同链路)
- [ ] **R.6 window.settings.browser("https://apple.com")**Safari 打开 URL(不退出 ylgamehall
- [ ] **R.7 Cookie 隔离**:弹层登录某第三方网站 → 弹层 pop 后 → 主大厅 H5 不带这个 cookieWKWebsiteDataStore.nonPersistent 隔离)
- [ ] **R.8 子游戏 → 弹层 → backgameData → 子游戏**:从子游戏 push overlay → overlay 调 backgameData → pop 回子游戏 + 子游戏 H5 收到 getWebdata(覆盖 .subGameDidReturn 通知链)
---
## Phase 8 — 视频房间 stub + phonestate(已落地)
### S. 视频房间 3 件套 stub
- [ ] **S.1 createRoom** → cb "createRoom"(无真实 Agora 加入)
- [ ] **S.2 getVideoinfoH5→Native** → cb "getVideoinfo"(无真实排版)
- [ ] **S.3 exitRoom** → cb "exitRoom"(无真实 leaveChannel
- [ ] **S.4 大厅误调 createRoom**:契约规定大厅不注册 → bridge handler not foundH5 console 看到错误信号(不该崩)
### T. phonestatePhoneStateMonitor,仅子游戏订阅)
H5 在子游戏 console
```js
bridge.registerHandler('phonestate', function(d){ console.log('phonestate', d); });
```
- [ ] **T.1 子游戏内来电响铃**:模拟器 Features → Toggle In-Call Status 或真机来电 → H5 收 `phonestate("2")`
- [ ] **T.2 接通通话** → H5 收 `phonestate("2")`hasConnected=true 分支)
- [ ] **T.3 挂断** → H5 收 `phonestate("0")`
- [ ] **T.4 大厅页不订阅**:在大厅期间来电 → 大厅 H5 **不**收 phonestate(仅 SubGameViewController 注册)
- [ ] **T.5 子游戏 pop 后停止订阅**:子游戏返回大厅 → 期间来电 → 大厅 H5 不收(PhoneStateMonitor.stop() 已 detach
---
## Phase 9 — H5ErrorRelay9.2 已落地)
### U. H5 错误中继到原生 print
- [ ] **U.1 H5 console.error**Safari Web Inspector → 大厅 console 执行 `console.error("test", 123, {a:1})` → Xcode console 看到 `[H5 console.error] test 123 [object Object]`
- [ ] **U.2 H5 onerror**H5 console 执行 `undefinedVar.foo` → Xcode console 看到 `[H5 onerror] ReferenceError: ... at xxx.js:N:N` + stack
- [ ] **U.3 H5 unhandledrejection**H5 console 执行 `Promise.reject("boom")` → Xcode console 看到 `[H5 unhandledrejection] boom`
- [ ] **U.4 仅大厅+子游戏拦截**:弹层 OverlayViewController 内执行 console.error → Xcode console **不**看到(Overlay 不挂 H5ErrorRelay
- [ ] **U.5 原始函数链未断**H5 业务的 console.error 仍能在 Safari Web Inspector 里看到(hook 保留 origErr.apply
---
## 附:契约 §10 26 项验收清单映射
本文档每个章节对应 Contract §10 的 A/B/C/D/E 五节:
| 本文档章节 | Contract §10 节 | 状态 |
|---|---|---|
| A. 启动图 + LaunchScreen | §A 启动(启动相关) | Phase 1 ✅ |
| B. 启动流水线时序 | §A 启动 | Phase 1 ✅ |
| C. app_*.js 落盘 + 命名 | §A 启动 | Phase 1 ✅ |
| D. window.app_* H5 console | §A 启动 + §B 桥接 | Phase 1 ✅ |
| E. H5→Native handler | §B 桥接 | Phase 2 ✅ |
| F. 反向 callback | §C 系统事件 + §B 桥接 | Phase 2 ✅ |
| G. 生命周期(防双发) | Design §2.4.2 | Phase 2 ✅ / Phase 6 联动 |
| H. 本地音频 srcIsloop | §B 桥接 | Phase 3.A ✅ |
| I. 录音 / 远端语音 stub | §B 桥接 | Phase 3.B/C/D ⏳(待 opencore-amr|
| I.5. 七牛 token + 上传 | §B 桥接 | Phase 3.6 ✅(链路完整,需 SDK 接入后真机验) |
| J. 微信授权 sharelogin | §B + §E 回归 | Phase 4.B ✅ |
| K. 微信链接分享 type=1 | §B + §E | Phase 4.E ✅ |
| L. SharePanel 三选一 | §B | Phase 4.B ✅ |
| M. QQ URL Scheme 分享 | §B + §E | Phase 4.C ✅ |
| N. 抖音 URL Scheme + Photos | §B + §E | Phase 4.D ✅ |
| O. 微信截图 / 远端图 type=2/3 | §B + §E | Phase 4.F ✅ |
| P. 高德定位 9 字段 | §B 桥接 | Phase 5 ✅(**仅真机**|
| Q. 子游戏 push / backgameData | §B + §D | Phase 6 ✅ |
| R. 弹层 OpenurlTitleData / settings 3 件 | §B + §D | Phase 7 ✅ |
| S. 视频房间 stub | §B + §D 视频 | Phase 8.1-8.3 ✅(stub 满足契约边界)|
| T. phonestate(来电/挂断)| §C 系统事件 | Phase 8.4 ✅(**仅真机** CXCallObserver|
| U. H5ErrorRelay | Design §11.4,原则 B 内部加强)| Phase 9.2 ✅ |
---
## 维护规则
- 每完成一个 Phase 子项 → 在对应章节补 / 勾验证步骤
- **不要把验证步骤复制到 Contract §10**Contract §10 是契约边界("必须长这样"),本文档是验证操作手册("怎么验证它长这样"),两份保持独立
- 验证发现的 bug → 修复后 commit 时在 message 引用本文档对应 checkbox 编号(如 "修 E.10 getphoneInfo 大小写错"