Compare commits
5
Commits
8a7d543cdc
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707abcb9d3 | ||
|
|
7fe089577b | ||
|
|
f4acccdd6c | ||
|
|
1203957924 | ||
|
|
3f8b382ad3 |
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: daoqi-lessons
|
||||
description: 从原项目 daoqi/msext 排查出来的典型案例存档(LaunchScreen 启动图方向错误、H5 与原生通讯接口的真实路径)。在处理启动图 / LaunchScreen / 启动方向问题,或处理 H5 读取渠道值、app_*.js 变量、window.settings polyfill 相关问题时阅读。
|
||||
---
|
||||
|
||||
# 原项目 daoqi 排查案例存档
|
||||
|
||||
> 这些是从 `../daoqi/` 实际排查出来的完整案例,佐证 CLAUDE.md「原项目 daoqi:遇到问题时的参考来源」一节的方法论。
|
||||
> 注意:`典型案例:app_* 注入时序问题(原则 A 不可妥协)` 仍保留在 CLAUDE.md 正文中,因为它是原则 A 的规范性说明,必须常驻。
|
||||
|
||||
## 典型案例:LaunchScreen orientation(启动图方向错误)
|
||||
|
||||
- **症状**:新项目启动图渲染成竖向矩形再被整体旋转 90°,画面横躺在中间
|
||||
- **错误做法**:凭 Xcode 26 文档推测、纯调整 storyboard / contentMode / 反复换素材
|
||||
- **去原项目找答案**:`daoqi/msext/Info.plist` + `msext.xcodeproj/project.pbxproj`
|
||||
- 发现 1:Info.plist 用的是**顶层无后缀的 `UISupportedInterfaceOrientations`**(值 = landscape),**没有** `~iphone` / `~ipad` 后缀变体
|
||||
- 发现 2:根本**没有** `UILaunchStoryboardName`,用的是 `ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = "LaunchImage-1"`(iOS 8 时代的 LaunchImage Asset Catalog 机制)
|
||||
- **原项目为什么稳**:
|
||||
- 它的所有 LaunchImage PNG(`Default-568h@2x` / `LaunchImage-1-800-667h@2x` 等)**物理像素都是竖向、画面内容横躺**
|
||||
- iOS 8 LaunchImage Asset Catalog 内置"按 device idiom + orientation 自动选图 + 系统级 90° 旋转"魔法,会读 Info.plist 顶层 `UISupportedInterfaceOrientations` 决定是否旋转 portrait 资源
|
||||
- 因此一张物理 portrait 的 PNG 在横屏设备上能被正确旋转后铺满
|
||||
- **不能照搬到新项目**:
|
||||
- LaunchImage Asset Catalog 在 iOS 14+ 已 deprecated,新项目按苹果推荐用 `LaunchScreen.storyboard`
|
||||
- LaunchScreen.storyboard **没有自动旋转 portrait 资源**的魔法,UIImageView 直接渲染 PNG 物理像素
|
||||
- 把 `docs/res/Res/Default-568h@2x` 这类**物理 portrait + 画面横躺**的 msext 残留素材直接塞进 LaunchScreen.storyboard 就会看到躺倒画面
|
||||
- **新项目(LaunchScreen.storyboard)的正确组合**:
|
||||
1. **素材必须物理像素就是横屏的**(必要时用 `sips -r -90 -s format png` 一次性把 msext 残留 portrait 素材逆时针 90° 输出为标准 PNG,物理像素正确)
|
||||
2. **Info.plist 显式补一份无后缀 `UISupportedInterfaceOrientations = landscape`**(Xcode 26 General → Deployment Info UI 只写带后缀的 `~iphone` / `~ipad`,少了无后缀 key 会让 LaunchScreen 阶段——device idiom 尚未识别的早期窗口——fallback 到 portrait 渲染再被旋转)
|
||||
3. UIImageView `contentMode = scaleAspectFit` + 黑底(比 16:9 更宽的现代横屏设备左右补黑边,保画面完整不裁切 logo)
|
||||
- **iOS launch snapshot 缓存粘滞(开发期 iteration 坑)**:
|
||||
- iOS 为加速启动,会把首次渲染的 LaunchScreen 缓存为 PNG snapshot 存到 app sandbox(`Library/Caches/Snapshots/<bundleID>/com.apple.UIKit.SplashBoard/`),后续启动不重渲染
|
||||
- **Xcode 覆盖安装 .app 不会清 sandbox 缓存**,所以改了 Info.plist / LaunchScreen.storyboard / 启动图素材后,Run 出来仍可能是旧 snapshot
|
||||
- 改完启动相关任意一项**必须做**:长按 app → 删除 → Xcode 重新 Run;或模拟器 Erase All Content and Settings;或 `xcrun simctl uninstall booted <bundleID>`
|
||||
- **终端用户不受影响**:从 IPA 首次安装、或升级新 IPA 第一次启动后即被 iOS 自动刷新;只是开发期反复迭代同一台 device 时会被迷惑
|
||||
|
||||
## 典型案例:H5 与原生通讯接口的真实路径
|
||||
|
||||
- **症状**:Design / Contract 文档描述 H5 用 `window.settings.getXxx()` 同步函数读渠道值(如 `getothername` / `getchannelName`);按文档实现完 polyfill 后 H5 业务行为不对,user 反馈"H5 读不到数据"
|
||||
- **错误做法**:基于 Contract §附录 A 列出的旧桥 JSExport 28 方法实现 polyfill,把 9 项 getter 注入到 `window.settings`,认为这是 H5 端的主路径
|
||||
- **去原项目找答案**:grep `var app_` 在 `daoqi/msext/Class/RootVC/NewRootVC.m:1204` / `gameController.m:2160` / `AppDelegate.m:259`
|
||||
- 发现 1:原项目 `initJSdata()` 是 `[NSString stringWithFormat:@"var app_xxx=...;"]` + `writeToFile:` **写 .js 文件到沙盒**,H5 业务用 `<script src="app_data.js">` 同步引入预生成的 12 个全局变量直接读
|
||||
- 发现 2:Contract §附录 A 自身标注「iOS<9 路径使用的旧协议,新外壳如最低系统 ≥ iOS 14 可不实现」,新项目最低 iOS 15.6 → 旧桥 polyfill **完全不必要**
|
||||
- 发现 3:Contract §4.2 之前列的 11 项变量与原项目代码不一致 — 漏 6 项(version / Launchtype / getwifisignalLevel / gamename / invitationcode / gamesname)、多 2 项(gameid / compareCode 原项目根本不写)、文件名与变量名混淆(文件 `app_battery.js` 内的变量名是带 get 的 `app_getbattery`,文件名不带 get)
|
||||
- **修复**:
|
||||
1. 撤销已实现的 `window.settings.getXxx()` polyfill 章节(Design §3.4.1)
|
||||
2. 新增 Design §7.5「H5 `app_*.js` 预注入文件机制」覆盖 15 个变量的写入逻辑
|
||||
3. 按原项目代码全面修订 Contract §4.2(删 gameid/compareCode、补 6 项、修正 battery/network 变量名带 get、注明大厅/子游戏字面差异、加修订记录小节)
|
||||
- **教训**:
|
||||
- **文档与代码冲突时,原项目代码是真理**。Contract 也可能写错(一份 10 年前写就的文档遗漏 / 误传是常态),必须用 `grep` 验证字面字符串
|
||||
- **优先验证字面字符串、不要依赖语义猜测**:`app_getbattery` 与 `app_battery` 一字之差,AI 助手凭语义"应该就是 app_battery 吧" 100% 会犯错
|
||||
- **iOS 最低系统版本是契约边界的天然过滤器**:契约里 iOS<9 路径明示可不实现的接口(如 §附录 A 旧桥),新项目最低 iOS 15.6 应当主动剔除,不要"出于完备性"反而实现一份用不到的 polyfill
|
||||
@@ -51,47 +51,14 @@ daoqi 仓库当前并存两条工作线:
|
||||
- 参考原项目 = 把它当作 **"哪些事必须做"的清单** 和 **"哪些坑已经踩过"的备忘**
|
||||
- 新项目的现代化重构(Swift 6 / SwiftUI / actor 隔离 / SPM 依赖等)依然按原则 B 自由设计;只是设计前先确认"我没有漏掉原项目实际需要解决的事"
|
||||
|
||||
### 典型案例:LaunchScreen orientation(启动图方向错误)
|
||||
### 案例存档(按需读取)
|
||||
|
||||
- **症状**:新项目启动图渲染成竖向矩形再被整体旋转 90°,画面横躺在中间
|
||||
- **错误做法**:凭 Xcode 26 文档推测、纯调整 storyboard / contentMode / 反复换素材
|
||||
- **去原项目找答案**:`daoqi/msext/Info.plist` + `msext.xcodeproj/project.pbxproj`
|
||||
- 发现 1:Info.plist 用的是**顶层无后缀的 `UISupportedInterfaceOrientations`**(值 = landscape),**没有** `~iphone` / `~ipad` 后缀变体
|
||||
- 发现 2:根本**没有** `UILaunchStoryboardName`,用的是 `ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = "LaunchImage-1"`(iOS 8 时代的 LaunchImage Asset Catalog 机制)
|
||||
- **原项目为什么稳**:
|
||||
- 它的所有 LaunchImage PNG(`Default-568h@2x` / `LaunchImage-1-800-667h@2x` 等)**物理像素都是竖向、画面内容横躺**
|
||||
- iOS 8 LaunchImage Asset Catalog 内置"按 device idiom + orientation 自动选图 + 系统级 90° 旋转"魔法,会读 Info.plist 顶层 `UISupportedInterfaceOrientations` 决定是否旋转 portrait 资源
|
||||
- 因此一张物理 portrait 的 PNG 在横屏设备上能被正确旋转后铺满
|
||||
- **不能照搬到新项目**:
|
||||
- LaunchImage Asset Catalog 在 iOS 14+ 已 deprecated,新项目按苹果推荐用 `LaunchScreen.storyboard`
|
||||
- LaunchScreen.storyboard **没有自动旋转 portrait 资源**的魔法,UIImageView 直接渲染 PNG 物理像素
|
||||
- 把 `docs/res/Res/Default-568h@2x` 这类**物理 portrait + 画面横躺**的 msext 残留素材直接塞进 LaunchScreen.storyboard 就会看到躺倒画面
|
||||
- **新项目(LaunchScreen.storyboard)的正确组合**:
|
||||
1. **素材必须物理像素就是横屏的**(必要时用 `sips -r -90 -s format png` 一次性把 msext 残留 portrait 素材逆时针 90° 输出为标准 PNG,物理像素正确)
|
||||
2. **Info.plist 显式补一份无后缀 `UISupportedInterfaceOrientations = landscape`**(Xcode 26 General → Deployment Info UI 只写带后缀的 `~iphone` / `~ipad`,少了无后缀 key 会让 LaunchScreen 阶段——device idiom 尚未识别的早期窗口——fallback 到 portrait 渲染再被旋转)
|
||||
3. UIImageView `contentMode = scaleAspectFit` + 黑底(比 16:9 更宽的现代横屏设备左右补黑边,保画面完整不裁切 logo)
|
||||
- **iOS launch snapshot 缓存粘滞(开发期 iteration 坑)**:
|
||||
- iOS 为加速启动,会把首次渲染的 LaunchScreen 缓存为 PNG snapshot 存到 app sandbox(`Library/Caches/Snapshots/<bundleID>/com.apple.UIKit.SplashBoard/`),后续启动不重渲染
|
||||
- **Xcode 覆盖安装 .app 不会清 sandbox 缓存**,所以改了 Info.plist / LaunchScreen.storyboard / 启动图素材后,Run 出来仍可能是旧 snapshot
|
||||
- 改完启动相关任意一项**必须做**:长按 app → 删除 → Xcode 重新 Run;或模拟器 Erase All Content and Settings;或 `xcrun simctl uninstall booted <bundleID>`
|
||||
- **终端用户不受影响**:从 IPA 首次安装、或升级新 IPA 第一次启动后即被 iOS 自动刷新;只是开发期反复迭代同一台 device 时会被迷惑
|
||||
以下两个完整案例已移到 `.claude/skills/daoqi-lessons/SKILL.md`(用到时再加载,不常驻上下文):
|
||||
|
||||
### 典型案例:H5 与原生通讯接口的真实路径
|
||||
- **典型案例:LaunchScreen orientation(启动图方向错误)** — 处理启动图 / 启动方向问题时读
|
||||
- **典型案例:H5 与原生通讯接口的真实路径** — 处理 H5 读渠道值 / `app_*.js` 变量 / `window.settings` polyfill 时读
|
||||
|
||||
- **症状**:Design / Contract 文档描述 H5 用 `window.settings.getXxx()` 同步函数读渠道值(如 `getothername` / `getchannelName`);按文档实现完 polyfill 后 H5 业务行为不对,user 反馈"H5 读不到数据"
|
||||
- **错误做法**:基于 Contract §附录 A 列出的旧桥 JSExport 28 方法实现 polyfill,把 9 项 getter 注入到 `window.settings`,认为这是 H5 端的主路径
|
||||
- **去原项目找答案**:grep `var app_` 在 `daoqi/msext/Class/RootVC/NewRootVC.m:1204` / `gameController.m:2160` / `AppDelegate.m:259`
|
||||
- 发现 1:原项目 `initJSdata()` 是 `[NSString stringWithFormat:@"var app_xxx=...;"]` + `writeToFile:` **写 .js 文件到沙盒**,H5 业务用 `<script src="app_data.js">` 同步引入预生成的 12 个全局变量直接读
|
||||
- 发现 2:Contract §附录 A 自身标注「iOS<9 路径使用的旧协议,新外壳如最低系统 ≥ iOS 14 可不实现」,新项目最低 iOS 15.6 → 旧桥 polyfill **完全不必要**
|
||||
- 发现 3:Contract §4.2 之前列的 11 项变量与原项目代码不一致 — 漏 6 项(version / Launchtype / getwifisignalLevel / gamename / invitationcode / gamesname)、多 2 项(gameid / compareCode 原项目根本不写)、文件名与变量名混淆(文件 `app_battery.js` 内的变量名是带 get 的 `app_getbattery`,文件名不带 get)
|
||||
- **修复**:
|
||||
1. 撤销已实现的 `window.settings.getXxx()` polyfill 章节(Design §3.4.1)
|
||||
2. 新增 Design §7.5「H5 `app_*.js` 预注入文件机制」覆盖 15 个变量的写入逻辑
|
||||
3. 按原项目代码全面修订 Contract §4.2(删 gameid/compareCode、补 6 项、修正 battery/network 变量名带 get、注明大厅/子游戏字面差异、加修订记录小节)
|
||||
- **教训**:
|
||||
- **文档与代码冲突时,原项目代码是真理**。Contract 也可能写错(一份 10 年前写就的文档遗漏 / 误传是常态),必须用 `grep` 验证字面字符串
|
||||
- **优先验证字面字符串、不要依赖语义猜测**:`app_getbattery` 与 `app_battery` 一字之差,AI 助手凭语义"应该就是 app_battery 吧" 100% 会犯错
|
||||
- **iOS 最低系统版本是契约边界的天然过滤器**:契约里 iOS<9 路径明示可不实现的接口(如 §附录 A 旧桥),新项目最低 iOS 15.6 应当主动剔除,不要"出于完备性"反而实现一份用不到的 polyfill
|
||||
下面保留的 `app_*` 注入时序案例是原则 A 的规范性说明,常驻不移出。
|
||||
|
||||
### 典型案例:`app_*` 注入时序问题(原则 A 不可妥协)
|
||||
|
||||
|
||||
+76
-3
@@ -172,11 +172,42 @@ Safari "开发" 菜单会把每个**已加载页面**的 `WKWebView` 列为**独
|
||||
|
||||
### Q3:能看到条目但 Console 是空的
|
||||
|
||||
可能原因:
|
||||
**首要原因:H5 自带的调试总开关出厂是关的** ⭐
|
||||
|
||||
`gamehall.zip` → `js/01_SubGame/00_SubGame_Config.js:1-12`:
|
||||
|
||||
```js
|
||||
Game_Config.Debugger={
|
||||
isDebugger : false, // debugger模式下会将所有收发的包输出到控制台(正式发布改为false)
|
||||
...
|
||||
```
|
||||
|
||||
整包 220 处 `console.log`,82 处已被 `//` 注释掉,剩下的**高频日志全部挡在这个开关后面**:
|
||||
|
||||
| 位置 | 内容 |
|
||||
|------|------|
|
||||
| `js/00_Surface/09_Net.js:47` | `发送数据:...` |
|
||||
| `js/00_Surface/12_Logic.js:210` | `接收数据:...` |
|
||||
| `js/00_Surface/12_Logic.js:463 / 1317` | 包体输出 |
|
||||
|
||||
没被挡的只剩 `12_Logic.js:784/829/870/921` 的 `Connect:...`,那几行在大厅启动建 websocket 时就打完了,等你挂上 Inspector 早已过去。
|
||||
|
||||
**打开方式(不改 H5 一个字节,符合原则 A)** —— 在 Web Inspector 控制台运行:
|
||||
|
||||
```js
|
||||
Game_Config.Debugger.isDebugger = true
|
||||
```
|
||||
|
||||
这是运行时改内存里的对象属性,不碰 `gamehall.zip`、不碰任何文件;杀进程重启即恢复 `false`。
|
||||
|
||||
> ⚠️ **不要去改 `Resources/gamehall.zip` 里的这个 `false`** —— 那是 H5 修改,违反原则 A,且会跟着渠道包发出去。
|
||||
|
||||
其它可能原因:
|
||||
|
||||
- H5 端确实没有 `console.*` 调用(业务在沉默运行,没问题就不输出)
|
||||
- Web Inspector 打开前的旧日志没被记录 → 在 Web Inspector 已经打开的状态下**触发一次** H5 操作(点按钮、切 tab)再看
|
||||
- Console 顶部过滤器把日志级别过滤掉了 → 确认 All Levels 都选中
|
||||
- 连错 WebView(3 个实例 title 都是 `gameabc`)→ 敲 `typeof Game_Config`,返回 `"undefined"` 说明不是大厅那个,按 §5.2 用 hover 高亮法重选
|
||||
- H5 端确实没有 `console.*` 调用(业务在沉默运行,没问题就不输出)
|
||||
|
||||
### Q4:Sources 里看不到 .js 源码
|
||||
|
||||
@@ -200,9 +231,49 @@ Safari Web Inspector 是开发期主力,但有它够不到的场景:
|
||||
|
||||
- **真机 + 用户已在生产环境**:Release 包没有 inspectable;需要的是事后日志,参考原生侧的崩溃 / 错误上报(项目目前无 crash 监控,依赖用户反馈)
|
||||
- **断网 / 弱网下的 H5 表现**:用 Mac 上的 Network Link Conditioner,或模拟器 Features → Network Link Conditioner
|
||||
- **生产环境的 H5 console 输出**:原生侧已经装了 `H5ErrorRelay`(`Source/Bridge/H5ErrorRelay.swift`),会把 `console.error / window.onerror / unhandledrejection` 转发到原生 `print`,Debug build 可在 Xcode 控制台看到。要扩展到 `console.log` 全量转发,可参考其实现照葫芦画瓢
|
||||
- **不想开 Safari、只在 Xcode 控制台看 H5 输出**:原生侧的 `H5ErrorRelay`(`Source/Bridge/H5ErrorRelay.swift`)已覆盖,见下面 §8.1
|
||||
- **断点调试原生 Swift 与 H5 同时跑**:Xcode + Safari Web Inspector 同时打开,两边各自下断点互不影响
|
||||
|
||||
### 8.1 H5 console → Xcode 控制台(`H5ErrorRelay`)
|
||||
|
||||
`Source/Bridge/H5ErrorRelay.swift` 用 `WKUserScript(.atDocumentStart)` hook 住 console 与全局异常,通过 `webkit.messageHandlers.h5error` 转发到原生。**不改 H5 一行**(原则 A)。
|
||||
|
||||
前缀里的 `lobby` / `subGame` 是 `BridgedWebView(label:)`,用来区分同时存在的多个 WebView——也用来反推该在 Safari「开发」菜单里选哪个条目。
|
||||
|
||||
| 来源 | Xcode 输出前缀 | 落 `Documents/diag.log` | 构建配置 |
|
||||
|-------|---------------|------------------------|---------|
|
||||
| `console.log` | `[H5:lobby console.log]` | ❌ | **仅 Debug** |
|
||||
| `console.info` | `[H5:lobby console.info]` | ❌ | **仅 Debug** |
|
||||
| `console.warn` | `[H5:lobby console.warn]` | ✅ | Debug + Release |
|
||||
| `console.error` | `[H5:lobby console.error]` | ✅ | Debug + Release |
|
||||
| 未捕获 JS 异常 | `[H5:lobby onerror]` | ✅ | Debug + Release |
|
||||
| 未处理的 Promise rejection | `[H5:lobby unhandledrejection]` | ✅ | Debug + Release |
|
||||
| 资源 404(`<script>`/`<img>`/`<audio>`) | `[H5:lobby resourceerror]` | ✅ | Debug + Release |
|
||||
| WebView 首次/提交后加载失败 | `[WebView:lobby] …加载失败` | ✅ | Debug + Release |
|
||||
| WebContent 进程终止(白屏) | `[WebView:lobby] ⚠️ WebContent 进程终止` | ✅ | Debug + Release |
|
||||
|
||||
### 8.2 仍然抓不到的(已知盲区)
|
||||
|
||||
| 场景 | 为什么抓不到 |
|
||||
|------|------------|
|
||||
| 被 H5 自己 `try/catch` 吞掉的异常 | 根本不冒到 window。本包里 `12_Logic.js:254` / `gameabc.min.js:2175` 就是 catch 后用 `console.log(e.stack)` 打印 → 只在 Debug 的 `[H5:… console.log]` 里能看到 |
|
||||
| iframe 内的异常 | `WKUserScript(forMainFrameOnly: true)`,只 hook 主框架 |
|
||||
| 弹层(`OverlayViewController`)的 JS | 第三方外链,按设计不挂 relay |
|
||||
| 跨域脚本的异常细节 | 浏览器安全策略统一报 `Script error.`,无行号无堆栈 |
|
||||
| 原生 handler 内部抛错 | 属原生侧,走 `BridgeBus` 自己的 `diagLog` |
|
||||
|
||||
### 8.3 设计要点
|
||||
|
||||
- **资源 404 必须开捕获阶段**:`<script>`/`<img>`/`<audio>` 的 error 事件只在元素自身触发且**不冒泡**,`addEventListener('error', fn)` 收不到,必须 `addEventListener('error', fn, true)`。JS 异常的 `ev.target` 是 `window`,据此在同一 listener 内分流两类
|
||||
- **导航层失败只 log 不自动 reload**:自动恢复是行为变化,msext 没有,不引入
|
||||
- **`log` / `info` 只在 Debug 注入**:Release 包不该为每条业务日志付一次 JS→Native IPC,也不该把 H5 内部输出暴露给外部审阅。`#if DEBUG` 在编译期就把这段 JS 从 `javaScriptSource` 里摘掉
|
||||
- **`log` / `info` 走裸 `print` 而非 `diagLog`**:`diagLog` 会同步落盘到 `Documents/diag.log`,而该 sink 无轮转、无大小上限(`BridgeBus.swift:47`)。一旦 §7 Q3 里的 `isDebugger` 被打开,收发包 firehose 灌进去会把设备磁盘吃光
|
||||
- **对象参数走 `JSON.stringify`**:大厅业务大量使用 `console.log(msg)` / `console.log(res)` 打整包,直接 `String(obj)` 只会得到无用的 `[object Object]`。循环引用时 stringify 抛错 → 回退 `String()` → 再抛则输出 `[unstringifiable]`
|
||||
- **原始 console 调用被透传**(`origLog.apply(console, arguments)`),所以转发不影响 Safari Web Inspector 里照常看到日志,两条路可以同时用
|
||||
- **仅 `BridgedWebView`(大厅 + 子游戏)注入**,`OverlayViewController` 是第三方外链,不挂
|
||||
|
||||
⚠️ 依然受 §7 Q3 的 `Game_Config.Debugger.isDebugger` 制约:收发包日志被 H5 自己的开关挡着,Xcode 控制台同样看不到。需要时在 Safari 控制台运行 `Game_Config.Debugger.isDebugger = true`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 修订记录
|
||||
@@ -210,3 +281,5 @@ Safari Web Inspector 是开发期主力,但有它够不到的场景:
|
||||
| 日期 | 内容 |
|
||||
|------|------|
|
||||
| 2026-06-24 | 文档建立。落地 `BridgedWebView` / `OverlayViewController` 两处 `isInspectable` 补丁。 |
|
||||
| 2026-08-08 | 查明「Console 空」的首要原因是 H5 自带 `Game_Config.Debugger.isDebugger = false`,补进 §7 Q3。`H5ErrorRelay` 扩展 `console.log` / `console.info` 转发(Debug only)+ 对象参数 JSON 序列化,新增 §8.1。 |
|
||||
| 2026-08-08 | 补齐「非 H5 主动 console.error」的报错通路:资源 404(error 事件捕获阶段)、WebView 加载失败 / WebContent 进程终止(两个 VC 的 `WKNavigationDelegate`,此前完全没实现)。输出加 `lobby` / `subGame` 前缀。新增 §8.2 已知盲区、§8.3 设计要点。 |
|
||||
|
||||
@@ -616,13 +616,14 @@ H5 端无需任何改动:仍然按照"收到 `gameui_stop_voice(user)` 就停
|
||||
**表 B — `getlocationinfo`** (成功):
|
||||
```jsonc
|
||||
{
|
||||
"errorCode": 0, // ⚠️ 新外壳补齐项,msext 遗漏,见下方说明
|
||||
"address": "<完整地址>",
|
||||
"city": "<市>",
|
||||
"cityCode": "<城市编码>",
|
||||
"country": "<国>",
|
||||
"district": "<区>",
|
||||
"latitude": "30.567890", // ⚠️ string,原生 stringWithFormat:@"%f",不是 double
|
||||
"longitude": "104.123456", // ⚠️ string
|
||||
"latitude": 30.567890, // ⚠️ 数字(非字符串),6 位小数。新外壳修正项,见下方说明
|
||||
"longitude": 104.123456, // ⚠️ 数字(非字符串),6 位小数
|
||||
"province": "<省>", // ⚠️ 小写 p,与 sharelogin 的 "Province" 大写形成不一致,沿用历史
|
||||
"street": "<街道>"
|
||||
}
|
||||
@@ -632,6 +633,54 @@ H5 端无需任何改动:仍然按照"收到 `gameui_stop_voice(user)` 就停
|
||||
{ "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
|
||||
{
|
||||
|
||||
@@ -208,9 +208,11 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = NX5W3B4QP3;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = NX5W3B4QP3;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/AMap",
|
||||
@@ -242,6 +244,8 @@
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.skyapp.ylgamehall;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iOS dev";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
@@ -258,9 +262,12 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = NX5W3B4QP3;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = NX5W3B4QP3;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/AMap",
|
||||
@@ -292,6 +299,8 @@
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.skyapp.ylgamehall;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = ios_ad_hoc;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<key>gameid</key>
|
||||
<string>G2hw0ubng0zcoI0r4mx3H2yr4GejidwO</string>
|
||||
<key>channel</key>
|
||||
<string>FtJf073aa0d6rI1xD8J1Y42fINTm0ziK</string>
|
||||
<string>aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG</string>
|
||||
<key>gamedir</key>
|
||||
<string>FtJf073aa0d6rI1xD8J1Y42fINTm0ziK</string>
|
||||
<string>aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG</string>
|
||||
<key>gamestart</key>
|
||||
<string>gamehall</string>
|
||||
<key>gameconfig</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>market</key>
|
||||
<string>2</string>
|
||||
<key>agent</key>
|
||||
<string>veRa0qrBf0df2K1G4de2tgfmVxB2jxpv</string>
|
||||
<string>1B2h0ccl205c390Y28m1Ajdplkuu4wgy</string>
|
||||
<key>appversion</key>
|
||||
<string>44</string>
|
||||
<key>other</key>
|
||||
|
||||
@@ -4,8 +4,26 @@
|
||||
* H5 端桥协议代码,与 Native BridgeBus(@MainActor BridgeProtocol 实现)
|
||||
* 一一对应。详见 docs/H5-Native-Implementation-Design.md §3.2 / §3.3。
|
||||
*
|
||||
* 在 BridgedWebView 构造时通过 WKUserScript(.atDocumentStart)注入到所有
|
||||
* 加载的 H5 页面。H5 业务代码运行时,window.WebViewJavascriptBridge 已就绪。
|
||||
* ⚠️ 本文件的**语义**必须与原工程 msext 的 `WebViewJavascriptBridge_JS.m`
|
||||
* (marcuswestin 原版,daoqi/msext/Class/WebViewJavascriptBridge/)保持一致。
|
||||
* 传输层可以现代化(msext 用 iframe + _fetchQueue 拉取,我们用
|
||||
* WKScriptMessageHandler 直推),但**派发时机与消息字段必须逐项对齐**,
|
||||
* 否则 H5 侧同一份代码在两个壳里的执行顺序不同 → 行为不一致。
|
||||
*
|
||||
* 已对齐的 msext 语义(每一条都曾经不一致,是排查「子游戏定位拿不到」时发现的):
|
||||
*
|
||||
* 1. **异步派发**:msext `dispatchMessagesWithTimeoutSafety = true`,且
|
||||
* `disableJavscriptAlertBoxSafetyTimeout` 在整个 msext 工程里**从未被调用**,
|
||||
* 所以 Native → H5 的每一条消息都走 `setTimeout(_doDispatch)`,在**新的
|
||||
* macrotask** 上执行。早前本文件是同步派发(在 evaluateJavaScript 内直接
|
||||
* 调 handler),H5 handler 相对自身 pending 脚本/微任务的顺序与 msext 不同。
|
||||
* 2. **WVJBCallbacks 用 `setTimeout(..., 0)` flush**(msext `_callWVJBCallbacks`),
|
||||
* 不是同步 flush —— 决定 H5 的 `registerHandler` 何时生效。
|
||||
* 3. **H5 → Native 的 response 消息带 `handlerName`**(msext `_doSend({handlerName,
|
||||
* responseId, responseData})`)。
|
||||
* 4. `_disableJavascriptAlertBoxSafetyTimeout` 作为 H5 侧 handler 注册(msext 有)。
|
||||
* 5. 缺省安装 `window.onerror`(msext 在 H5 未装时兜底装一个)。
|
||||
* 6. 「收到 Native 消息但没有对应 handler」用 `console.log`(msext 原文),不是 warn。
|
||||
*
|
||||
* 协议(Native ↔ H5 双向 JSON 消息):
|
||||
* - H5 → Native:window.webkit.messageHandlers.WVJBHandler.postMessage({...})
|
||||
@@ -18,18 +36,26 @@
|
||||
;(function () {
|
||||
if (window.WebViewJavascriptBridge) { return; }
|
||||
|
||||
// msext 同款:H5 没装 onerror 时兜底装一个
|
||||
if (!window.onerror) {
|
||||
window.onerror = function (msg, url, line) {
|
||||
console.log("WebViewJavascriptBridge: ERROR:" + msg + "@" + url + ":" + line);
|
||||
};
|
||||
}
|
||||
|
||||
// ── 内部状态 ─────────────────────────────────────────────
|
||||
var messageHandlers = {}; // H5 注册的 handler:name → fn(data, responseCallback)
|
||||
var responseCallbacks = {}; // 等待 Native 响应的 JS 回调:callbackId → fn(responseData)
|
||||
var nextCallbackId = 1;
|
||||
// msext `dispatchMessagesWithTimeoutSafety`:默认 true,仅在 Native 下发
|
||||
// `_disableJavascriptAlertBoxSafetyTimeout` 时置 false(msext 从不下发)。
|
||||
var dispatchMessagesWithTimeoutSafety = true;
|
||||
|
||||
// ── 对外 API ─────────────────────────────────────────────
|
||||
var bridge = window.WebViewJavascriptBridge = {
|
||||
|
||||
/**
|
||||
* H5 注册 handler,等待 Native 主动 callHandler。重名覆盖。
|
||||
* @param {string} handlerName
|
||||
* @param {function(data, responseCallback)} handler
|
||||
*/
|
||||
registerHandler: function (handlerName, handler) {
|
||||
messageHandlers[handlerName] = handler;
|
||||
@@ -37,39 +63,31 @@
|
||||
|
||||
/**
|
||||
* H5 主动调 Native handler。
|
||||
* @param {string} handlerName
|
||||
* @param {*} data 任意 JSON 可序列化值
|
||||
* @param {function(responseData)} [responseCallback]
|
||||
* msext `callHandler` 支持 2 参形式(第二个参数是函数时视为 responseCallback)。
|
||||
*/
|
||||
callHandler: function (handlerName, data, responseCallback) {
|
||||
var callbackId = null;
|
||||
if (typeof responseCallback === 'function') {
|
||||
callbackId = 'cb_' + (nextCallbackId++) + '_' + Date.now();
|
||||
responseCallbacks[callbackId] = responseCallback;
|
||||
if (arguments.length === 2 && typeof data === 'function') {
|
||||
responseCallback = data;
|
||||
data = null;
|
||||
}
|
||||
var message = { handlerName: handlerName };
|
||||
if (data !== undefined && data !== null) {
|
||||
message.data = data;
|
||||
}
|
||||
if (callbackId) {
|
||||
message.callbackId = callbackId;
|
||||
}
|
||||
_postMessageToNative(message);
|
||||
_doSend({ handlerName: handlerName, data: data }, responseCallback);
|
||||
},
|
||||
|
||||
disableJavscriptAlertBoxSafetyTimeout: function () {
|
||||
dispatchMessagesWithTimeoutSafety = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Native → H5 入口。BridgeBus.sendToJS 会注入:
|
||||
* window.WebViewJavascriptBridge._handleMessageFromObjC('<base64of JSON>')
|
||||
* base64 包装是为了避免 JSON 内容里的单引号 / 反斜杠扰乱 evaluateJavaScript 的字符串字面量。
|
||||
* base64 包装是为了避免 JSON 内容里的单引号 / 反斜杠扰乱 evaluateJavaScript
|
||||
* 的字符串字面量(msext 走的是逐个 escape,等价)。
|
||||
*
|
||||
* ⚠️ 无返回值:msext 的派发是 setTimeout 异步的,拿不到「H5 有没有跑到
|
||||
* handler」的同步结果。原生侧不要依赖它的返回值做诊断。
|
||||
*/
|
||||
_handleMessageFromObjC: function (base64String) {
|
||||
try {
|
||||
var json = _decodeBase64UTF8(base64String);
|
||||
var message = JSON.parse(json);
|
||||
_dispatchFromNative(message);
|
||||
} catch (e) {
|
||||
console.error('[WVJB] handle message from native failed:', e);
|
||||
}
|
||||
_dispatchMessageFromObjC(base64String);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,42 +97,77 @@
|
||||
try {
|
||||
window.webkit.messageHandlers.WVJBHandler.postMessage(message);
|
||||
} catch (e) {
|
||||
console.error('[WVJB] post to native failed (WVJBHandler 通道未注册?):', e);
|
||||
console.log('[WVJB] post to native failed (WVJBHandler 通道未注册?):', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _dispatchFromNative(message) {
|
||||
// Native 响应 H5 早前 callHandler
|
||||
if (message.responseId) {
|
||||
var cb = responseCallbacks[message.responseId];
|
||||
if (cb) {
|
||||
delete responseCallbacks[message.responseId];
|
||||
try { cb(message.responseData); }
|
||||
catch (e) { console.error('[WVJB] response callback threw:', e); }
|
||||
function _doSend(message, responseCallback) {
|
||||
if (typeof responseCallback === 'function') {
|
||||
var callbackId = 'cb_' + (nextCallbackId++) + '_' + new Date().getTime();
|
||||
responseCallbacks[callbackId] = responseCallback;
|
||||
message['callbackId'] = callbackId;
|
||||
}
|
||||
return;
|
||||
_postMessageToNative(message);
|
||||
}
|
||||
|
||||
// Native 主动调用 H5 handler
|
||||
var handler = messageHandlers[message.handlerName];
|
||||
if (!handler) {
|
||||
console.warn('[WVJB] no H5 handler registered for:', message.handlerName);
|
||||
/// msext `_dispatchMessageFromObjC` 逐句等价:默认走 setTimeout(新 macrotask)。
|
||||
function _dispatchMessageFromObjC(base64String) {
|
||||
if (dispatchMessagesWithTimeoutSafety) {
|
||||
setTimeout(_doDispatchMessageFromObjC);
|
||||
} else {
|
||||
_doDispatchMessageFromObjC();
|
||||
}
|
||||
|
||||
function _doDispatchMessageFromObjC() {
|
||||
var message;
|
||||
try {
|
||||
message = JSON.parse(_decodeBase64UTF8(base64String));
|
||||
} catch (e) {
|
||||
console.log('[WVJB] handle message from native failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
var responseCallback;
|
||||
|
||||
if (message.responseId) {
|
||||
responseCallback = responseCallbacks[message.responseId];
|
||||
if (!responseCallback) { return; }
|
||||
responseCallback(message.responseData);
|
||||
delete responseCallbacks[message.responseId];
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.callbackId) {
|
||||
var responseId = message.callbackId;
|
||||
var callbackResponseId = message.callbackId;
|
||||
responseCallback = function (responseData) {
|
||||
_postMessageToNative({
|
||||
responseId: responseId,
|
||||
// msext 同款:response 消息里**带 handlerName**
|
||||
_doSend({
|
||||
handlerName: message.handlerName,
|
||||
responseId: callbackResponseId,
|
||||
responseData: responseData
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
var handler = messageHandlers[message.handlerName];
|
||||
if (!handler) {
|
||||
console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
|
||||
// 诊断上报(不改 msext 语义,只是额外往自家通道发一条原生侧忽略的消息)
|
||||
_postMessageToNative({ diagDispatched: message.handlerName, hadHandler: false });
|
||||
} else {
|
||||
var threw = null;
|
||||
try { handler(message.data, responseCallback); }
|
||||
catch (e) { console.error('[WVJB] H5 handler "' + message.handlerName + '" threw:', e); }
|
||||
catch (e) { threw = String(e); }
|
||||
_postMessageToNative({
|
||||
diagDispatched: message.handlerName,
|
||||
hadHandler: true,
|
||||
threw: threw
|
||||
});
|
||||
// msext 不吞异常(handler 抛错会冒到 setTimeout 的全局 onerror);
|
||||
// 这里把它原样重抛,保持行为一致,只是先记录了一笔。
|
||||
if (threw !== null) { throw new Error(threw); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,25 +180,26 @@
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
// msext 同款:把 Native 可下发的 safety-timeout 开关注册成 H5 侧 handler
|
||||
bridge.registerHandler("_disableJavascriptAlertBoxSafetyTimeout",
|
||||
bridge.disableJavscriptAlertBoxSafetyTimeout);
|
||||
|
||||
// ── 告知原生「桥已就绪」──────────────────────────────────
|
||||
// 等价 msext 的 `__bridge_loaded__` iframe 回调 → `injectJavascriptFile` →
|
||||
// flush `startupMessageQueue`。原生在收到本消息前发出的 callHandler 必须排队,
|
||||
// 不能丢(msext 有 startupMessageQueue,早前我们是直接丢弃)。
|
||||
_postMessageToNative({ bridgeReady: true });
|
||||
|
||||
// ── marcuswestin 旧式握手兼容 ─────────────────────────────
|
||||
// 部分 H5 代码这样初始化 bridge:
|
||||
// function setupWebViewJavascriptBridge(callback) {
|
||||
// if (window.WebViewJavascriptBridge) return callback(WebViewJavascriptBridge);
|
||||
// if (window.WVJBCallbacks) return window.WVJBCallbacks.push(callback);
|
||||
// window.WVJBCallbacks = [callback];
|
||||
// /* iframe trick - WKWebView 模式不需要 */
|
||||
// }
|
||||
// setupWebViewJavascriptBridge(function (bridge) { bridge.registerHandler(...) });
|
||||
//
|
||||
// 我们在 documentStart 就注入 bridge,正常路径下 H5 拿到的 window.WebViewJavascriptBridge
|
||||
// 已存在,会立即同步 callback(bridge)。但若 H5 代码本身用了 WVJBCallbacks 队列模式
|
||||
// (Array.push 在 bridge 之前),需要在此处 flush 一次。
|
||||
if (Array.isArray(window.WVJBCallbacks)) {
|
||||
var pending = window.WVJBCallbacks;
|
||||
// msext `_callWVJBCallbacks` 用 setTimeout(..., 0),**不是同步 flush**:
|
||||
// 这决定 H5 的 registerHandler 相对页面其它顶层脚本的生效时机。
|
||||
setTimeout(function () {
|
||||
var callbacks = window.WVJBCallbacks;
|
||||
if (!callbacks) { return; }
|
||||
delete window.WVJBCallbacks;
|
||||
for (var i = 0; i < pending.length; i++) {
|
||||
try { pending[i](bridge); }
|
||||
catch (e) { console.error('[WVJB] pending WVJBCallback threw:', e); }
|
||||
}
|
||||
for (var i = 0; i < callbacks.length; i++) {
|
||||
try { callbacks[i](bridge); }
|
||||
catch (e) { console.log('[WVJB] pending WVJBCallback threw:', e); }
|
||||
}
|
||||
}, 0);
|
||||
})();
|
||||
|
||||
@@ -8,6 +8,75 @@
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
import os.log
|
||||
|
||||
/// 桥诊断日志。文件级 `let`(Logger 是 Sendable)以便在 evaluateJavaScript
|
||||
/// 完成回调等非 MainActor 上下文里也能直接用。
|
||||
private let bridgeLog = Logger(subsystem: "ylgamehall", category: "Bridge")
|
||||
|
||||
/// ── 临时诊断输出(排查「子游戏收不到 getlocationinfo」,结束后整体移除)──
|
||||
///
|
||||
/// 真机上 `Logger.debug` 会被系统日志级别过滤掉:devicectl console / Xcode
|
||||
/// console 都只能看到 `.error` 及以上。`print` 直写 stderr 必达,所以诊断期
|
||||
/// 统一走这里;带毫秒时间戳以便与 CoreLocation 的 os_log 行对齐时序。
|
||||
/// 同时落盘到 `Documents/diag.log`:用 Xcode 跑真机时 print 只进 Xcode console,
|
||||
/// 外部拿不到;落盘后可用
|
||||
/// xcrun devicectl device copy from --domain-type appDataContainer \
|
||||
/// --domain-identifier com.skyapp.ylgamehall --source Documents/diag.log ...
|
||||
/// 把整轮日志取出来分析。
|
||||
nonisolated func diagLog(_ message: String) {
|
||||
let line = "[DIAG \(diagTimestamp())] \(message)"
|
||||
print(line)
|
||||
diagFileSink.append(line)
|
||||
}
|
||||
|
||||
private nonisolated let diagFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss.SSS"
|
||||
return f
|
||||
}()
|
||||
|
||||
private nonisolated func diagTimestamp() -> String {
|
||||
diagFormatter.string(from: Date())
|
||||
}
|
||||
|
||||
/// 串行写入 `Documents/diag.log`。每个进程首次写入前插一行 session 分隔,
|
||||
/// 便于区分多次启动。纯诊断设施,排查结束随 diagLog 一并移除。
|
||||
// 工程默认 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor,需显式 nonisolated 才能在
|
||||
// @Sendable 桥回调 / evaluateJavaScript 完成块里同步调用。
|
||||
private nonisolated final class DiagFileSink: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "ylgamehall.diag.log")
|
||||
private let url: URL
|
||||
private var wroteHeader = false
|
||||
|
||||
init() {
|
||||
let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
url = dir.appendingPathComponent("diag.log")
|
||||
}
|
||||
|
||||
func append(_ line: String) {
|
||||
queue.async { [self] in
|
||||
var text = line + "\n"
|
||||
if !wroteHeader {
|
||||
wroteHeader = true
|
||||
let stamp = ISO8601DateFormatter().string(from: Date())
|
||||
text = "\n===== session \(stamp) pid=\(ProcessInfo.processInfo.processIdentifier) =====\n" + text
|
||||
}
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
let fm = FileManager.default
|
||||
if !fm.fileExists(atPath: url.path) {
|
||||
fm.createFile(atPath: url.path, contents: nil)
|
||||
}
|
||||
guard let handle = try? FileHandle(forWritingTo: url) else { return }
|
||||
handle.seekToEndOfFile()
|
||||
handle.write(data)
|
||||
try? handle.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated let diagFileSink = DiagFileSink()
|
||||
|
||||
|
||||
/// H5 ↔ Native 消息总线。
|
||||
///
|
||||
@@ -24,14 +93,33 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
|
||||
/// JS 端注入的全局桥对象名(约定 `window.WebViewJavascriptBridge`)。
|
||||
public static let jsBridgeName = "WebViewJavascriptBridge"
|
||||
|
||||
/// 容器标识(`lobby` / `subGame` / …),仅用于诊断日志区分是哪个 WebView 的桥。
|
||||
/// 不参与任何契约行为。
|
||||
public let label: String
|
||||
|
||||
private weak var webView: WKWebView?
|
||||
private var handlers: [String: BridgeHandler] = [:]
|
||||
private var pendingCallbacks: [String: BridgeCallback] = [:]
|
||||
private var nativeCallbackCounter: UInt64 = 0
|
||||
|
||||
/// 桥就绪前的消息暂存队列。等价 msext `WebViewJavascriptBridgeBase.startupMessageQueue`:
|
||||
/// `_queueMessage` 在 bridge 尚未 loaded 时入队,`injectJavascriptFile` 时按序 flush。
|
||||
///
|
||||
/// 早前的实现在 `window.WebViewJavascriptBridge` 不存在时**直接丢弃**消息 ——
|
||||
/// 真机日志里每次启动都有一条 `sendToJS 'getnetwork' 未送达 H5:no-bridge`,
|
||||
/// msext 里这条是会补发到 H5 的。子游戏 WebView 是全新建的、H5 很早就调
|
||||
/// `startlocation`,反向 `getlocationinfo` 落在这个窗口里就会被静默吞掉。
|
||||
private var startupMessageQueue: [[String: Any]] = []
|
||||
|
||||
/// H5 侧 WVJB.js 初始化完成后会 postMessage `{bridgeReady:true}`,收到即置 true 并 flush。
|
||||
private var bridgeReady = false
|
||||
|
||||
/// 构造时绑定 WebView + 用户内容控制器,自动注册 `WVJBHandler` 消息处理器。
|
||||
public init(webView: WKWebView, controller: WKUserContentController) {
|
||||
public init(webView: WKWebView,
|
||||
controller: WKUserContentController,
|
||||
label: String = "webview") {
|
||||
self.webView = webView
|
||||
self.label = label
|
||||
super.init()
|
||||
controller.add(self, name: Self.scriptMessageHandlerName)
|
||||
}
|
||||
@@ -40,6 +128,7 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
|
||||
|
||||
public func register(_ name: String, handler: @escaping BridgeHandler) {
|
||||
handlers[name] = handler
|
||||
diagLog("[\(self.label)] register handler '\(name)'")
|
||||
}
|
||||
|
||||
public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) {
|
||||
@@ -53,6 +142,10 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
|
||||
pendingCallbacks[cbId] = callback
|
||||
payload["callbackId"] = cbId
|
||||
}
|
||||
// 诊断:打出实际序列化后的字节,确认字段集合 / 类型 / 引号与 msext 一致
|
||||
let wire = (try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]))
|
||||
.flatMap { String(data: $0, encoding: .utf8) } ?? "<encode-failed>"
|
||||
diagLog("[\(self.label)] → H5 callHandler '\(name)' wire=\(wire)")
|
||||
sendToJS(payload: payload)
|
||||
}
|
||||
|
||||
@@ -73,6 +166,27 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
|
||||
// MARK: - 私有
|
||||
|
||||
private func handleIncoming(_ msg: [String: Any]) {
|
||||
// H5 侧 WVJB.js 初始化完成信号(等价 msext 的 `__bridge_loaded__`)。
|
||||
// 每次导航都会重新注入 user script,因此会重复收到;每次都要重新 flush。
|
||||
if msg["bridgeReady"] as? Bool == true {
|
||||
bridgeReady = true
|
||||
let queued = startupMessageQueue
|
||||
startupMessageQueue.removeAll()
|
||||
if !queued.isEmpty {
|
||||
diagLog("[\(self.label)] 桥就绪,flush \(queued.count) 条暂存消息")
|
||||
}
|
||||
for payload in queued { dispatchToJS(payload: payload) }
|
||||
return
|
||||
}
|
||||
|
||||
// 诊断上报:H5 侧派发结果(有没有对应 handler、handler 有没有抛错)
|
||||
if let dispatched = msg["diagDispatched"] as? String {
|
||||
let had = msg["hadHandler"] as? Bool ?? false
|
||||
let threw = msg["threw"] as? String
|
||||
diagLog("[\(self.label)] H5 派发 '\(dispatched)':hadHandler=\(had) threw=\(threw ?? "nil")")
|
||||
return
|
||||
}
|
||||
|
||||
// 优先判 responseId(JS 响应 Native 早前的 callHandler)
|
||||
if let responseId = msg["responseId"] as? String {
|
||||
let respData = msg["responseData"].flatMap { BridgeData(jsonObject: $0) }
|
||||
@@ -95,9 +209,19 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
|
||||
// 构造一个回调闭包,handler 调用即把 responseData 异步派回 JS
|
||||
let responseCallback: BridgeCallback? = makeResponseCallback(for: callbackId)
|
||||
|
||||
diagLog("[\(self.label)] ← H5 call '\(name)' data=\(String(describing: msg["data"]))")
|
||||
|
||||
guard let handler = handlers[name] else {
|
||||
print("[BridgeBus] no handler registered for '\(name)'")
|
||||
responseCallback?(nil)
|
||||
// msext `WebViewJavascriptBridgeBase.flushMessageQueue`:
|
||||
// if (!handler) { NSLog(@"WVJBNoHandlerException, ..."); continue; }
|
||||
// `continue` 意味着 **responseCallback 一次都不会被调用**。
|
||||
//
|
||||
// 早前这里调了 `responseCallback?(nil)`,会给 H5 回一条 {responseId:...}。
|
||||
// 现网 H5 的 `Func.getlocation()` 就是带 callback 调 `getlocationinfo` 的
|
||||
// (`bridge.callHandler('getlocationinfo',"",function(resp){})`,而
|
||||
// `getlocationinfo` 两边都没有对应的原生 handler),于是这条在原工程里
|
||||
// 永不触发的 JS 回调在我们这儿会被触发一次 —— 链条不一致。改为不回。
|
||||
diagLog("[\(self.label)] no NATIVE handler registered for '\(name)'(msext 同款:不回 responseCallback)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -124,17 +248,42 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
|
||||
sendToJS(payload: payload)
|
||||
}
|
||||
|
||||
/// 等价 msext `WebViewJavascriptBridgeBase._queueMessage:`:
|
||||
/// 桥未就绪则入 `startupMessageQueue`,就绪则直接派发。
|
||||
private func sendToJS(payload: [String: Any]) {
|
||||
guard let webView else { return }
|
||||
guard bridgeReady else {
|
||||
let what = (payload["handlerName"] as? String) ?? (payload["responseId"] as? String) ?? "?"
|
||||
diagLog("[\(self.label)] '\(what)' 桥未就绪 → 入队(msext startupMessageQueue 等价,队列长 \(startupMessageQueue.count + 1))")
|
||||
startupMessageQueue.append(payload)
|
||||
return
|
||||
}
|
||||
dispatchToJS(payload: payload)
|
||||
}
|
||||
|
||||
/// 等价 msext `_dispatchMessage:`:序列化 → 注入 `_handleMessageFromObjC`。
|
||||
/// H5 侧派发是 `setTimeout` 异步的(msext dispatchMessagesWithTimeoutSafety=true),
|
||||
/// 因此 evaluateJavaScript 的返回值**不代表** H5 handler 是否执行,不要据此判断送达。
|
||||
private func dispatchToJS(payload: [String: Any]) {
|
||||
guard let webView else {
|
||||
diagLog("[\(self.label)] dispatchToJS 丢弃:webView 已释放")
|
||||
return
|
||||
}
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
print("[BridgeBus] JSON encode failed for payload: \(payload)")
|
||||
diagLog("[\(self.label)] JSON encode failed for payload: \(String(describing: payload))")
|
||||
return
|
||||
}
|
||||
// base64 包装避免 JSON 内单引号 / 反斜杠扰乱 JS 字符串字面量
|
||||
// (msext 是逐个 escape 反斜杠/引号/换行/U+2028/U+2029,等价)
|
||||
let base64 = Data(json.utf8).base64EncodedString()
|
||||
let js = "if (window.\(Self.jsBridgeName)) window.\(Self.jsBridgeName)._handleMessageFromObjC('\(base64)');"
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
let js = "window.\(Self.jsBridgeName)._handleMessageFromObjC('\(base64)');"
|
||||
let tag = label
|
||||
let what = (payload["handlerName"] as? String) ?? (payload["responseId"] as? String) ?? "?"
|
||||
webView.evaluateJavaScript(js) { _, error in
|
||||
if let error {
|
||||
diagLog("[\(tag)] dispatchToJS '\(what)' evaluateJavaScript 失败: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,14 @@ public typealias BridgeCallback = @Sendable (BridgeData?) -> Void
|
||||
public enum BridgeData: Sendable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
/// 定点小数。序列化成 JSON **数字**,但保留给定的小数位数文本。
|
||||
///
|
||||
/// 为什么不用 `.number(Double)`:`JSONSerialization` 写 Double 会输出 17 位有效
|
||||
/// 数字,`28.636486` 变成 `28.636486000000001`。虽然 JS `JSON.parse` 出来是同一个
|
||||
/// IEEE754 double(H5 侧不可分辨),但 JSON **文本**与原工程不一致。
|
||||
/// `getlocationinfo` 的经纬度要求与 msext 老 UIWebView 路径
|
||||
/// (`RootVC.m:1998` 拼 `\"latitude\":%f`)的字面文本对齐,故走这条。
|
||||
case decimal(Decimal)
|
||||
case bool(Bool)
|
||||
case null
|
||||
case array([BridgeData])
|
||||
@@ -89,6 +97,7 @@ extension BridgeData {
|
||||
switch self {
|
||||
case .string(let s): return s
|
||||
case .number(let d): return d
|
||||
case .decimal(let d): return d as NSDecimalNumber
|
||||
case .bool(let b): return b
|
||||
case .null: return NSNull()
|
||||
case .array(let arr): return arr.map { $0.jsonObject }
|
||||
@@ -124,6 +133,7 @@ extension BridgeData {
|
||||
return String(Int(d))
|
||||
}
|
||||
return String(d)
|
||||
case .decimal(let d): return "\(d)"
|
||||
case .bool(let b): return b ? "true" : "false"
|
||||
case .null: return nil
|
||||
case .array, .object: return nil
|
||||
@@ -131,8 +141,11 @@ extension BridgeData {
|
||||
}
|
||||
|
||||
nonisolated public var asDouble: Double? {
|
||||
if case .number(let d) = self { return d }
|
||||
return nil
|
||||
switch self {
|
||||
case .number(let d): return d
|
||||
case .decimal(let d): return (d as NSDecimalNumber).doubleValue
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated public var asInt: Int? {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// H5ErrorRelay.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// H5 错误中继:把 WebView 内 `console.error` / `window.onerror` /
|
||||
// `unhandledrejection` 通过 `webkit.messageHandlers.h5error` 上报到原生 print。
|
||||
// H5 日志/错误中继:把 WebView 内 `console.error` / `console.warn` /
|
||||
// `window.onerror` / `unhandledrejection` 通过 `webkit.messageHandlers.h5error`
|
||||
// 上报到原生 print;Debug 构建下额外转发 `console.log` / `console.info`。
|
||||
//
|
||||
// Phase 9.2(Design §11.2)。msext 时代仅靠用户/运营手工反馈;本项目作为
|
||||
// 原则 B「内部自由现代化」的一部分,开发期把 H5 异常打到 Xcode console,
|
||||
@@ -31,56 +32,127 @@ public final class H5ErrorRelay: NSObject {
|
||||
|
||||
/// 把 hook JS + message handler 注册到给定的 UserContentController。
|
||||
/// BridgedWebView.init 在挂 WVJB 之后调一次。
|
||||
public func install(into controller: WKUserContentController) {
|
||||
///
|
||||
/// - Parameter label: 该 WebView 的标识(大厅 / 子游戏),会作为输出前缀。
|
||||
/// 同时存在多个 BridgedWebView 时,没有前缀就分不清哪条日志来自谁,
|
||||
/// 也就无法反推该在 Safari「开发」菜单里选哪个条目。
|
||||
public func install(into controller: WKUserContentController, label: String) {
|
||||
let userScript = WKUserScript(
|
||||
source: Self.javaScriptSource,
|
||||
injectionTime: .atDocumentStart, // 业务 JS 之前 hook
|
||||
forMainFrameOnly: true // iframe 不抓
|
||||
)
|
||||
controller.addUserScript(userScript)
|
||||
controller.add(MessageProxy(target: self), name: Self.messageName)
|
||||
controller.add(MessageProxy(target: self, label: label), name: Self.messageName)
|
||||
}
|
||||
|
||||
fileprivate func handle(_ body: Any) {
|
||||
fileprivate func handle(_ body: Any, label: String) {
|
||||
guard let dict = body as? [String: Any],
|
||||
let kind = dict["kind"] as? String else { return }
|
||||
let tag = "[H5:\(label)"
|
||||
switch kind {
|
||||
case "console.log", "console.info":
|
||||
// 仅 Debug 构建会有这两类(见 javaScriptSource 的 #if DEBUG)。
|
||||
// ⚠️ 走裸 print 而不是 diagLog:H5 把 `Game_Config.Debugger.isDebugger`
|
||||
// 打开后每个收发包都会 console.log 一次,而 diagLog 会同步落盘到
|
||||
// `Documents/diag.log`(无轮转、无大小上限,见 BridgeBus.swift:47),
|
||||
// firehose 灌进去会把设备磁盘吃光。Xcode console 看得到就够了。
|
||||
let args = (dict["args"] as? [String]) ?? []
|
||||
print("\(tag) \(kind)] " + args.joined(separator: " "))
|
||||
case "console.error":
|
||||
let args = (dict["args"] as? [String]) ?? []
|
||||
print("[H5 console.error]", args.joined(separator: " "))
|
||||
diagLog("\(tag) console.error] " + args.joined(separator: " "))
|
||||
case "console.warn":
|
||||
// 桥诊断需要:WebViewJavascriptBridge.js 在「Native 发来的 handlerName
|
||||
// H5 侧没注册」时走 console.warn,之前不上报导致这类丢包完全不可见。
|
||||
let args = (dict["args"] as? [String]) ?? []
|
||||
diagLog("\(tag) console.warn] " + args.joined(separator: " "))
|
||||
case "onerror":
|
||||
let msg = (dict["msg"] as? String) ?? ""
|
||||
let src = (dict["src"] as? String) ?? ""
|
||||
let line = (dict["line"] as? Int) ?? 0
|
||||
let col = (dict["col"] as? Int) ?? 0
|
||||
let stack = dict["stack"] as? String
|
||||
print("[H5 onerror] \(msg) at \(src):\(line):\(col)" + (stack.map { "\n\($0)" } ?? ""))
|
||||
diagLog("\(tag) onerror] \(msg) at \(src):\(line):\(col)" + (stack.map { "\n\($0)" } ?? ""))
|
||||
case "resourceerror":
|
||||
// 资源 404 / 加载失败。H5 层完全静默(不会走 console.error),
|
||||
// 缺图 / 缺 js 在线上就是白屏或功能失灵,这里是唯一可观察点。
|
||||
let tagName = (dict["tag"] as? String) ?? "?"
|
||||
let src = (dict["src"] as? String) ?? ""
|
||||
diagLog("\(tag) resourceerror] <\(tagName)> 加载失败:\(src)")
|
||||
case "unhandledrejection":
|
||||
let reason = (dict["reason"] as? String) ?? ""
|
||||
let stack = dict["stack"] as? String
|
||||
print("[H5 unhandledrejection]", reason + (stack.map { "\n\($0)" } ?? ""))
|
||||
diagLog("\(tag) unhandledrejection] " + reason + (stack.map { "\n\($0)" } ?? ""))
|
||||
default:
|
||||
print("[H5 unknown error]", dict)
|
||||
diagLog("\(tag) unknown] \(dict)")
|
||||
}
|
||||
}
|
||||
|
||||
/// JS 端 hook 三类异常源,统一通过 `webkit.messageHandlers.h5error.postMessage(payload)`
|
||||
/// JS 端 hook 各类日志/异常源,统一通过 `webkit.messageHandlers.h5error.postMessage(payload)`
|
||||
/// 上报。所有 send 调用都包 try/catch,hook 自身永远不应抛错(避免污染业务流程)。
|
||||
private static let javaScriptSource = """
|
||||
///
|
||||
/// `console.log` / `console.info` 只在 Debug 构建注入:Release 包不该为每条业务
|
||||
/// 日志付一次 JS→Native IPC,也不该把 H5 内部输出暴露给外部审阅。
|
||||
private static var javaScriptSource: String {
|
||||
#if DEBUG
|
||||
let verboseHooks = verboseConsoleHookJS
|
||||
#else
|
||||
let verboseHooks = ""
|
||||
#endif
|
||||
return """
|
||||
(function(){
|
||||
function send(payload){
|
||||
try { window.webkit.messageHandlers.h5error.postMessage(payload); } catch(e){}
|
||||
}
|
||||
// 参数格式化:直接 String(obj) 会得到无用的 "[object Object]",而大厅业务
|
||||
// 大量使用 console.log(msg) / console.log(res) 打整包,故对象走 JSON.stringify。
|
||||
// 循环引用时 stringify 抛错,回退 String();再抛就放弃,绝不让 hook 影响业务。
|
||||
function fmt(v){
|
||||
try {
|
||||
if (v === null) { return 'null'; }
|
||||
if (v === undefined) { return 'undefined'; }
|
||||
var t = typeof v;
|
||||
if (t === 'string') { return v; }
|
||||
if (t === 'number' || t === 'boolean' || t === 'function') { return String(v); }
|
||||
if (v instanceof Error) { return v.stack || (v.name + ': ' + v.message); }
|
||||
var s = JSON.stringify(v);
|
||||
return (s === undefined) ? String(v) : s;
|
||||
} catch(e) {
|
||||
try { return String(v); } catch(e2) { return '[unstringifiable]'; }
|
||||
}
|
||||
}
|
||||
function collect(a){
|
||||
var out = [];
|
||||
for (var i=0; i<a.length; i++) { out.push(fmt(a[i])); }
|
||||
return out;
|
||||
}
|
||||
var origErr = console.error;
|
||||
console.error = function(){
|
||||
try {
|
||||
var args = [];
|
||||
for (var i=0; i<arguments.length; i++) { args.push(String(arguments[i])); }
|
||||
send({ kind:'console.error', args: args });
|
||||
} catch(e){}
|
||||
try { send({ kind:'console.error', args: collect(arguments) }); } catch(e){}
|
||||
if (origErr) { origErr.apply(console, arguments); }
|
||||
};
|
||||
var origWarn = console.warn;
|
||||
console.warn = function(){
|
||||
try { send({ kind:'console.warn', args: collect(arguments) }); } catch(e){}
|
||||
if (origWarn) { origWarn.apply(console, arguments); }
|
||||
};
|
||||
\(verboseHooks)
|
||||
// capture = true:资源加载失败(<script>/<img>/<audio> 的 404)只在元素自身
|
||||
// 触发 error 且**不冒泡**,不开捕获阶段就完全收不到 —— 而这恰恰是 H5 最常见的
|
||||
// 线上故障(缺图 / 缺 js)。JS 异常的 ev.target 是 window,据此分流两类。
|
||||
// 注:listener 挂在 window 上,window 自己是 target 时走 AT_TARGET 阶段,
|
||||
// 不受 capture 标记影响,所以一个 listener 同时收得到两类。
|
||||
window.addEventListener('error', function(ev){
|
||||
var t = ev.target;
|
||||
if (t && t !== window && t.tagName) {
|
||||
send({
|
||||
kind: 'resourceerror',
|
||||
tag: String(t.tagName),
|
||||
src: String(t.src || t.href || '')
|
||||
});
|
||||
return;
|
||||
}
|
||||
send({
|
||||
kind:'onerror',
|
||||
msg: String(ev.message || ''),
|
||||
@@ -89,7 +161,7 @@ public final class H5ErrorRelay: NSObject {
|
||||
col: ev.colno || 0,
|
||||
stack: (ev.error && ev.error.stack) ? String(ev.error.stack) : null
|
||||
});
|
||||
});
|
||||
}, true);
|
||||
window.addEventListener('unhandledrejection', function(ev){
|
||||
var r = ev.reason;
|
||||
send({
|
||||
@@ -100,6 +172,27 @@ public final class H5ErrorRelay: NSObject {
|
||||
});
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
/// Debug 专用:`console.log` / `console.info` 全量转发。
|
||||
///
|
||||
/// 注意 H5 自带调试总开关 `Game_Config.Debugger.isDebugger`
|
||||
/// (`gamehall.zip` → `js/01_SubGame/00_SubGame_Config.js`)出厂为 `false`,
|
||||
/// 收发包日志(`发送数据:` / `接收数据:`)被它挡住。要看这些需在
|
||||
/// Safari Web Inspector 控制台运行时打开:`Game_Config.Debugger.isDebugger = true`
|
||||
/// —— 运行时改内存属性,不动 H5 任何文件(契约原则 A)。
|
||||
private static let verboseConsoleHookJS = """
|
||||
var origLog = console.log;
|
||||
console.log = function(){
|
||||
try { send({ kind:'console.log', args: collect(arguments) }); } catch(e){}
|
||||
if (origLog) { origLog.apply(console, arguments); }
|
||||
};
|
||||
var origInfo = console.info;
|
||||
console.info = function(){
|
||||
try { send({ kind:'console.info', args: collect(arguments) }); } catch(e){}
|
||||
if (origInfo) { origInfo.apply(console, arguments); }
|
||||
};
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - WKScriptMessageHandler proxy(weak target 切断 retain cycle)
|
||||
@@ -107,9 +200,11 @@ public final class H5ErrorRelay: NSObject {
|
||||
@MainActor
|
||||
private final class MessageProxy: NSObject, WKScriptMessageHandler {
|
||||
private weak var target: H5ErrorRelay?
|
||||
private let label: String
|
||||
|
||||
init(target: H5ErrorRelay) {
|
||||
init(target: H5ErrorRelay, label: String) {
|
||||
self.target = target
|
||||
self.label = label
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -117,6 +212,6 @@ private final class MessageProxy: NSObject, WKScriptMessageHandler {
|
||||
didReceive message: WKScriptMessage) {
|
||||
// WKScriptMessageHandler 在主队列派发;MessageProxy / H5ErrorRelay 都
|
||||
// MainActor,直接调即可,无需 Task 切换。
|
||||
target?.handle(message.body)
|
||||
target?.handle(message.body, label: label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//
|
||||
// ✅ Phase 4.E 微信:新版 SDK(2.x)无 static delegate API,逐次调用 handleOpenURL/sendAuthReq
|
||||
// 时传入并被 SDK 弱引用;WeChatManager singleton 长生命周期,子游戏 pop 不析构,无需清理
|
||||
// ✅ Phase 5 高德定位:`LocationService.shared.stop()`(已加,canImport 守卫,
|
||||
// ✅ Phase 5 高德定位:`location.stop()`(子游戏私有实例,canImport 守卫,
|
||||
// Xcode Embed AMapLocationKit 后激活)
|
||||
// ⏳ Phase 3.B/C/D 录音 / 七牛上传:`AudioRecorder.shared.cancel()` 待 opencore-amr
|
||||
// 接入后取消注释;`QiniuUploader.cancelInFlight()` 依赖 Task.cancel 传播无需显式调
|
||||
@@ -34,7 +34,9 @@ import Foundation
|
||||
|
||||
public enum BackGameDataHandler {
|
||||
|
||||
public static func register(on bridge: any BridgeProtocol) {
|
||||
/// - Parameter location: **子游戏自己的** LocationService(msext cleanUpAction
|
||||
/// 只停 gameController 那一个 manager,不影响大厅)。
|
||||
public static func register(on bridge: any BridgeProtocol, location: LocationService) {
|
||||
// 【18】backgameData
|
||||
// 入参 data(字符串,子游戏 H5 自定义透传给大厅 getWebdata)
|
||||
// cb: "backgameData"
|
||||
@@ -57,7 +59,7 @@ public enum BackGameDataHandler {
|
||||
|
||||
#if canImport(AMapLocationKit)
|
||||
// msext gameController.m:2473-2478 cleanUpAction 等价
|
||||
LocationService.shared.stop()
|
||||
location.stop(caller: "backgameData")
|
||||
#endif
|
||||
|
||||
// Phase 3.C 录音 / Phase 3.B 七牛上传 清理(QiniuUploader.cancelInFlight 当前
|
||||
|
||||
@@ -17,43 +17,82 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
// 工程默认 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor,全局 let 也会被 MainActor 隔离;
|
||||
// handler 闭包是 @Sendable 非隔离上下文,故显式 nonisolated(Logger 本身 Sendable,安全)。
|
||||
private nonisolated let startLocLog = Logger(subsystem: "ylgamehall", category: "Location")
|
||||
|
||||
public enum StartLocationHandler {
|
||||
|
||||
public static func register(on bridge: any BridgeProtocol) {
|
||||
bridge.register("startlocation") { _, callback in
|
||||
/// - Parameters:
|
||||
/// - label: 容器标识(`lobby` / `subGame`),仅用于诊断日志。
|
||||
/// - location: **本容器私有**的 LocationService。msext 大厅 / 子游戏各持一个
|
||||
/// `AMapLocationManager`,不可共用(见 LocationService.init 注释)。
|
||||
public static func register(on bridge: any BridgeProtocol,
|
||||
label: String = "?",
|
||||
location: LocationService) {
|
||||
bridge.register("startlocation") { data, callback in
|
||||
// 与 msext 行为一致:先回 cb 字面,不等定位完成
|
||||
callback?(.string("startlocation"))
|
||||
|
||||
diagLog("[\(label)] ← startlocation 入参 type=\(data?.asLooseString ?? "nil")")
|
||||
|
||||
#if canImport(AMapLocationKit)
|
||||
// SDK 已链接:拉起一次性定位 → 反向 callback 9 字段
|
||||
// (持续定位 data == 1 暂未实现,业务实际只调一次性 — Phase 5.4 再扩展)
|
||||
// msext NewRootVC.m:411-421 / gameController.m:533-543 的分支逐字等价:
|
||||
// int tempinfo = [data intValue];
|
||||
// if (tempinfo == 1) [self.locationManager startUpdatingLocation]; // 持续
|
||||
// else [self reGeocodeAction]; // 单次
|
||||
// 现网两个 H5 包(gamehall / jinxianmahjong)实测入参恒为 2,持续定位分支
|
||||
// 是死代码;但 msext 有、我们缺就是原生侧的行为不一致,按 msext 补齐。
|
||||
let isContinuous = Int(data?.asLooseString ?? "") == 1
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let payload = try await LocationService.shared.requestOnce()
|
||||
// 单次路径下一次请求可能产出两条 outcome(msext completionBlock 的原始
|
||||
// 控制流);持续路径下每次定位更新都会回一条 .success。
|
||||
let dispatch: @MainActor (LocationOutcome) -> Void = { outcome in
|
||||
switch outcome {
|
||||
case .failure:
|
||||
// msext gameController.m:2507 等价
|
||||
diagLog("[\(label)] → getlocationinfo errorCode=12")
|
||||
bridge.call("getlocationinfo", data: .object([
|
||||
"errorCode": .number(12),
|
||||
"errorMsg": .string("缺少定位权限")
|
||||
]), callback: nil)
|
||||
case .success(let payload):
|
||||
// msext gameController.m:2528 的 9 字段 + `errorCode: 0`。
|
||||
//
|
||||
// ⚠️ 契约影响(docs/H5-Native-Contract.md §3.2 表 B):
|
||||
// msext 全工程只在**失败**分支发 `errorCode: 12`,成功分支
|
||||
// 9 个字段里**没有** errorCode —— 这是原工程的遗漏。
|
||||
// H5 侧以 `errorCode == 0` 作为"定位数据有效"的判据:
|
||||
// 子游戏 jinxianmahjong 的 05_Func.js(2026-07-19)在
|
||||
// `Func.startlocation` / `Func.getlocation` 的兜底桩里
|
||||
// 自造的定位对象就带 `"errorCode":0`;而大厅 gamehall 的
|
||||
// 05_Func.js(2026-02-04)整份文件都没有 errorCode。
|
||||
// 这正是"大厅定位正常、子游戏拿不到"的原生侧成因:成功包缺
|
||||
// errorCode 时子游戏 H5 不认这份数据。
|
||||
// 按项目方决定补齐(此处是**有意偏离** msext 的一处,其余
|
||||
// 定位链路仍严格对齐)。
|
||||
diagLog("[\(label)] → getlocationinfo 成功 city=\(payload.city) province=\(payload.province)")
|
||||
bridge.call("getlocationinfo", data: .object([
|
||||
"errorCode": .number(0),
|
||||
"address": .string(payload.address),
|
||||
"city": .string(payload.city),
|
||||
"cityCode": .string(payload.cityCode),
|
||||
"country": .string(payload.country),
|
||||
"district": .string(payload.district),
|
||||
"latitude": .string(payload.latitude), // ← string
|
||||
"longitude": .string(payload.longitude), // ← string
|
||||
"latitude": .decimal(payload.latitude), // ← 数字(非字符串),6 位小数
|
||||
"longitude": .decimal(payload.longitude), // ← 数字(非字符串),6 位小数
|
||||
"province": .string(payload.province), // ← 小写 p
|
||||
"street": .string(payload.street)
|
||||
]), callback: nil)
|
||||
} catch LocationError.authorizationDenied {
|
||||
// 契约失败回包(msext gameController.m:2507 等价)
|
||||
bridge.call("getlocationinfo", data: .object([
|
||||
"errorCode": .number(12),
|
||||
"errorMsg": .string("缺少定位权限")
|
||||
]), callback: nil)
|
||||
} catch {
|
||||
// 其它失败也归一到 12(msext 行为)
|
||||
bridge.call("getlocationinfo", data: .object([
|
||||
"errorCode": .number(12),
|
||||
"errorMsg": .string("缺少定位权限")
|
||||
]), callback: nil)
|
||||
}
|
||||
}
|
||||
|
||||
if isContinuous {
|
||||
location.startContinuous(caller: label, emit: dispatch)
|
||||
} else {
|
||||
location.requestOnce(caller: label, emit: dispatch)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -90,6 +90,7 @@ public final class AppCoordinator {
|
||||
AudioPlayer.shared.stopAllBackground()
|
||||
|
||||
let subGame = SubGameViewController(request: request)
|
||||
diagLog("[coordinator] push 子游戏(栈深 \(nav.viewControllers.count) → \(nav.viewControllers.count + 1))")
|
||||
nav.pushViewController(subGame, animated: true)
|
||||
return true
|
||||
}
|
||||
@@ -97,6 +98,7 @@ public final class AppCoordinator {
|
||||
/// 子游戏 pop 回大厅。data 是 H5 调 backgameData 时传入的字符串,
|
||||
/// 通过通知派发给大厅 WebContainer,由大厅触发 `getWebdata` 反向 callback。
|
||||
public func popSubGame(returningData data: String) {
|
||||
diagLog("[coordinator] pop 子游戏回大厅")
|
||||
navigationController?.popViewController(animated: true)
|
||||
NotificationCenter.default.post(
|
||||
name: .subGameDidReturn,
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import os.log
|
||||
|
||||
/// 定位诊断日志(文件级 let,Logger 是 Sendable,便于在 AMap 回调闭包内直接用)。
|
||||
private let locLog = Logger(subsystem: "ylgamehall", category: "Location")
|
||||
|
||||
#if canImport(AMapLocationKit)
|
||||
import AMapLocationKit
|
||||
@@ -28,78 +32,276 @@ public struct LocationPayload: Sendable {
|
||||
public let cityCode: String
|
||||
public let country: String
|
||||
public let district: String
|
||||
public let latitude: String
|
||||
public let longitude: String
|
||||
/// ⚠️ **JSON 数字**(不是字符串),且保留 `%f` 的 6 位小数文本。
|
||||
/// 用 `Decimal` 而不是 `Double`,见下方 init 注释与契约 §3.2 表 B。
|
||||
public let latitude: Decimal
|
||||
public let longitude: Decimal
|
||||
public let province: String
|
||||
public let street: String
|
||||
}
|
||||
|
||||
public enum LocationError: Error, Sendable {
|
||||
case sdkNotLinked
|
||||
case authorizationDenied
|
||||
case timeout
|
||||
case underlying(any Error)
|
||||
#if canImport(AMapLocationKit)
|
||||
extension LocationPayload {
|
||||
/// 严格照搬 msext 的 `@{...}` 字面量语义。
|
||||
///
|
||||
/// msext:
|
||||
/// ```objc
|
||||
/// @try{ [_bridge callHandler:@"getlocationinfo" data:@{
|
||||
/// @"address":regeocode.formattedAddress, @"city":regeocode.city, ... }]; }
|
||||
/// @catch (NSException * e) { NSLog(...); }
|
||||
/// ```
|
||||
/// `@{}` 字面量里**任何一个 value 为 nil 都会抛 NSException**(这正是它包
|
||||
/// `@try/@catch` 的原因),净效果是:只要 9 个字段里有一个是 nil,
|
||||
/// **整条 getlocationinfo 都不会发给 H5**。
|
||||
///
|
||||
/// 早前我们对每个字段做 `?? ""` 兜底,于是会发出一个带空串的包 —— 数据格式
|
||||
/// 与原工程不一致(H5 会收到 msext 里根本收不到的消息)。这里改为任一字段
|
||||
/// 为 nil 就返回 nil,由调用方跳过发送。
|
||||
nonisolated init?(location: CLLocation, reGeocode: AMapLocationReGeocode) {
|
||||
guard let address = reGeocode.formattedAddress,
|
||||
let city = reGeocode.city,
|
||||
let cityCode = reGeocode.citycode,
|
||||
let country = reGeocode.country,
|
||||
let district = reGeocode.district,
|
||||
let province = reGeocode.province,
|
||||
let street = reGeocode.street
|
||||
else { return nil }
|
||||
|
||||
self.address = address
|
||||
self.city = city
|
||||
self.cityCode = cityCode
|
||||
self.country = country
|
||||
self.district = district
|
||||
// 经纬度是 **数字**,保留 `%f` 的 6 位小数精度。
|
||||
//
|
||||
// ⚠️ 契约影响(docs/H5-Native-Contract.md §3.2 表 B):msext 的 WVJB 路径
|
||||
// (`gameController.m:2528` / `NewRootVC.m:2132`)用
|
||||
// `[NSString stringWithFormat:@"%f", ...]` 把经纬度发成**字符串**;但老
|
||||
// UIWebView / JSContext 路径(`RootVC.m:1998` / `fourviewVC.m:1541`)拼的是
|
||||
// `\"latitude\":%f` —— **不带引号,是 JSON 数字**。现网子游戏 H5
|
||||
// (jinxianmahjong 05_Func.js:2029-2031)自造定位对象时也写
|
||||
// `"latitude":28.623546` 数字形式。与 `errorCode` 是同一类问题:WVJB 路径
|
||||
// 当年把它字符串化了。按项目方决定改回数字。
|
||||
//
|
||||
// 用 `%f` 文本构造 `Decimal`:既保住 6 位小数的舍入行为,又让
|
||||
// JSONSerialization 输出 `28.636486` 而不是 Double 的 17 位有效数字
|
||||
// `28.636486000000001`(两者 JS parse 后是同一个 IEEE754 double,H5 不可分辨,
|
||||
// 但 JSON 文本要与原工程一致)。
|
||||
let latText = String(format: "%f", location.coordinate.latitude)
|
||||
let lonText = String(format: "%f", location.coordinate.longitude)
|
||||
self.latitude = Decimal(string: latText) ?? Decimal(location.coordinate.latitude)
|
||||
self.longitude = Decimal(string: lonText) ?? Decimal(location.coordinate.longitude)
|
||||
self.province = province
|
||||
self.street = street
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 一次定位请求可能产出的事件。**一次请求可以产出两条**(先 failure 再 success),
|
||||
/// 这是 msext `completionBlock` 的原始控制流,不是笔误 —— 详见 requestOnce 注释。
|
||||
public enum LocationOutcome: Sendable {
|
||||
/// → H5 `getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`
|
||||
case failure
|
||||
/// → H5 `getlocationinfo(<9 字段>)`
|
||||
case success(LocationPayload)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class LocationService {
|
||||
|
||||
public static let shared = LocationService()
|
||||
/// 容器标识(`lobby` / `subGame`),仅用于诊断日志。
|
||||
private let owner: String
|
||||
|
||||
public init() {}
|
||||
/// **每个容器必须持有自己的实例**(不要做成单例)。
|
||||
/// msext `NewRootVC.m:1568` / `gameController.m:1230` 各自
|
||||
/// `[[AMapLocationManager alloc] init]`,两套 manager + completionBlock +
|
||||
/// delegate 完全独立。共用一个 manager 会引入三个原生侧故障:
|
||||
/// 1. `requestLocationWithReGeocode:completionBlock:` 同一时刻只保留一个
|
||||
/// 单次定位请求,两个容器并发请求会互相顶掉,被顶掉的一侧
|
||||
/// completionBlock 永不触发 → continuation 永挂 → H5 什么都收不到;
|
||||
/// 2. `stopUpdatingLocation`「会 cancel 掉所有的单次定位请求」,子游戏
|
||||
/// `backgameData` 会连带取消大厅在飞的定位;
|
||||
/// 3. `delegate = nil` 作用在共享 manager 上是**进程级永久**副作用。
|
||||
public init(owner: String = "?") {
|
||||
self.owner = owner
|
||||
#if canImport(AMapLocationKit)
|
||||
// msext `configLocationManager` 里 `setDelegate:self` 是**无条件**装上的,
|
||||
// 不只服务持续定位:`amapLocationManager:doRequireLocationAuth:` 也走 delegate。
|
||||
// 所以这里在 init 就把 shim 挂上,而不是等持续定位分支才 lazy 触发。
|
||||
_ = delegateShim
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(AMapLocationKit)
|
||||
/// msext `configLocationManager` 逐项等价。
|
||||
private let manager: AMapLocationManager = {
|
||||
let m = AMapLocationManager()
|
||||
m.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
m.pausesLocationUpdatesAutomatically = false
|
||||
m.locationTimeout = 6
|
||||
m.reGeocodeTimeout = 3
|
||||
m.locatingWithReGeocode = true
|
||||
return m
|
||||
}()
|
||||
|
||||
/// 持续定位(`startlocation` type == 1)的 delegate 承接器。
|
||||
/// msext `configLocationManager` 里 `setDelegate:self` 由 VC 自己承担,
|
||||
/// 这里用独立对象持有,避免 LocationService 被迫继承 NSObject。
|
||||
private lazy var delegateShim: LocationDelegateShim = {
|
||||
let shim = LocationDelegateShim(owner: owner)
|
||||
manager.delegate = shim
|
||||
return shim
|
||||
}()
|
||||
#endif
|
||||
|
||||
/// 一次性定位 + 逆地理(msext locAction + completionBlock 等价)
|
||||
public func requestOnce() async throws -> LocationPayload {
|
||||
/// 诊断用:requestOnce 调用序号,日志里可对上「谁发起、谁回来」。
|
||||
private var requestSeq: UInt64 = 0
|
||||
|
||||
/// 一次性定位 + 逆地理。**逐行等价 msext `initCompleteBlock` 的 completionBlock**
|
||||
/// (`gameController.m:2493-2545` / `NewRootVC.m:2099-2150`):
|
||||
///
|
||||
/// ```objc
|
||||
/// if (error) {
|
||||
/// callHandler getlocationinfo {errorCode:12,...} // ①
|
||||
/// if (error.code == AMapLocationErrorLocateFailed) return;
|
||||
/// }
|
||||
/// if (location) { if (regeocode) {
|
||||
/// if (regeocode.formattedAddress != nil)
|
||||
/// callHandler getlocationinfo <9 字段> // ②
|
||||
/// }}
|
||||
/// ```
|
||||
///
|
||||
/// 注意 ① 之后**没有 return**(除 locateFailed):高德在「拿到了 CLLocation 但
|
||||
/// 逆地理超时/网络出错」时会带着 error **和** location 一起回调,msext 会先发一条
|
||||
/// errorCode 12、再把真实定位补发出去,H5 最终拿到的是真实定位。
|
||||
///
|
||||
/// 早前的实现用 `async throws -> LocationPayload` 表达,单个返回值天然只能二选一,
|
||||
/// 于是在 error 分支直接 return,②永远不会发 —— H5 只收到 errorCode 12,
|
||||
/// `C_Player.SetLocationInfo({errorCode:12})` 还会把已有的 addr 覆盖掉。子游戏
|
||||
/// 进房瞬间网络/CPU 最忙、逆地理最容易超时,所以这个分叉在子游戏侧暴露得多。
|
||||
/// 因此改成回调式:一次请求可以按 msext 的顺序产出两条 outcome。
|
||||
///
|
||||
/// - Parameter emit: 在 MainActor 上调用,可能被调用 0 / 1 / 2 次。
|
||||
public func requestOnce(caller: String = "?",
|
||||
emit: @escaping @MainActor (LocationOutcome) -> Void) {
|
||||
#if canImport(AMapLocationKit)
|
||||
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<LocationPayload, Error>) in
|
||||
manager.requestLocation(withReGeocode: true) { loc, regeo, err in
|
||||
requestSeq += 1
|
||||
let seq = requestSeq
|
||||
diagLog("[\(caller)] requestOnce #\(seq) 发起(owner=\(owner) manager=\(UInt(bitPattern: ObjectIdentifier(self.manager).hashValue)))")
|
||||
let t0 = Date()
|
||||
// 高德的 completionBlock 在主线程回调;这里保持与 msext 同样的同步语义。
|
||||
let accepted = manager.requestLocation(withReGeocode: true) { loc, regeo, err in
|
||||
let ms = Int(Date().timeIntervalSince(t0) * 1000)
|
||||
diagLog("[\(caller)] requestOnce #\(seq) completionBlock 回来(耗时 \(ms)ms):loc=\(loc != nil) regeo=\(regeo != nil) err=\(err?.localizedDescription ?? "nil")")
|
||||
|
||||
MainActor.assumeIsolated {
|
||||
if let err = err as NSError? {
|
||||
// msext 行为:权限相关错误归一到 12 errorCode(handler 转字段)
|
||||
emit(.failure) // ①
|
||||
if err.code == AMapLocationErrorCode.locateFailed.rawValue {
|
||||
cont.resume(throwing: LocationError.authorizationDenied)
|
||||
} else {
|
||||
cont.resume(throwing: LocationError.underlying(err))
|
||||
}
|
||||
diagLog("[\(caller)] requestOnce #\(seq) locateFailed,按 msext 就此结束")
|
||||
return
|
||||
}
|
||||
guard let loc, let regeo else {
|
||||
cont.resume(throwing: LocationError.timeout)
|
||||
diagLog("[\(caller)] requestOnce #\(seq) 有 error 但非 locateFailed,按 msext 继续尝试补发真实定位")
|
||||
}
|
||||
// msext 要求 location / regeocode / formattedAddress 三者都在才发 ②
|
||||
guard let loc, let regeo, regeo.formattedAddress != nil else {
|
||||
diagLog("[\(caller)] requestOnce #\(seq) 无可用逆地理(loc=\(loc != nil) regeo=\(regeo != nil)),按 msext 不发 9 字段")
|
||||
return
|
||||
}
|
||||
cont.resume(returning: LocationPayload(
|
||||
address: regeo.formattedAddress ?? "",
|
||||
city: regeo.city ?? "",
|
||||
cityCode: regeo.citycode ?? "",
|
||||
country: regeo.country ?? "",
|
||||
district: regeo.district ?? "",
|
||||
latitude: String(format: "%f", loc.coordinate.latitude),
|
||||
longitude: String(format: "%f", loc.coordinate.longitude),
|
||||
province: regeo.province ?? "",
|
||||
street: regeo.street ?? ""
|
||||
))
|
||||
guard let payload = LocationPayload(location: loc, reGeocode: regeo) else {
|
||||
diagLog("[\(caller)] requestOnce #\(seq) 逆地理有 nil 字段,按 msext(@{} 抛 NSException)整条不发")
|
||||
return
|
||||
}
|
||||
emit(.success(payload)) // ②
|
||||
}
|
||||
}
|
||||
// AMapLocationManager.h:返回值是「是否成功添加单次定位Request」。msext 忽略它,
|
||||
// 效果就是什么都不发给 H5;回调式实现天然等价(不再有 continuation 可挂死),
|
||||
// 这里只留一条日志便于排查。
|
||||
if !accepted {
|
||||
diagLog("[\(caller)] requestOnce #\(seq) requestLocation 返回 NO(未挂上请求),按 msext 静默")
|
||||
}
|
||||
#else
|
||||
throw LocationError.sdkNotLinked
|
||||
diagLog("[\(caller)] requestOnce: AMapLocationKit 未链接,按 msext 无 SDK 场景不回 H5")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 持续定位(`startlocation` type == 1)。
|
||||
/// msext `NewRootVC.m:414` / `gameController.m:537`:
|
||||
/// `if (tempinfo == 1) [self.locationManager startUpdatingLocation];`
|
||||
/// 结果通过 delegate `amapLocationManager:didUpdateLocation:reGeocode:` 逐次推
|
||||
/// `getlocationinfo`(msext `gameController.m:2551-2560`),只在 `reGeocode` 与
|
||||
/// `formattedAddress` 都非空时发;`didFailWithError` msext 只 NSLog、不通知 H5。
|
||||
///
|
||||
/// - Parameter emit: 每次定位更新都会调用(不是一次性)。
|
||||
public func startContinuous(caller: String = "?",
|
||||
emit: @escaping @MainActor (LocationOutcome) -> Void) {
|
||||
#if canImport(AMapLocationKit)
|
||||
diagLog("[\(caller)] startContinuous 发起(owner=\(owner))")
|
||||
delegateShim.onUpdate = emit
|
||||
delegateShim.caller = caller
|
||||
manager.startUpdatingLocation()
|
||||
#else
|
||||
diagLog("[\(caller)] startContinuous: AMapLocationKit 未链接,按 msext 无 SDK 场景不回 H5")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 停止:msext gameController.m:2473-2478 cleanUpAction 等价
|
||||
public func stop() {
|
||||
public func stop(caller: String = "?") {
|
||||
#if canImport(AMapLocationKit)
|
||||
// `stopUpdatingLocation` 会 cancel 掉本 manager 上所有单次定位请求。因为
|
||||
// manager 是本容器私有的(每容器一个实例),影响范围仅限本容器 —— 与 msext
|
||||
// cleanUpAction 只动自己那个 manager 一致。
|
||||
diagLog("[\(caller)] stop():stopUpdatingLocation + delegate=nil(owner=\(owner))")
|
||||
manager.stopUpdatingLocation()
|
||||
manager.delegate = nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(AMapLocationKit)
|
||||
|
||||
/// `AMapLocationManagerDelegate` 承接器(仅持续定位路径用到)。
|
||||
/// msext 里这些方法直接实现在 NewRootVC / gameController 上;新外壳把它独立出来,
|
||||
/// 每个 LocationService 一个,生命周期随宿主容器。
|
||||
@MainActor
|
||||
private final class LocationDelegateShim: NSObject, AMapLocationManagerDelegate {
|
||||
|
||||
var onUpdate: (@MainActor (LocationOutcome) -> Void)?
|
||||
var caller: String = "?"
|
||||
private let owner: String
|
||||
|
||||
init(owner: String) {
|
||||
self.owner = owner
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// msext `gameController.m:2551-2560` 等价:仅 reGeocode 与 formattedAddress
|
||||
/// 都非空时推 9 字段,其余情况什么都不发。
|
||||
nonisolated func amapLocationManager(_ manager: AMapLocationManager!,
|
||||
didUpdate location: CLLocation!,
|
||||
reGeocode: AMapLocationReGeocode!) {
|
||||
// 先在当前上下文把 AMap 的非 Sendable 对象拆成纯值,只把 Sendable 的
|
||||
// LocationPayload 送进 MainActor(否则 Swift 6 判定 reGeocode 跨隔离域有数据竞争)。
|
||||
guard let location, let reGeocode, reGeocode.formattedAddress != nil else {
|
||||
diagLog("[持续定位] 更新但无可用逆地理,按 msext 不发")
|
||||
return
|
||||
}
|
||||
guard let payload = LocationPayload(location: location, reGeocode: reGeocode) else {
|
||||
diagLog("[持续定位] 逆地理有 nil 字段,按 msext(@{} 抛 NSException)整条不发")
|
||||
return
|
||||
}
|
||||
MainActor.assumeIsolated {
|
||||
diagLog("[\(caller)] 持续定位 → getlocationinfo city=\(payload.city)")
|
||||
onUpdate?(.success(payload))
|
||||
}
|
||||
}
|
||||
|
||||
/// msext `gameController.m:2547` 等价:**只打日志,不通知 H5**。
|
||||
nonisolated func amapLocationManager(_ manager: AMapLocationManager!,
|
||||
didFailWithError error: (any Error)!) {
|
||||
diagLog("[持续定位 owner=\(owner)] didFailWithError: \(error?.localizedDescription ?? "nil")(msext 同款:不通知 H5)")
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -17,7 +17,9 @@ public final class BridgedWebView: UIView {
|
||||
public let webView: WKWebView
|
||||
public let bridge: BridgeBus
|
||||
|
||||
public init() {
|
||||
/// - Parameter label: 容器标识(`lobby` / `subGame`),仅用于桥诊断日志区分来源,
|
||||
/// 不参与任何契约行为。
|
||||
public init(label: String = "webview") {
|
||||
// ── WKWebViewConfiguration(契约 §4.1)─────────────────
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
@@ -61,7 +63,7 @@ public final class BridgedWebView: UIView {
|
||||
// ── H5 错误中继(Phase 9.2,原则 A 零修改 H5)─────────────
|
||||
// 把 console.error / window.onerror / unhandledrejection 通过独立
|
||||
// 的 webkit.messageHandlers.h5error 桥到原生 print,开发期减少盲点。
|
||||
H5ErrorRelay.shared.install(into: configuration.userContentController)
|
||||
H5ErrorRelay.shared.install(into: configuration.userContentController, label: label)
|
||||
|
||||
// ── 创建 WKWebView + BridgeBus ─────────────────────────
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
@@ -71,7 +73,8 @@ public final class BridgedWebView: UIView {
|
||||
}
|
||||
#endif
|
||||
let bridge = BridgeBus(webView: webView,
|
||||
controller: configuration.userContentController)
|
||||
controller: configuration.userContentController,
|
||||
label: label)
|
||||
|
||||
self.webView = webView
|
||||
self.bridge = bridge
|
||||
|
||||
@@ -33,7 +33,11 @@ public final class SubGameViewController: UIViewController {
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private let bridgedWebView = BridgedWebView()
|
||||
private let bridgedWebView = BridgedWebView(label: "subGame")
|
||||
|
||||
/// 子游戏私有的定位服务(msext gameController.m:1230 `configLocationManager`
|
||||
/// 每次进子游戏都 alloc 一个自己的 AMapLocationManager)。随本 VC 一起析构。
|
||||
private let locationService = LocationService(owner: "subGame")
|
||||
private let splash = SplashOverlay()
|
||||
|
||||
// MARK: - Handlers
|
||||
@@ -88,7 +92,7 @@ public final class SubGameViewController: UIViewController {
|
||||
DeviceInfoHandler.register(on: bridge)
|
||||
BrowserHandler.register(on: bridge)
|
||||
OpenSaomaHandler.register(on: bridge)
|
||||
StartLocationHandler.register(on: bridge)
|
||||
StartLocationHandler.register(on: bridge, label: "subGame", location: locationService)
|
||||
|
||||
// assetsRoot 闭包 = 子游戏 H5 根目录(msext gameController.m:364 等价)。
|
||||
// 用 closure 是因为 effectiveGameDir 在 boot pipeline 升级路径下会变化,
|
||||
@@ -118,7 +122,7 @@ public final class SubGameViewController: UIViewController {
|
||||
OpenurlTitleDataHandler.register(on: bridge)
|
||||
|
||||
// 子游戏专属:backgameData(退出回大厅 + 反向 callback getWebdata)
|
||||
BackGameDataHandler.register(on: bridge)
|
||||
BackGameDataHandler.register(on: bridge, location: locationService)
|
||||
|
||||
// 视频房间 3 件套 stub(业务暂未启用,仅维持桥契约不让 H5 报 "no handler")
|
||||
// 未来接 Agora 时把 VideoRoomHandlers 内 3 个 stub 展开实现,注册点不变。
|
||||
@@ -408,5 +412,26 @@ extension SubGameViewController: WKNavigationDelegate {
|
||||
self?.splash.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
// ── 加载失败 / WebContent 进程崩溃:仅记录,不改行为 ──────────────
|
||||
// 说明同 WebContainerViewController 对应实现。子游戏更容易踩到:zip 下载不全 /
|
||||
// 解压残缺 / gameStart 目录名对不上时都会走 didFailProvisionalNavigation。
|
||||
public func webView(_ webView: WKWebView,
|
||||
didFailProvisionalNavigation navigation: WKNavigation!,
|
||||
withError error: Error) {
|
||||
let ns = error as NSError
|
||||
diagLog("[WebView:subGame] 首次加载失败 code=\(ns.code) \(ns.localizedDescription)")
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView,
|
||||
didFail navigation: WKNavigation!,
|
||||
withError error: Error) {
|
||||
let ns = error as NSError
|
||||
diagLog("[WebView:subGame] 提交后加载失败 code=\(ns.code) \(ns.localizedDescription)")
|
||||
}
|
||||
|
||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
diagLog("[WebView:subGame] ⚠️ WebContent 进程终止(多为内存不足),页面已白屏")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ public final class WebContainerViewController: UIViewController {
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private let bridgedWebView = BridgedWebView()
|
||||
private let bridgedWebView = BridgedWebView(label: "lobby")
|
||||
|
||||
/// 大厅私有的定位服务(msext NewRootVC.m:1568 `configLocationManager` 等价)。
|
||||
/// 与子游戏那一个互不影响 —— 这是 msext 的原始结构,不可再合并成单例。
|
||||
private let locationService = LocationService(owner: "lobby")
|
||||
private let splash = SplashOverlay()
|
||||
|
||||
// MARK: - Handlers (有状态的 handler 注册器持有,无状态的走 enum 静态 register)
|
||||
@@ -77,7 +81,7 @@ public final class WebContainerViewController: UIViewController {
|
||||
DeviceInfoHandler.register(on: bridge) // §3.1 [21] + §3.2 [1]
|
||||
BrowserHandler.register(on: bridge) // §3.1 [16]
|
||||
OpenSaomaHandler.register(on: bridge) // §3.1 [22]空 stub
|
||||
StartLocationHandler.register(on: bridge) // §3.1 [20]Phase 5 完整实现,Phase 2 stub
|
||||
StartLocationHandler.register(on: bridge, label: "lobby", location: locationService) // §3.1 [20]Phase 5 完整实现,Phase 2 stub
|
||||
|
||||
// Phase 3.A 本地音频 + 3.C/3.D stub
|
||||
// assetsRoot 闭包 = 大厅 H5 根目录(lobbyIndex 父目录),lobby 不做升级
|
||||
@@ -756,5 +760,30 @@ extension WebContainerViewController: WKNavigationDelegate {
|
||||
self?.splash.removeFromSuperview()
|
||||
})
|
||||
}
|
||||
|
||||
// ── 加载失败 / WebContent 进程崩溃:仅记录,不改行为 ──────────────
|
||||
// 此前这三个回调都没实现:H5 加载不出来(AppSchemeHandler 找不到 index.html、
|
||||
// zip 解压残缺)时 splash 永不淡出、卡在启动图,Xcode 里一行日志都没有;
|
||||
// WebContent 进程被系统回收(canvas 游戏 JSC OOM)时页面直接白屏且同样静默。
|
||||
// 这些都不是 JS 异常,H5ErrorRelay 抓不到,只能在原生导航层观察。
|
||||
// ⚠️ 只 log 不自动 reload —— 自动恢复是行为变化,msext 没有,不引入。
|
||||
public func webView(_ webView: WKWebView,
|
||||
didFailProvisionalNavigation navigation: WKNavigation!,
|
||||
withError error: Error) {
|
||||
let ns = error as NSError
|
||||
// -999 = NSURLErrorCancelled,多为后续导航覆盖前一次,属正常噪声
|
||||
diagLog("[WebView:lobby] 首次加载失败 code=\(ns.code) \(ns.localizedDescription)")
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView,
|
||||
didFail navigation: WKNavigation!,
|
||||
withError error: Error) {
|
||||
let ns = error as NSError
|
||||
diagLog("[WebView:lobby] 提交后加载失败 code=\(ns.code) \(ns.localizedDescription)")
|
||||
}
|
||||
|
||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
diagLog("[WebView:lobby] ⚠️ WebContent 进程终止(多为内存不足),页面已白屏")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user