Compare commits

..
15 Commits
Author SHA1 Message Date
joywayer 707abcb9d3 更新 ChannelConfig.plist 中的渠道和游戏目录配置,修正 SKILL.md 文件以存档典型案例 2026-08-08 16:18:06 +08:00
joywayer 7fe089577b 补齐非 console.error 类 H5 报错的观察通路
排查「H5 有输出但 Safari 看不到」时发现,即便绕开 Safari 只看 Xcode,也有三类
故障是完全静默的:

1. 资源 404(<script>/<img>/<audio> 加载失败)
   error 事件只在元素自身触发且不冒泡,原先 addEventListener('error', fn) 收不到。
   改为捕获阶段 (capture=true),JS 异常的 ev.target 是 window,据此在同一 listener
   内分流 onerror / resourceerror 两类。缺图缺 js 是 H5 最常见的线上故障,此前零信号。

2. WebView 加载失败(didFailProvisionalNavigation / didFail)
   两个 VC 的 WKNavigationDelegate 此前只实现了 didFinish。AppSchemeHandler 找不到
   index.html、子游戏 zip 解压残缺时,splash 永不淡出、卡在启动图,Xcode 无任何输出。

3. WebContent 进程终止(webViewWebContentProcessDidTerminate)
   canvas 游戏 JSC OOM 被系统回收时页面白屏,同样静默。
   ⚠️ 只 log 不自动 reload —— 自动恢复是行为变化,msext 没有,不引入。

另:H5ErrorRelay.install 增加 label 参数,输出前缀从 [H5 xxx] 变为
[H5:lobby xxx] / [H5:subGame xxx]。同时存在多个 BridgedWebView 时原先分不清
日志来自谁,也就无法反推该在 Safari「开发」菜单里选哪个条目。

无契约影响:H5 可观察行为不变,新增回调仅记录不改流程。
Debug / Release 两个配置均已编译验证。
docs/H5-Debug-Guide.md §8.1 更新能力表,新增 §8.2 已知盲区 / §8.3 设计要点。
2026-08-08 14:19:58 +08:00
joywayer f4acccdd6c H5ErrorRelay 增加 console.log/info 转发,查明 Console 空的根因
排查「Safari Web Inspector 里 H5 控制台没输出」时定位到首要原因不在原生侧:
gamehall.zip 的 js/01_SubGame/00_SubGame_Config.js 里 Game_Config.Debugger
.isDebugger 出厂为 false,把「发送数据 / 接收数据」等高频日志全挡住了;未被
挡住的 Connect:... 又在建 websocket 时就打完,挂上 Inspector 时早已过去。
调试时在 Safari 控制台运行时改 Game_Config.Debugger.isDebugger = true 即可,
不动 H5 任何文件(原则 A)。

顺带补齐原生转发能力:
- 新增 console.log / console.info 转发,仅 Debug 构建注入(#if DEBUG 编译期
  摘掉那段 JS)。Release 包不为每条业务日志付 JS→Native IPC,也不把 H5 内部
  输出暴露给外部审阅;已验证 Release 二进制中该 hook 字符串命中 0 次
- log/info 走裸 print 而非 diagLog:diagLog 会落盘到 Documents/diag.log,而该
  sink 无轮转无上限(BridgeBus.swift:47),isDebugger 打开后 firehose 会吃光磁盘
- 参数格式化统一走 fmt(),对象改用 JSON.stringify —— 大厅业务大量 console.log(msg)
  打整包,原先 String(obj) 只得到无用的 [object Object];循环引用逐级回退
- 原始 console 调用照常透传,不影响 Safari 侧同时查看

无契约影响:H5 可观察行为不变,Release 行为完全不变。
docs/H5-Debug-Guide.md 同步补 §7 Q3 根因 + 新增 §8.1 转发能力表。
2026-08-08 14:05:37 +08:00
joywayer 1203957924 修复定位的回调问题 2026-08-07 12:18:44 +08:00
joywayerandClaude Opus 5 3f8b382ad3 加桥/定位边界诊断日志,排查子游戏收不到 getlocationinfo
现象:大厅 H5 能正确拿到定位,子游戏拿不到。已静态排除「子游戏未注册
handler」与「回调错发给大厅」两个猜测(BridgeBus 是 per-WebView 实例、
handlers 是实例状态,反向 call 捕获各自的 bridge,物理上不可能串台;
下载真实子游戏 zip 比对后确认 H5 侧大厅/子游戏逻辑完全对称)。

为定位真正的失败层,在各组件边界加日志(纯诊断,不改任何行为):
- BridgeBus 加 label(lobby / subGame)区分来源,记录 ← H5 调用、
  → H5 反向 call、native handler 未注册、evaluateJavaScript 失败
- sendToJS 改为返回 'ok'/'no-bridge',H5 侧 bridge 未就绪导致的静默丢包
  现在会打错误日志(原实现 `if (window.X)` 直接丢弃,完全不可见)
- LocationService 记录 requestOnce 序号 / shared manager id /
  requestLocation 的 BOOL 返回值 / completionBlock 是否回来 / stop() 调用
  —— 高德文档明确 requestLocation 返回 NO 时 completionBlock 永不调用,
  当前代码忽略该返回值会让 continuation 永挂,H5 连 errorCode 12 都收不到
- H5ErrorRelay 补 console.warn 中继,让 WVJB「H5 侧无对应 handler」的
  warn 能出现在 Xcode console

契约影响:无。仅新增日志与可选 label 参数,桥接接口名 / 字段名 / 数据结构
均未变动(docs/H5-Native-Contract.md §3.1[20]/ §3.2[6]不变)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMPCLfsuxvwgMotzsb67QH
2026-08-07 01:54:18 +08:00
joywayer 8a7d543cdc 优化联网提示 2026-07-04 18:03:24 +08:00
joywayer 842ca2b9dc 优化首次启动app的网络权限体验 2026-07-04 17:37:23 +08:00
joywayer d76064c4c5 添加multishare配置,用来开启关闭3分享 2026-07-03 22:20:48 +08:00
joywayer 113b4d4960 微信分享去sdk 2026-06-30 17:41:10 +08:00
joywayer c1e51d2183 微信分享去sdk 2026-06-30 17:40:54 +08:00
joywayerandClaude Opus 4.7 04e702a99b Phase 1.F: WebView 加载从 file:// 迁到 ylgame://h5 自定义 scheme
为未来 Cocos 子游戏 H5 build 适配铺路(file:// 下 Cocos XHR/fetch 受
null-origin 限制几乎必踩坑),同时保留大厅 + 子游戏跨页 localStorage
共享语义(用单虚拟 host `h5` 让所有 H5 same-origin)。

变更:
- 新增 AppSchemeHandler(WKURLSchemeHandler 单例 + Range/MIME/异步 IO/
  取消语义;闭包只携 Sendable ObjectIdentifier,不捕获 task)
- SandboxPaths 加 lobbyIndexAppURL / subGameIndexAppURL builders
- BridgedWebView 注册 scheme handler(WKWebView init 前)
- WebContainerViewController / SubGameViewController 的 loadFileURL
  → webView.load(URLRequest),OverlayViewController 不变

文档:
- Plan 新增 Phase 1.F (1.18-1.21) + ADR-010 决策记录 + 进度勾选
- Design 新增 §7.6 包含 URL 结构 / 实现要点 / 等价性表 / Cocos 预检脚本
- Contract §0.2 / §4.1 / §10 验收清单同步切换说明(H5 可观察差异:
  location.protocol "file:" → "ylgame:",项目方已 grep 确认现网 H5
  不依赖此字面)

存量影响:file:// → ylgame:// origin 切换时老用户 localStorage 一次性
清零,已与项目方确认业务可接受、不做迁移补偿。

BuildProject 通过。Plan 进度已勾选 1.18/1.19/1.20。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-29 08:58:00 +08:00
joywayerandClaude Opus 4.7 960adc5491 IPA 升级 itms-services 路径补主动 suspend:用户回桌面 + 看到下载进度
【背景修正】
上个 commit (befe205) commit msg 里说"iOS 调 itms-services 时会自动把 App 切后台"
不准确 —— 实测系统弹窗只是模态覆盖在本 App 上,用户点「安装」后 App 回到 active,
桌面图标下载进度需要用户手动按 Home 才能看到。

【方案】
调 UIApplication.open(itms-services URL) 后,0.3s 用私有 selector "suspend"
把 App 主动切后台(等价按 Home / 上滑):
- 用户立即看到桌面 + 系统安装确认窗(弹窗是系统级,覆盖在桌面上)
- 用户点「安装」→ 弹窗 dismiss → 用户已经在桌面 → 看到 App 图标下载进度
- App 进程留在内存,IPA 替换时由 iOS 自动结束

【私有 API 风险评估】
suspend selector 是 iOS 私有 API,AppStore 审核会拒。本项目企业签 / 渠道分发,
不上架 AppStore,可以用 (CLAUDE.md 第 6 节稳定>一切原则下接受)。
UIControl().sendAction 比 perform() 更安全:selector 不响应时静默忽略,不 crash。

【仅 itms-services 路径触发 suspend】
Safari 路径(普通 https URL)不调 suspend:用户已经在 Safari,主动切后台反而
让 Safari 也消失,糟糕的 UX。

BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 22:12:59 +08:00
joywayerandClaude Opus 4.7 befe205bf9 IPA 升级 URL 自动包成 itms-services:跳过 Safari、直接弹系统安装窗
【UX 目标】
点「确定」→ 直接弹系统安装确认窗(不跳 Safari)→ 用户点「安装」→
App 自然切后台、用户看到桌面图标下载进度,App 进程留在内存不退出。

【方案】
makeInstallURL 规范化 appDownload:
- itms-services://...        → 原样
- https?://...*.plist (mfst) → 包成 itms-services://?action=download-manifest&url=...
- 其它(网页 / 短链)         → 原样跳 Safari(后台需把 URL 改成上面两种之一才能跳过 Safari)

不再需要 exit(0);iOS 处理 itms-services 时本来就会把 App 切后台,自动满足用户
"回到桌面但 App 不退出"的需求。

文案改为"开始升级,系统会自动下载并安装"匹配新流程。

BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 21:46:43 +08:00
joywayerandClaude Opus 4.7 692c849e0d fallback 链头嵌入根(RemoteConfig 顶层):所有 7 字段真正统一,删字段级特殊兜底
【动机】
上一版"统一接口"虽然合并了 resolve / resolveAudio,但 resolve 内部仍有不对称:
- showmessage / audioDomain / audioBucket: pickString(chain) ?? config.xxx
- appVersion / appDownload / gameVersion / gameZip: 只 pickInt/String(chain),
  顶层声明这 4 个字段也读不到

【方案】
- RemoteConfig 加 4 个 String? 字段(appVersion/appDownload/gameVersion/gameZip)
  配齐与节点 struct 同款 7 字段集合
- extension RemoteConfig: RemoteConfigNode(自然 conform)
- buildChain 首行 var chain = [config]:根始终作为最浅一层
- resolve() 去掉 ?? config.xxx 三处特殊兜底,全 7 字段一律 pickString/Int(chain)

【效果】
- 节点链 1..5 长(含根);所有字段一视同仁,pickString 倒序找即天然包含顶层
- 任何字段都可以在顶层 / agent / game / channel / market 任一层声明,最深层赢
- 算法对称、代码无字段级特殊分支

【硬约束(Plan ADR-009 同步)】
新增字段:加进协议 + 5 个 struct(含 RemoteConfig)+ ResolvedVersion +
resolve() 内一行 pickString/Int(chain, \.xxx);不允许 ?? config.xxx 类回退。

BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 21:38:03 +08:00
joywayerandClaude Opus 4.7 485f0c6679 合并 resolveAudio 进 resolve():所有 RemoteConfig 字段走单一入口 + 同款 4 层 fallback
【动机】
上个 commit 把 audio_domain/audio_bucket 改成 4 层 fallback,但用了独立的
resolveAudio() 入口 —— 与 resolve() 并存意味着调用方要记两个 API,违反
"统一接口"原则。

【方案】
- ResolvedVersion 加 audioDomain / audioBucket 两个字段(扁平结构)
- resolve() 内直接 pickString(chain, \.audioDomain) ?? config.audioDomain 一次解出
- 删除 resolveAudio() 公开入口
- WebContainerViewController parsed 分支:单次 resolve() 调用 + 同一 r 取所有字段

【硬约束(Plan ADR-009 同步)】
远端 RemoteConfig 所有字段必须走 VersionResolver.resolve(...) 单一入口、
同一套 4 层 fallback;新增字段时加进协议 + 节点 struct + ResolvedVersion +
一行 pickString,不允许为某字段单独写 if 链或 helper。

BuildProject 通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-27 21:32:57 +08:00
32 changed files with 2299 additions and 654 deletions
+51
View File
@@ -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`
- 发现 1Info.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 **完全不必要**
- 发现 3Contract §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
+5 -38
View File
@@ -51,47 +51,14 @@ daoqi 仓库当前并存两条工作线:
- 参考原项目 = 把它当作 **"哪些事必须做"的清单** 和 **"哪些坑已经踩过"的备忘** - 参考原项目 = 把它当作 **"哪些事必须做"的清单** 和 **"哪些坑已经踩过"的备忘**
- 新项目的现代化重构(Swift 6 / SwiftUI / actor 隔离 / SPM 依赖等)依然按原则 B 自由设计;只是设计前先确认"我没有漏掉原项目实际需要解决的事" - 新项目的现代化重构(Swift 6 / SwiftUI / actor 隔离 / SPM 依赖等)依然按原则 B 自由设计;只是设计前先确认"我没有漏掉原项目实际需要解决的事"
### 典型案例:LaunchScreen orientation(启动图方向错误 ### 案例存档(按需读取
- **症状**:新项目启动图渲染成竖向矩形再被整体旋转 90°,画面横躺在中间 以下两个完整案例已移到 `.claude/skills/daoqi-lessons/SKILL.md`(用到时再加载,不常驻上下文):
- **错误做法**:凭 Xcode 26 文档推测、纯调整 storyboard / contentMode / 反复换素材
- **去原项目找答案**`daoqi/msext/Info.plist` + `msext.xcodeproj/project.pbxproj`
- 发现 1Info.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 与原生通讯接口的真实路径 - **典型案例:LaunchScreen orientation(启动图方向错误)** — 处理启动图 / 启动方向问题时读
- **典型案例:H5 与原生通讯接口的真实路径** — 处理 H5 读渠道值 / `app_*.js` 变量 / `window.settings` polyfill 时读
- **症状**Design / Contract 文档描述 H5 用 `window.settings.getXxx()` 同步函数读渠道值(如 `getothername` / `getchannelName`);按文档实现完 polyfill 后 H5 业务行为不对,user 反馈"H5 读不到数据" 下面保留的 `app_*` 注入时序案例是原则 A 的规范性说明,常驻不移出。
- **错误做法**:基于 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 **完全不必要**
- 发现 3Contract §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 不可妥协)
+170
View File
@@ -0,0 +1,170 @@
# ylgamehall
进贤聚友棋牌 iOS 新外壳(greenfield 重写)。原生外壳 + 内置 H5(`gamehall.zip` 解压到沙盒)+ WebViewJavascriptBridge 桥接,企业签 / 超级签 / TF 渠道分发,**不上架 App Store**。
> 同仓库 `../daoqi/` 是已上线多年的旧外壳 `msext`Objective-C / MRC / iOS 9.0+),作为本项目的"工作参照实现"。本项目按现代 iOS 标准重写,但 **H5 端零修改** 是不可妥协的第一准则。
---
## 技术栈
| 项 | 值 |
|---|---|
| 语言 | Swift 6.0`SWIFT_APPROACHABLE_CONCURRENCY=YES``SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor` |
| UI | SwiftUI + UIKit 互操作(按需) |
| 最低 iOS | 15.6 |
| 设备 | iPhone + iPad,仅横屏,`UIRequiresFullScreen=true` |
| Xcode | 26.5+`objectVersion=77`File System Synchronized Group |
| 工程入口 | `ylgamehall.xcodeproj`**无 CocoaPods**,不使用 workspace |
| 依赖管理 | SPM(首选)+ Vendor `.xcframework`(闭源 SDK |
| Bundle ID | `com.skyapp.ylgamehall` |
| 并发模型 | Swift async/await + actor 隔离(**不使用 Combine** |
### SPM 依赖
- `ZIPFoundation` — H5 zip 解压
- `HappyDNS` — 七牛 SDK 配套
- `Qiniu``qiniu/objc-sdk` v8.9.x — 七牛对象存储
### Vendor 闭源 SDK
放在 `Vendor/<SDKName>/`,每个目录附 `README.md` 记录版本来源:
- `Vendor/WechatSDK/` — 微信开放平台
- `Vendor/AMap/` — 高德定位
- `Vendor/OpenCoreAMR/` — AMR 编解码(`libopencore-amrnb.a` / `libopencore-amrwb.a`
---
## 目录结构
```
ylgamehall/
├── CLAUDE.md # AI 助手协作约定(第一权威)
├── README.md # 本文件
├── ylgamehall.xcodeproj # Xcode 工程
├── ylgamehall/ # App target
│ ├── AppDelegate.swift
│ ├── SceneDelegate.swift
│ ├── Info.plist
│ ├── ylgamehall-Bridging-Header.h
│ ├── Assets.xcassets
│ ├── Base.lproj/ # LaunchScreen.storyboard
│ ├── Resources/
│ │ ├── gamehall.zip # 内置 H5 包
│ │ ├── ChannelConfig.plist # 渠道配置(gameid / channel / agent / market 等)
│ │ ├── AppSecrets.plist # SDK 凭证(微信 AppID / 高德 Key / 七牛 等)
│ │ └── JS/
│ │ └── WebViewJavascriptBridge.js
│ └── Source/
│ ├── Audio/ # AMR 编解码、录音 / 播放、录音浮窗
│ ├── Bridge/ # WebViewJavascriptBridge + 各 Handler
│ │ └── Handlers/ # 39 项 H5↔Native 桥接 handler
│ ├── Coordinator/ # AppCoordinator 启动流水线
│ ├── Location/ # 高德定位封装
│ ├── Login/ # 微信登录
│ ├── Network/ # RemoteConfig、版本解析、七牛上传
│ ├── Resource/ # 沙盒路径、H5 zip 升级、解压、子游戏下载
│ ├── SDK/ # 微信 / 高德 SDK 包装
│ ├── Share/ # 微信 / QQ / 抖音分享 + 分享面板
│ └── WebView/ # WKWebView 容器、Splash、子游戏 VC
├── Vendor/ # 闭源 SDK
│ ├── WechatSDK/
│ ├── AMap/
│ └── OpenCoreAMR/
└── docs/ # 项目文档(见下节)
└── res/ # 维护者私人素材池(项目代码不感知)
```
---
## 文档体系
所有架构决策、契约定义、执行计划、验收清单都在 `docs/` 下。**任何代码修改前先读对应文档**。
| 文档 | 角色 | 何时读 |
|------|------|--------|
| [`CLAUDE.md`](./CLAUDE.md) | 项目协作约定、契约边界声明、两条核心原则 | **每次对话先读** |
| [`docs/H5-Native-Contract.md`](./docs/H5-Native-Contract.md) | H5 ↔ 原生 桥接**契约**(黑盒可观察行为) | 涉及桥接接口必读 |
| [`docs/H5-Native-Implementation-Design.md`](./docs/H5-Native-Implementation-Design.md) | 新外壳实施蓝图(架构、模块、代码骨架) | 实现新外壳必读 |
| [`docs/Development-Plan.md`](./docs/Development-Plan.md) | 开发执行计划(Phase 0–10、依赖图、进度追踪) | 排期 / 进度更新必读 |
| [`docs/Verification-Checklist.md`](./docs/Verification-Checklist.md) | 功能验证清单(统一验收的操作手册) | 阶段验收时勾选 |
| [`docs/SDK-Integration-Guide.md`](./docs/SDK-Integration-Guide.md) | 微信 / 高德 / opencore-amr / 七牛 接入手册 | 接 SDK 时按章节落地 |
| [`docs/H5-Debug-Guide.md`](./docs/H5-Debug-Guide.md) | H5 端调试(Safari Web Inspector | 调试 H5 console / Network / 断点时 |
| `../daoqi/` | 原项目 `msext` 工作参照实现 | 遇到行为差异 / 渠道注入 / SDK 时序等不确定问题时优先参考 |
> 文档优先级冲突时:**Contract > Design > Plan**。
---
## 核心原则(两条,原则 A 是第一准则)
### 原则 A:H5 端零修改(不可妥协)
H5 端 **一行代码、一个字符、一个文件名、一个调用时机都不许改**。遇到契约边界冲突时,**回到原 msext 老路**、由原生 Swift 端硬扛,哪怕代码看起来"不现代"。
必须 1:1 与现网一致的五类内容:
1. 桥接接口名(handler 字符串,含历史驼峰 / 下划线大小写)
2. 参数字段名(含 `"title "` 末尾空格、`Province` 大写 P 等历史包袱)
3. 参数数据结构(类型、嵌套、fallback)
4. H5 端注册方式(`WebViewJavascriptBridge` JS 协议 / 弹层 `window.settings.xxx`
5. 空实现接口也必须保留注册(`opensaoma``getGameplay` 等)
详见 [`docs/H5-Native-Contract.md`](./docs/H5-Native-Contract.md)。
### 原则 B:原生内部自由重构
原生内部一切自由——技术栈、模块结构、状态管理、SDK 选型、并发模型按现代 iOS 标准重新设计,不照搬旧项目实现细节。
**冲突时无条件选 A**:原生多一层适配 / 一段"看似过时"的代码可接受;契约破裂不可接受。
---
## 构建
### 前置
- macOS + Xcode 26.5+
- 项目方提供的 SDK 凭证(按 [`docs/SDK-Integration-Guide.md`](./docs/SDK-Integration-Guide.md) 填到 `Resources/AppSecrets.plist`
- Vendor 闭源 SDK 二进制(按 `Vendor/<SDK>/README.md` 放置)
### 构建步骤
```bash
open ylgamehall.xcodeproj
```
Xcode → Product → Run(或 ⌘R)。
> **不要**新建 `Podfile` / `.xcworkspace`。本项目通过 SPM + Vendor 两层管理依赖,禁止引入 CocoaPodsADR-006)。
### 渠道配置
`ylgamehall/Resources/ChannelConfig.plist` 是渠道注入入口(替代 msext 时代的"空目录名注入"机制):
| key | 说明 |
|---|---|
| `gameid` | 游戏 ID |
| `channel` / `gamedir` | 渠道标识 / H5 解压子目录名 |
| `gamestart` | H5 启动入口目录名(默认 `gamehall` |
| `gameconfig` | RemoteConfig 拉取地址 |
| `market` | 市场标识 |
| `agent` | 代理标识 |
| `appversion` | 当前外壳版本号(用于 RemoteConfig 升级判断) |
---
## 提交与协作
- commit message 使用中文短句,描述"做了什么 + 为什么"
- 改动通过最小验证(BuildProject / 关键路径手测 / 单测)后可直接 commit,不必每次确认
- 涉及桥接接口名 / 参数字段名 / 数据结构的改动必须在 commit message 中明示 **"契约影响"** 并附 [`docs/H5-Native-Contract.md`](./docs/H5-Native-Contract.md) 对应章节
- 每完成一个 Phase 子项立即同步勾选 [`docs/Development-Plan.md`](./docs/Development-Plan.md) §5 / §8
- 不在未授权情况下执行 `git push --force` / `git reset --hard` / `git rebase` 等不可逆操作
---
## 验收
新外壳完成后,**在不修改 H5 任何一行代码** 的前提下,逐项跑 [`docs/H5-Native-Contract.md`](./docs/H5-Native-Contract.md) §10 的 26 项验收清单。任意一项 H5 表现与现网(`../daoqi/msext`)不一致即视为契约违反。
+90 -3
View File
@@ -319,6 +319,40 @@ Contract Design Plan(本文档)
- BuildProject 通过 - BuildProject 通过
- [ ] **1.17** 把渠道目录里临时填入的 demo 值 + 一个 demo H5 跑通 / 真机验证完整 1.10-1.16 链路 - [ ] **1.17** 把渠道目录里临时填入的 demo 值 + 一个 demo H5 跑通 / 真机验证完整 1.10-1.16 链路
##### 1.F WKURLSchemeHandler 迁移(Cocos 适配前置 + 跨页 localStorage 共享,ADR-010
> Phase 1.F 是对 Phase 1.14.d / Phase 6.x `loadFileURL` 路径的**架构级替换**——把大厅 + 所有子游戏迁到 `ylgame://h5/...` 自定义 scheme,由 `AppSchemeHandler``WKURLSchemeHandler`)反向映射回沙盒。详见 ADR-010。三条触发动因:① 为未来 Cocos 子游戏 H5 build 适配铺路(file:// 下 Cocos `XHR/fetch` 受 null-origin 限制几乎必踩坑)② 大厅 + 子游戏共用单虚拟 host (`ylgame://h5`) → same-origin → **跨 H5 共享 localStorage**(现状 file:// 下也是同 null-origin 共享,A 方案显式保留此语义)③ 给 `<audio>/<video>` 提供 Range 请求支持。**H5 零改动红线已确认满足**(用户已 grep 验证现网 H5 无 `location.protocol === 'http:'` / 硬编码 `file://` / Service Worker 依赖三类问题)。
- [x] **1.18** 实现 `Source/WebView/AppSchemeHandler.swift`
- `AppScheme` enum 常量:scheme=`ylgame` / host=`h5` / lobbyPrefix=`lobby` / subGamePrefix=`subgame`,全部 `nonisolated`(项目 MainActor-by-default,未标会让 IO 队列无法访问)
- `@MainActor` 单例 `AppSchemeHandler.shared`,实现 `WKURLSchemeHandler` 两方法:`start` / `stop`
- 路由:`/lobby/<rest>``SandboxPaths.lobbyRoot/<rest>``/subgame/<dir>/<rest>``SandboxPaths.subGameRoot(<dir>)/<rest>`
- 读盘走后台并发队列 `ylgamehall.app-scheme-handler.io``.userInitiated`),结果 hop 回 MainActor 调 `didReceive/didFinish/didFailWithError`
- 取消语义:`stop` 加 key 到 `cancelledKeys` Set,背景 IO 完成后 deliver 见标志直接短路并清理;避免 stop 后向 task 发消息触发 NSException
- Sendable 安全:闭包只捕获 Sendable 的 `key` (`ObjectIdentifier`) + `resolved``ResolvedRequest`),不捕获非 Sendable 的 `any WKURLSchemeTask`
- Range 头解析:支持 `bytes=start-end` / `bytes=start-` / `bytes=-suffix`,返回 206 + `Content-Range`;不支持 multi-rangeCocos 不发)
- MIME 表:覆盖 Cocos H5 常用扩展(html / js / css / json / atlas / fnt / plist / wasm / png / jpg / webp / svg / mp3 / ogg / wav / mp4 / ttf / woff / woff2 等),未命中 `application/octet-stream`
- 响应头:`Cache-Control: no-cache`(沙盒升级会原地覆盖文件,禁强缓存)+ `Access-Control-Allow-Origin: *`(防御性,部分 Cocos build fetch 默认 cors mode 会走 preflight
- BuildProject 通过
- [x] **1.19**`Source/Resource/SandboxPaths.swift` 加 app:// URL builders
- `lobbyIndexAppURL: URL``ylgame://h5/lobby/<gameStart>/index.html`
- `subGameIndexAppURL(_ dir:, _ start:) -> URL``ylgame://h5/subgame/<dir>/<start>/index.html`
-`URLComponents` 组装避免手拼,`nonisolated` 与现有路径常量一致
- BuildProject 通过
- [x] **1.20** 三处 WebView 加载入口切换 + scheme handler 注册
- `BridgedWebView.init``configuration.setURLSchemeHandler(AppSchemeHandler.shared, forURLScheme: AppScheme.scheme)`(必须在 `WKWebView` init 之前注册)
- `WebContainerViewController.runBootPipelineSteps` step 7`loadFileURL(SandboxPaths.lobbyIndex, allowingReadAccessTo: lobbyRoot)``webView.load(URLRequest(url: SandboxPaths.lobbyIndexAppURL))`
- `SubGameViewController.runBootPipelineSteps` step 3:同款切换到 `subGameIndexAppURL(effectiveGameDir, request.gameStart)`
- `OverlayViewController` 不变(仍 `webView.load(URLRequest)` 加载 H5 给的外链 http/https URL
- BuildProject 通过
- [ ] **1.21** 真机验证 Phase 1.F 等价性(与 1.17 一起跑)
- 大厅 H5 启动正常、不黑屏、splash 淡出时机不变
- localStorage 跨大厅 ↔ 子游戏共享语义保持(手动验证:大厅写一项、push 子游戏读到同名 key)
- `app_*.js` 同步 `<script src>` 引入正常(H5 顶层 `var x = app_xxx` 拿到实际渠道值,不是 H5 zip 内默认占位)
- 升级路径:模拟远端 gameVersion +1 重启,handler 在 lobby zip 升级覆盖目录后立即读到新文件(`Cache-Control: no-cache` 起效)
- 子游戏切换:H5 调 `SwitchOverGameData` push 子游戏 VC,子游戏 H5 加载正常,pop 回大厅 localStorage 数据保留
- 备注:**老用户 file:// → ylgame:// 一次性 localStorage 清零**origin 切换不可避免),已与项目方确认业务可接受、不做迁移补偿
#### 验收 #### 验收
- 真机启动后能看到 H5 demo 页(首次会先经历"远程配置拉取"流程) - 真机启动后能看到 H5 demo 页(首次会先经历"远程配置拉取"流程)
@@ -326,6 +360,7 @@ Contract Design Plan(本文档)
- H5 收到 `responseCallback("vibrator")` - H5 收到 `responseCallback("vibrator")`
- 模拟 `game_version` 远端 +1,重启 App 后能看到 zip 重新下载 + 解压 + 加载新版本 - 模拟 `game_version` 远端 +1,重启 App 后能看到 zip 重新下载 + 解压 + 加载新版本
- BuildProject 通过;契约测试 `VibratorContractTest` + 单测 `VersionResolverTests``RemoteConfigClientTests` 通过 - BuildProject 通过;契约测试 `VibratorContractTest` + 单测 `VersionResolverTests``RemoteConfigClientTests` 通过
- **Phase 1.F 追加**H5 加载从 `file://` 迁到 `ylgame://h5/...`,大厅 + 子游戏 same-origin 跨页共享 localStorage`<audio>` Range seek 正常
#### 风险 #### 风险
@@ -894,6 +929,10 @@ H5 调 `OpenurlTitleData` 打开弹层 WebView,弹层内 H5 用 `window.settin
- [x] 1.15 VibratorHandler(单次振动,AudioServicesPlaySystemSound + responseCallback "vibrator" - [x] 1.15 VibratorHandler(单次振动,AudioServicesPlaySystemSound + responseCallback "vibrator"
- [x] 1.16 SceneDelegate → WebContainerViewController(删除 M0 占位 RootViewController.swift - [x] 1.16 SceneDelegate → WebContainerViewController(删除 M0 占位 RootViewController.swift
- [ ] 1.17 Demo H5 联调 + 真机验证升级路径 - [ ] 1.17 Demo H5 联调 + 真机验证升级路径
- [x] 1.18 AppSchemeHandlerWKURLSchemeHandler 单例,ylgame://h5 路由 + Range + MIME 表 + 异步 IO + 取消语义,ADR-010
- [x] 1.19 SandboxPaths.lobbyIndexAppURL / subGameIndexAppURLURLComponents 拼装)
- [x] 1.20 BridgedWebView 注册 scheme handler + WebContainer/SubGame loadFileURL → webView.load(URLRequest) 切换
- [ ] 1.21 真机验证 Phase 1.F 等价性(与 1.17 一起跑:跨页 localStorage 共享 / app_*.js 同步引入 / 升级路径 / 子游戏切换)
### Phase 2 大厅简单 handler + 反向 callback ### Phase 2 大厅简单 handler + 反向 callback
- [x] 2.1 剪贴板(ClipboardHandler.swift - [x] 2.1 剪贴板(ClipboardHandler.swift
@@ -1259,8 +1298,12 @@ ResolvedVersion(
| 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 | | 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 |
| 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 | | 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 |
| 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`4 层 fallback + 顶层兜底) | 后台运维管理,按渠道差异化下发 | | 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`4 层 fallback + 顶层兜底) | 后台运维管理,按渠道差异化下发 |
- **远端注入时序**`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolveAudio(...)` 解析(与版本字段同款 4 层 fallbackagent → game → channel → market 倒序找第一个非空,整链空时 fallback 到顶层 `audio_domain` / `audio_bucket`),再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停 - **远端注入时序**`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolve(...)` 一次得到所有字段(含 `audioDomain` / `audioBucket`),再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停
- **2026-06-27 修订**:初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取,实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底(同 `app_version` / `game_zip` 等版本字段的 fallback 算法),节点与顶层任一处声明即可 - **统一接口(硬约束)**:远端 RemoteConfig 所有字段的获取**必须**走 `VersionResolver.resolve(...)` 单一入口、走同一套 5 层 fallbackroot(顶层)→ agent → game → channel → market,倒序找第一个非空。`RemoteConfig` 自身 conform `RemoteConfigNode` 协议作为链最浅一层兜底,因此 `pickString(chain, \.xxx)` 一行即包含顶层兜底,无需 `?? config.xxx` 这类特殊写法。新增字段:加进 `RemoteConfigNode` 协议 + 5 个 struct(含 `RemoteConfig`+ `ResolvedVersion` + `resolve()` 内一行 `pick*`
- **2026-06-27 修订**
- 初版 `audio_domain` / `audio_bucket` 只从 RemoteConfig 顶层读取,实测渠道方习惯把这俩字段放 agent 节点下 → 客户端读不到误报"音频服务暂不可用"。改为 4 层 fallback + 顶层兜底(同 `app_version` / `game_zip` 等字段算法)。
- 同日二次:曾短暂拆 `resolve()` / `resolveAudio()` 两个公开入口,违反"单一接口"原则。已合并为单一 `resolve()`,所有字段(版本 + 音频 + showmessage)一次返回
- 同日三次:把 `RemoteConfig` 顶层也作为节点参与 fallback 链(链头 = 根),删掉 `showmessage / audioDomain / audioBucket` 三处特殊的 `?? config.xxx` 兜底;`appVersion / appDownload / gameVersion / gameZip` 也获得顶层兜底能力。所有字段真正完全统一
- **类型设计** - **类型设计**
- `AppSecrets`:与 `BundleConfig` 同款 `nonisolated public final class Sendable`3 个不可变 String 属性 - `AppSecrets`:与 `BundleConfig` 同款 `nonisolated public final class Sendable`3 个不可变 String 属性
- `QiniuConfig`:从 `enum` 改为 `actor``accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async - `QiniuConfig`:从 `enum` 改为 `actor``accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async
@@ -1276,5 +1319,49 @@ ResolvedVersion(
--- ---
### ADR-010WebView 加载从 `file://` 迁到 `ylgame://h5/...`WKURLSchemeHandler2026-06-29
- **背景**Phase 1.14.d 大厅 + Phase 6.x 子游戏都用 `webView.loadFileURL(_:allowingReadAccessTo:)` 加载 H5。这一路径上线后会面临两个外部驱动的兼容性压力:
1. **未来 Cocos 子游戏 H5 build 接入**Cocos 引擎 `assetManager` 用 XHR/fetch 加载 JSON / atlas / 音频 / 纹理;WKWebView 下 `file://` origin 是 nullXHR/fetch 触发 CORS null-origin 拒绝,Cocos 启动期资源加载几乎必踩坑
2. **跨页 localStorage 共享**:现状 file:// 下因 origin = null 让大厅 + 子游戏天然共享 localStorage(业务依赖);任何不同 origin 的迁移方案都会破坏这一语义
- **可选方案对比**
| 方案 | localStorage 共享 | Cocos 适配 | 离线启动 | 端口冲突 | 三方依赖 | 维护成本 |
|---|---|---|---|---|---|---|
| A:自定义 scheme `ylgame://h5/...` + `WKURLSchemeHandler` | ✅ 单虚拟 host (`h5`),所有页 same-origin | ✅ 同源 XHR/fetch 通畅 | ✅ | 无 | 无(系统 API | 自实现 handler(约 300 行 Swift |
| B:内置 GCDWebServer (`http://localhost:port/...`) | ✅ | ✅(行为最接近浏览器) | ✅ | 有 | 一个新 C 依赖 | 端口探测 + 后台挂起期生命周期 |
| C:远程真域名 | ✅(远端 origin 也共享) | ✅ | ❌ 启动期必须有网 | 无 | 无 | 启动模式完全打破 |
| D:保持 file:// | ✅(null origin 共享) | ❌ Cocos XHR 死路 | ✅ | 无 | 无 | 0 |
- **决策:选 A**:在不引入新依赖、不打破离线启动模式、不破坏 localStorage 共享语义的前提下,把所有 H5 入口迁到 `ylgame://h5/lobby/...` / `ylgame://h5/subgame/<dir>/...`。`AppSchemeHandler` 单例反向映射 path → 沙盒文件读盘,支持 Range / MIME / 异步 IO / 取消语义
- **H5 零改动红线**CLAUDE.md 原则 A 第一准则):项目方已确认现网 H5 不踩三类红线(`location.protocol === 'http:'` 字面比较 / 硬编码 `file://` 拼链接 / Service Worker 依赖),cookies 也未使用,仅需保留 localStorage 共享语义
- **URL 命名(带项目前缀)**:
- scheme = `ylgame`(避免与其它 app 自定义 scheme 冲突)
- 单虚拟 host = `h5`(决定 origin,所有 H5 页必须共用)
- 大厅:`ylgame://h5/lobby/<gameStart>/index.html`
- 子游戏:`ylgame://h5/subgame/<dir>/<start>/index.html`
- **关键实现要点**
- **背景 IO 队列读盘**,结果 hop 回 MainActor deliver;闭包只捕获 Sendable 的 `ObjectIdentifier` key,不捕获非 Sendable 的 `any WKURLSchemeTask`
- **取消语义**`stop` 把 key 加入 `cancelledKeys`IO 完成后 deliver 见标志直接短路并清理两张表,避免 stop 后向 task 发消息触发 NSException
- **Range 请求**:解析 `bytes=start-end` / `start-` / `-suffix` → 206 + `Content-Range`Cocos `<audio>` seek 必需)
- **MIME 表**:覆盖 Cocos 常用扩展(atlas / fnt / plist / wasm / webp / ogg / m4a / woff2 等),未命中 `application/octet-stream`
- **响应头**`Cache-Control: no-cache`(沙盒升级原地覆盖文件)+ `Access-Control-Allow-Origin: *`(防御部分 Cocos build fetch 默认 cors mode
- **WKWebsiteDataStore.default()**:所有 WebView 沿用默认(已有),确保 localStorage 持久化跨实例共享
- **行为对比(迁移前后等价性)**:
| 行为 | file:// 现状 | ylgame://h5 新方案 |
|---|---|---|
| 大厅 / 子游戏 H5 加载 | loadFileURL | webView.load(URLRequest) |
| 跨页 localStorage 共享 | null origin 共享 | 同 host (`h5`) same-origin 共享 |
| `app_*.js` 同步 `<script src>` 引入 | 沙盒文件同目录 | 同左(handler 透明读相同物理路径) |
| XHR / fetch 资源 | null origin 受限 | 同源直通 |
| `<audio>` seek (Range) | WKWebView 原生支持 | handler 实现 206 |
| 跨目录引用 (`../foo.js`) | allowingReadAccessTo: lobbyRoot | path 自然落回 lobbyRoot |
| localStorage 持久化 | WKWebsiteDataStore.default() | 同左 |
- **存量用户影响**file:// → ylgame:// origin 切换时**老用户 localStorage 一次性清零**WebKit 不会跨 origin 自动迁移)。项目方已确认业务可接受、不做迁移补偿(如未来发现某条数据丢失影响大,可加"隐藏 file:// WebView 读出 storage → 写入 ylgame:// origin"的一次性桥接迁移)
- **回滚条件**:若发现某款 Cocos 子游戏在 `ylgame://` 下踩到无法绕过的红线(如硬编码 `location.protocol` 检查),优先:① 单游戏开 `http://localhost` 逃生门(per-game 配置走 B 方案),不退回全局 file://;② 把"`WebContentLoader` 加载入口"抽象成协议,主路径 A、单游戏 B 共存
- **影响 Phase 6 / Phase 10**
- Phase 6 子游戏 push 已自动复用 BridgedWebView 配置 → handler 自动生效,无需额外改动
- Phase 10 灰度时把 Phase 1.21 验证清单纳入回归矩阵(跨页 localStorage / Cocos 接入预检脚本)
---
文档完成日期:2026-06-21 文档完成日期:2026-06-21
最后更新:2026-06-27ADR-009 凭证集中化 + AppSecrets.plist + 七牛运行参数远端注入 + ChannelConfig 11→10 key;同日 ADR-008 第三轮修订:VersionResolver 单链 4 层 fallback;前置历史 2026-06-22 ADR-008 二次精确化 / ADR-007 渠道注入改 ChannelConfig.plist / ADR-006 纯 SPM + Vendor / ADR-005 极光降级 / Resources 目录记录 最后更新:2026-06-29ADR-010 WebView 加载从 file:// 迁到 ylgame://h5 自定义 scheme + Phase 1.F 三个子项落地;前置历史 2026-06-27 ADR-009 凭证集中化 / 2026-06-22 ADR-008 三轮修订 / ADR-007 ChannelConfig.plist / ADR-006 纯 SPM + Vendor / ADR-005 极光降级)
+76 -3
View File
@@ -172,11 +172,42 @@ Safari "开发" 菜单会把每个**已加载页面**的 `WKWebView` 列为**独
### Q3:能看到条目但 Console 是空的 ### 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)再看 - Web Inspector 打开前的旧日志没被记录 → 在 Web Inspector 已经打开的状态下**触发一次** H5 操作(点按钮、切 tab)再看
- Console 顶部过滤器把日志级别过滤掉了 → 确认 All Levels 都选中 - Console 顶部过滤器把日志级别过滤掉了 → 确认 All Levels 都选中
- 连错 WebView3 个实例 title 都是 `gameabc`)→ 敲 `typeof Game_Config`,返回 `"undefined"` 说明不是大厅那个,按 §5.2 用 hover 高亮法重选
- H5 端确实没有 `console.*` 调用(业务在沉默运行,没问题就不输出)
### Q4Sources 里看不到 .js 源码 ### Q4Sources 里看不到 .js 源码
@@ -200,9 +231,49 @@ Safari Web Inspector 是开发期主力,但有它够不到的场景:
- **真机 + 用户已在生产环境**Release 包没有 inspectable;需要的是事后日志,参考原生侧的崩溃 / 错误上报(项目目前无 crash 监控,依赖用户反馈) - **真机 + 用户已在生产环境**Release 包没有 inspectable;需要的是事后日志,参考原生侧的崩溃 / 错误上报(项目目前无 crash 监控,依赖用户反馈)
- **断网 / 弱网下的 H5 表现**:用 Mac 上的 Network Link Conditioner,或模拟器 Features → Network Link Conditioner - **断网 / 弱网下的 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 同时打开,两边各自下断点互不影响 - **断点调试原生 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. 修订记录 ## 9. 修订记录
@@ -210,3 +281,5 @@ Safari Web Inspector 是开发期主力,但有它够不到的场景:
| 日期 | 内容 | | 日期 | 内容 |
|------|------| |------|------|
| 2026-06-24 | 文档建立。落地 `BridgedWebView` / `OverlayViewController` 两处 `isInspectable` 补丁。 | | 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」的报错通路:资源 404error 事件捕获阶段)、WebView 加载失败 / WebContent 进程终止(两个 VC 的 `WKNavigationDelegate`,此前完全没实现)。输出加 `lobby` / `subGame` 前缀。新增 §8.2 已知盲区、§8.3 设计要点。 |
+79 -10
View File
@@ -46,7 +46,8 @@ iOS 26 时代 iOS 9 路径再也跑不到了(最低支持 iOS 9.0,但所有
- H5 源码以 `gamehall.zip`(≈ 11 MB)形式打入 Bundle - H5 源码以 `gamehall.zip`(≈ 11 MB)形式打入 Bundle
- 首次启动解压到沙盒 `Library/Caches/{gamedir}/``gamedir` 是渠道注入值) - 首次启动解压到沙盒 `Library/Caches/{gamedir}/``gamedir` 是渠道注入值)
- 启动入口固定为:`Library/Caches/{gamedir}/{gamestart}/index.html` - 启动入口固定为:`Library/Caches/{gamedir}/{gamestart}/index.html`
- WebView 直接 `loadFileURL:` 加载,**走 file:// 协议**,不依赖 HTTP 服务器 - **加载方式(msext 现网行为)**:`loadFileURL:``file://` 协议,不依赖 HTTP 服务器
- **新外壳(Phase 1.F 起)**:迁到自定义 scheme `ylgame://h5/lobby/<gameStart>/index.html` + `WKURLSchemeHandler`,反向映射回相同沙盒物理路径。H5 可观察差异 = `location.protocol``"file:"` 变为 `"ylgame:"`;项目方已 grep 确认现网 H5 不依赖此字面。详见 `docs/H5-Native-Implementation-Design.md` §7.6 / Plan ADR-010
- 启动 query 参数:旧桥附 `?Launchtype=0`,新桥未附;新桥额外注入 `app_data.js`/`app_battery.js`/`app_network.js` - 启动 query 参数:旧桥附 `?Launchtype=0`,新桥未附;新桥额外注入 `app_data.js`/`app_battery.js`/`app_network.js`
### 0.3 渠道注入机制("空目录名注入") ### 0.3 渠道注入机制("空目录名注入")
@@ -378,9 +379,9 @@ window.rootViewController = NavgationController
**流程** **流程**
1. 下载 AMR 到本地 1. 下载 AMR 到本地
2. `VoiceConverter ConvertAmrToWav` 转 WAV 2. `VoiceConverter ConvertAmrToWav` 转 WAV
3. `AVAudioPlayer` 播放 3. `AVAudioPlayer` 播放(若当前已有语音在播,先停旧、给 H5 发 `gameui_stop_voice(被打断 user)` 再起新播放)
4. 开始播放时 callback H5`gameui_play_voice(user)` (见 §3.2 7]) 4. 开始播放时 callback H5`gameui_play_voice(user)` (见 §3.2 7])
5. 播放结束 callback H5`gameui_stop_voice(user)` (见 §3.2 8]) 5. 播放结束 / 被打断 callback H5`gameui_stop_voice(user)` (见 §3.2 8])
##### 【6】 `voicePlaying` — 语音播放总开关 ##### 【6】 `voicePlaying` — 语音播放总开关
@@ -562,7 +563,7 @@ window.rootViewController = NavgationController
| 5 | `shakeEnd` | motionEnded 摇一摇结束 | `nil` | ✓ | ✓ | | 5 | `shakeEnd` | motionEnded 摇一摇结束 | `nil` | ✓ | ✓ |
| 6 | `getlocationinfo` | 定位成功/失败 | 见下方表 B | ✓ | ✓ | | 6 | `getlocationinfo` | 定位成功/失败 | 见下方表 B | ✓ | ✓ |
| 7 | `gameui_play_voice` | mediaTypeAudio 开始播放(且 voicePlaying=1 时)| 字符串 user_id | ✓ | ✓ | | 7 | `gameui_play_voice` | mediaTypeAudio 开始播放(且 voicePlaying=1 时)| 字符串 user_id | ✓ | ✓ |
| 8 | `gameui_stop_voice` | mediaTypeAudio AVAudioPlayer 播放结束 | 字符串 user_id(最近一次 mediaTypeAudio 的 user 字段| ✓ | ✓ | | 8 | `gameui_stop_voice` | mediaTypeAudio AVAudioPlayer 播放结束 / 被新一次 mediaTypeAudio 打断 / 解码失败 | 字符串 user_id(**该次结束 / 被打断的语音**所绑定的 user,不是入参里最新的 user — 与 daoqi 旧实现语义不同,详见下方 `gameui_stop_voice` 行为说明| ✓ | ✓ |
| 9 | `getaudiourl` | 录音 + 上传成功(七牛或旧 PostFile)| `{ "audiourl": "<七牛 URL>", "time": "<秒数字符串>" }` | ✓ | ✓ | | 9 | `getaudiourl` | 录音 + 上传成功(七牛或旧 PostFile)| `{ "audiourl": "<七牛 URL>", "time": "<秒数字符串>" }` | ✓ | ✓ |
| 10 | `sharelogin` | 微信授权 OAuth 拿到 userinfo 后 | 见下方表 C | ✓ | ✓ | | 10 | `sharelogin` | 微信授权 OAuth 拿到 userinfo 后 | 见下方表 C | ✓ | ✓ |
| 11 | `sharesuccess` | 分享完成(微信 SendMessageToWXResp / 闲聊 finishBlock| `{ "success": "2", "type": "<sharefriend 原值如 '1' 或 '2'>" }` | ✓ | ✓ | | 11 | `sharesuccess` | 分享完成(微信 SendMessageToWXResp / 闲聊 finishBlock| `{ "success": "2", "type": "<sharefriend 原值如 '1' 或 '2'>" }` | ✓ | ✓ |
@@ -585,6 +586,21 @@ H5 端 N 年来收到的实际语义就是:**`"2"` = 进入后台、`"1"` =
(顺带:原 msext 还有一条 iOS<9 UIWebView 路径 `RootVC.m:516-543` / `fourviewVC.m:1254-1282`,发的值反过来:进入后台 → `"1"` / 回到前台 → `"2"`。该路径在新外壳最低 iOS 15.6 下完全不可达,**不能作为对齐依据**,请忽略。) (顺带:原 msext 还有一条 iOS<9 UIWebView 路径 `RootVC.m:516-543` / `fourviewVC.m:1254-1282`,发的值反过来:进入后台 → `"1"` / 回到前台 → `"2"`。该路径在新外壳最低 iOS 15.6 下完全不可达,**不能作为对齐依据**,请忽略。)
**`gameui_stop_voice` 行为说明**(与 daoqi 旧实现的有意差异,用户反馈驱动):
daoqi `NewRootVC.m:1736-1748` / `gameController.m:1288-1308``voicePlayer` / `self.User_id` 都作为 VC 成员变量。覆盖式播放(前一条还在播,第二条到来)的代码路径:
1. `self.User_id = userid;` 先覆盖最新 user
2. `voicePlayer = [[AVAudioPlayer alloc]...]` 直接换 ivarMRC 下旧 player 实际泄漏继续在播)
3. 旧 player 自然播完触发 `audioPlayerDidFinishPlaying:` → 调 `stopradio``callHandler:@"gameui_stop_voice" data:self.User_id`
结果:旧消息对应的 `gameui_stop_voice` 发出去的 user_id **实际是新消息的 user_id**;H5 收不到旧消息的停止动画信号,旧动画一直转,新消息那条反而停了。
新外壳实现(`VoicePlayer.swift`)把 user 与 player 一一绑定(`currentUser`):
- 覆盖式 play 时**主动**停旧 player,并用旧 player 绑定的 user 触发一次 `gameui_stop_voice`,再起新播放 + `gameui_play_voice(新 user)`
- 自然结束 / 解码失败:用结束那个 player 绑定的 user 发 `gameui_stop_voice`,不再读"最近一次入参"
H5 端无需任何改动:仍然按照"收到 `gameui_stop_voice(user)` 就停这个 user 的动画"处理即可,只是信号现在会更准。验证场景:H5 连发两条 `mediaTypeAudio`user A → user BA 还没播完 B 就来),现在 H5 会先收 `gameui_stop_voice(A)` 再收 `gameui_play_voice(B)`B 播完再收 `gameui_stop_voice(B)`。daoqi 旧路径下 A 那次停止信号永远收不到。
**表 A — `getphoneinfo`** **表 A — `getphoneinfo`**
```json ```json
{ {
@@ -600,13 +616,14 @@ H5 端 N 年来收到的实际语义就是:**`"2"` = 进入后台、`"1"` =
**表 B — `getlocationinfo`** (成功): **表 B — `getlocationinfo`** (成功):
```jsonc ```jsonc
{ {
"errorCode": 0, // ⚠️ 新外壳补齐项,msext 遗漏,见下方说明
"address": "<完整地址>", "address": "<完整地址>",
"city": "<市>", "city": "<市>",
"cityCode": "<城市编码>", "cityCode": "<城市编码>",
"country": "<国>", "country": "<国>",
"district": "<区>", "district": "<区>",
"latitude": "30.567890", // ⚠️ string,原生 stringWithFormat:@"%f",不是 double "latitude": 30.567890, // ⚠️ 数字(非字符串),6 位小数。新外壳修正项,见下方说明
"longitude": "104.123456", // ⚠️ string "longitude": 104.123456, // ⚠️ 数字(非字符串),6 位小数
"province": "<省>", // ⚠️ 小写 p,与 sharelogin 的 "Province" 大写形成不一致,沿用历史 "province": "<省>", // ⚠️ 小写 p,与 sharelogin 的 "Province" 大写形成不一致,沿用历史
"street": "<街道>" "street": "<街道>"
} }
@@ -616,6 +633,54 @@ H5 端 N 年来收到的实际语义就是:**`"2"` = 进入后台、`"1"` =
{ "errorCode": 12, "errorMsg": "缺少定位权限" } // errorCode 是 NSNumberJSON 看是数字 12 { "errorCode": 12, "errorMsg": "缺少定位权限" } // errorCode 是 NSNumberJSON 看是数字 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`** **表 C — `sharelogin`**
```jsonc ```jsonc
{ {
@@ -696,9 +761,11 @@ _webView.scrollView.showsHorizontalScrollIndicator = NO;
``` ```
- 不设置自定义 User-Agent - 不设置自定义 User-Agent
- 不显式管理 Cookie - 不显式管理 Cookie(业务确认现网 H5 不用 cookies)
- LocalStorage 由 H5 自管(WKWebView 默认走 WebsiteDataStore - LocalStorage 由 H5 自管(WKWebView 默认走 `WKWebsiteDataStore.default()` 持久化);**跨 H5 共享语义**:大厅 + 所有子游戏共享同一 localStorage 命名空间(业务依赖)。msext 现网 file:// 下因 null origin 天然共享;新外壳 Phase 1.F 起用 `ylgame://h5/...` 单虚拟 host same-origin 保留此语义
- 加载方式:**`loadFileURL:fileURL allowingReadAccessToURL:dirURL`**file://),不走 HTTP - 加载方式:
- **msext 现网**`loadFileURL:fileURL allowingReadAccessToURL:dirURL`file://),不走 HTTP
- **新外壳(Phase 1.F 起)**`webView.load(URLRequest(url: ylgame://h5/...))` + `WKURLSchemeHandler` 反向映射;行为对 H5 等价(详见 Design §7.6.5 等价性表 / Plan ADR-010
### 4.2 JS 注入(最终路径:原生 `writeToFile` 写文件,与 msext 1:1 等价) ### 4.2 JS 注入(最终路径:原生 `writeToFile` 写文件,与 msext 1:1 等价)
@@ -1035,7 +1102,9 @@ Library/Caches/
- [ ] 重装 + 热启动:< 1 秒进入大厅 - [ ] 重装 + 热启动:< 1 秒进入大厅
- [ ] 渠道注入:把 `msext/channel/xxx` 目录名改成 `yyy`,重打包后大厅显示的渠道 ID 变为 `yyy` - [ ] 渠道注入:把 `msext/channel/xxx` 目录名改成 `yyy`,重打包后大厅显示的渠道 ID 变为 `yyy`
- [ ] iOS 18 / 19 / 26 上启动不卡死 - [ ] iOS 18 / 19 / 26 上启动不卡死
- [ ] **`app_*.js` 4 个文件在沙盒**`{gamedir}/{gamestart}/``app_data.js` / `app_battery.js` / `app_network.js` / `app_gamesname.js` 全部存在、loadFileURL 前已落盘(`xcrun simctl get_app_container booted <bundle>` + `cat` 验证) - [ ] **`app_*.js` 4 个文件在沙盒**`{gamedir}/{gamestart}/``app_data.js` / `app_battery.js` / `app_network.js` / `app_gamesname.js` 全部存在、`webView.load(...)` 前已落盘(`xcrun simctl get_app_container booted <bundle>` + `cat` 验证)
- [ ] **`window.location` 切到自定义 scheme**Phase 1.F 起):H5 console `location.protocol` === `"ylgame:"``location.host` === `"h5"`;同样地大厅 + 子游戏 host 必须完全相同(保证 same-origin
- [ ] **跨页 localStorage 共享**:大厅 H5 `localStorage.setItem('k','v')` → push 子游戏 → 子游戏 `localStorage.getItem('k')` === `'v'`pop 回大厅数据保留
- [ ] **`app_*` 12 + 1 + 1 + 1 = 15 个全局变量命名 100% 正确**(参 §4.2):H5 console 检查 `app_version` / `app_gameconfig` / `app_gamedir` / `app_gamestart` / `app_agent` / `app_appversion` / `app_market` / `app_channel` / `app_Launchtype` / `app_getwifisignalLevel` / `app_gamename` / `app_invitationcode` / `app_getbattery` / `app_getnetwork` / `app_gamesname` 全部 `!== undefined`;尤其大小写 `app_Launchtype`L 大写)/ `app_getwifisignalLevel`wifi 小写 + signal/Level 区分)/ `app_getbattery``app_getnetwork`(带 get 前缀)必须精确 - [ ] **`app_*` 12 + 1 + 1 + 1 = 15 个全局变量命名 100% 正确**(参 §4.2):H5 console 检查 `app_version` / `app_gameconfig` / `app_gamedir` / `app_gamestart` / `app_agent` / `app_appversion` / `app_market` / `app_channel` / `app_Launchtype` / `app_getwifisignalLevel` / `app_gamename` / `app_invitationcode` / `app_getbattery` / `app_getnetwork` / `app_gamesname` 全部 `!== undefined`;尤其大小写 `app_Launchtype`L 大写)/ `app_getwifisignalLevel`wifi 小写 + signal/Level 区分)/ `app_getbattery``app_getnetwork`(带 get 前缀)必须精确
- [ ] **值是实际渠道值而非占位**H5 console 读 `app_channel` 应为 `ChannelConfig.plist` 的 channel 值(不是 H5 团队 zip 内可能预留的占位字符串) - [ ] **值是实际渠道值而非占位**H5 console 读 `app_channel` 应为 `ChannelConfig.plist` 的 channel 值(不是 H5 团队 zip 内可能预留的占位字符串)
- [ ] **大厅 vs 子游戏字面差异**:大厅 H5 内 `app_Launchtype === 0`(数字 0,非字符串 `"0"`)、子游戏 H5 内 `app_Launchtype === 1` - [ ] **大厅 vs 子游戏字面差异**:大厅 H5 内 `app_Launchtype === 0`(数字 0,非字符串 `"0"`)、子游戏 H5 内 `app_Launchtype === 1`
+138 -8
View File
@@ -508,10 +508,10 @@ public enum OverlayBridge {
| getter | 返回类型 | 业务语义 | 数据源 | | getter | 返回类型 | 业务语义 | 数据源 |
|---|---|---|---| |---|---|---|---|
| `getchannelName()` | string | 渠道 ID10 项渠道注入之一)| `BundleConfig.shared.channel` | | `getchannelName()` | string | 渠道 ID11 项渠道注入之一)| `BundleConfig.shared.channel` |
| `getmarketname()` | string | 市场 ID10 项渠道注入之一)| `BundleConfig.shared.market` | | `getmarketname()` | string | 市场 ID11 项渠道注入之一)| `BundleConfig.shared.market` |
| `getOther()` | string | 渠道 `other` 字段 | `BundleConfig.shared.other` | | `getOther()` | string | 渠道 `other` 字段 | `BundleConfig.shared.other` |
| `getothername(name)` | string | 按 H5 传入 key 动态读 10 项渠道注入任意字段 | `BundleConfig.shared.value(forKey: name)` | | `getothername(name)` | string | 按 H5 传入 key 动态读 11 项渠道注入任意字段 | `BundleConfig.shared.value(forKey: name)` |
| `getcompareCode()` | int | 业务校验码(msext `RootVC.m` 沿用 zip 版本号或固定值) | 待原 msext 取值确认(Phase 2 实施时查 `RootVC.m:1560` 附近 `getcompareCode` 真实返回值,并对齐) | | `getcompareCode()` | int | 业务校验码(msext `RootVC.m` 沿用 zip 版本号或固定值) | 待原 msext 取值确认(Phase 2 实施时查 `RootVC.m:1560` 附近 `getcompareCode` 真实返回值,并对齐) |
| `getbattery()` | double | 当前电池电量 0.01.0 | `UIDevice.current.batteryLevel`(启动期 snapshot 一次) | | `getbattery()` | double | 当前电池电量 0.01.0 | `UIDevice.current.batteryLevel`(启动期 snapshot 一次) |
| `getnetwork()` | int | 当前网络类型(0 无 / 1 WiFi / 2 蜂窝) | `NWPathMonitor` 当前 pathloadFileURL 前 snapshot | | `getnetwork()` | int | 当前网络类型(0 无 / 1 WiFi / 2 蜂窝) | `NWPathMonitor` 当前 pathloadFileURL 前 snapshot |
@@ -521,7 +521,7 @@ public enum OverlayBridge {
**为什么不走 BridgeBus 异步 callback**H5 端代码形式是 `var ch = window.settings.getchannelName()``var b = window.settings.getbattery()` 等**同步表达式**(取值后立即用于业务判断),WKWebView 时代 native 无法同步返回 JS 值(异步 evaluateJavaScript 改不了 H5 端代码 → 违反契约原则 A)。唯一不破契约的实现路径:**WebView 加载前在 `documentStart` 注入完整数据快照 + 同步 JS getter polyfill**,本地查询零延迟。 **为什么不走 BridgeBus 异步 callback**H5 端代码形式是 `var ch = window.settings.getchannelName()``var b = window.settings.getbattery()` 等**同步表达式**(取值后立即用于业务判断),WKWebView 时代 native 无法同步返回 JS 值(异步 evaluateJavaScript 改不了 H5 端代码 → 违反契约原则 A)。唯一不破契约的实现路径:**WebView 加载前在 `documentStart` 注入完整数据快照 + 同步 JS getter polyfill**,本地查询零延迟。
**数据快照时机** **数据快照时机**
- 静态字段(渠道 / market / other / appVersion 等 10 项渠道注入):app 启动期读 `ChannelConfig.plist` 后即不变,全程一次即可 - 静态字段(渠道 / market / other / appVersion 等 11 项渠道注入):app 启动期读 `ChannelConfig.plist` 后即不变,全程一次即可
- 动态字段(getbattery / getnetwork):**每次 `loadFileURL` 前重新 snapshot 注入**(精度足够,原 msext 自身也只在 `viewDidLoad` 取一次,H5 业务里"启动时刻电量值"被复用整个会话) - 动态字段(getbattery / getnetwork):**每次 `loadFileURL` 前重新 snapshot 注入**(精度足够,原 msext 自身也只在 `viewDidLoad` 取一次,H5 业务里"启动时刻电量值"被复用整个会话)
- 已安装子游戏列表(getGameinstall):每次 loadFileURL 前扫描沙盒 + 注入 → SwitchOverGameData 解压新子游戏后自然在下次 loadFileURL 刷新 - 已安装子游戏列表(getGameinstall):每次 loadFileURL 前扫描沙盒 + 注入 → SwitchOverGameData 解压新子游戏后自然在下次 loadFileURL 刷新
@@ -552,7 +552,7 @@ public enum SettingsBridgePolyfill {
window.__nativeSnapshot = \(json); window.__nativeSnapshot = \(json);
window.settings = window.settings || {}; window.settings = window.settings || {};
// ── 10 项渠道注入(静态,启动期一次性快照)──────────── // ── 11 项渠道注入(静态,启动期一次性快照)────────────
window.settings.getchannelName = function() { return window.__nativeSnapshot.channel || ""; }; window.settings.getchannelName = function() { return window.__nativeSnapshot.channel || ""; };
window.settings.getmarketname = function() { return window.__nativeSnapshot.market || ""; }; window.settings.getmarketname = function() { return window.__nativeSnapshot.market || ""; };
window.settings.getOther = function() { return window.__nativeSnapshot.other || ""; }; window.settings.getOther = function() { return window.__nativeSnapshot.other || ""; };
@@ -583,7 +583,7 @@ public enum SettingsBridgePolyfill {
} }
public struct Snapshot: Sendable { public struct Snapshot: Sendable {
public let channelConfig: [String: String] // 10 public let channelConfig: [String: String] // 11
public let channel: String public let channel: String
public let market: String public let market: String
public let other: String public let other: String
@@ -1818,9 +1818,11 @@ Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownlo
**契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 `ChannelConfig.plist` 等价实现** —— 契约边界(H5 通过 `app_data.js` 看到的 JS 全局变量)完全不变,实现内部更简洁。 **契约 §0.3 描述 msext 用"空目录名注入"机制存储 11 个渠道值,本项目按 ADR-007 改用 `ChannelConfig.plist` 等价实现** —— 契约边界(H5 通过 `app_data.js` 看到的 JS 全局变量)完全不变,实现内部更简洁。
> **2026-06-27 修订(ADR-009)**:原 11 个 key 之一的 `qiniudomain` 已移除 —— 七牛 CDN 域名改由 RemoteConfig 顶层 `audio_domain` 远端动态下发,跨渠道无差异化诉求。**当前 ChannelConfig.plist = 10 个 string key**。 > **2026-06-27 修订(ADR-009)**:原 11 个 key 之一的 `qiniudomain` 已移除 —— 七牛 CDN 域名改由 RemoteConfig 顶层 `audio_domain` 远端动态下发,跨渠道无差异化诉求。
>
> **2026-07-03 修订**:新增 `multishare` 字段,控制子游戏 sharefriend=1 好友分享形态("1" = SharePanel 三选一 / "0" = 仅微信直发)。**当前 ChannelConfig.plist = 11 个 string key**。
**存储**:`ylgamehall/Resources/ChannelConfig.plist`,10 个 string key 一一对应渠道值: **存储**:`ylgamehall/Resources/ChannelConfig.plist`,11 个 string key 一一对应渠道值:
```xml ```xml
<dict> <dict>
@@ -1834,6 +1836,7 @@ Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownlo
<key>appversion</key> <string>43</string> <key>appversion</key> <string>43</string>
<key>other</key> <string></string> <key>other</key> <string></string>
<key>appleconfig</key> <string></string> <key>appleconfig</key> <string></string>
<key>multishare</key> <string>1</string>
</dict> </dict>
``` ```
@@ -2400,6 +2403,133 @@ console.log("app_gamesname =", app_gamesname); // 期望 Array
--- ---
### 7.6 WebView 加载方式:`WKURLSchemeHandler`Phase 1.FADR-010
> Phase 1 / Phase 6 初版用 `webView.loadFileURL(_:allowingReadAccessTo:)` 加载 H5。Phase 1.F 把这一路径迁到自定义 scheme `ylgame://h5/...` + `AppSchemeHandler`。三条动因:① 为未来 Cocos 子游戏 H5 build 适配铺路;② 大厅 + 子游戏共用单虚拟 host (`ylgame://h5`) → same-origin → **保留跨页 localStorage 共享**(业务依赖现状);③ 给 `<audio>` / `<video>` 提供 Range 请求支持。详细决策见 `docs/Development-Plan.md` ADR-010。
#### 7.6.1 URL 结构
| 入口 | URL | 反向映射 |
|------|-----|---------|
| 大厅 | `ylgame://h5/lobby/<gameStart>/index.html` | `{Caches}/{gameDir}/<gameStart>/index.html` |
| 子游戏 | `ylgame://h5/subgame/<dir>/<start>/index.html` | `{Caches}/<dir>/<start>/index.html` |
| `app_*.js` 同步引入 | `ylgame://h5/lobby/<gameStart>/app_data.js` | `{Caches}/{gameDir}/<gameStart>/app_data.js` |
| 跨目录引用(H5 `../foo.js` | `ylgame://h5/lobby/foo.js` | `{Caches}/{gameDir}/foo.js`(等价 file:// 时代 allowingReadAccessTo: lobbyRoot 语义) |
| 弹层外链(不变) | http(s)://... | 走 WebKit 默认网络栈,不经 handler |
**关键约束:scheme + host 必须完全一致**。大厅与所有子游戏都用 `ylgame://h5/...`,否则 origin 拆分破坏 localStorage 共享。
#### 7.6.2 `AppSchemeHandler` 实现要点
```swift
// Source/WebView/AppSchemeHandler.swift ~330
@MainActor
public final class AppSchemeHandler: NSObject, WKURLSchemeHandler {
public static let shared = AppSchemeHandler()
private let ioQueue = DispatchQueue(
label: "ylgamehall.app-scheme-handler.io",
qos: .userInitiated,
attributes: .concurrent
)
// Sendable ObjectIdentifier
// Sendable any WKURLSchemeTask @Sendable
private var tasksByKey: [ObjectIdentifier: any WKURLSchemeTask] = [:]
private var cancelledKeys: Set<ObjectIdentifier> = []
public func webView(_ webView: WKWebView, start task: any WKURLSchemeTask) {
let key = ObjectIdentifier(task)
tasksByKey[key] = task
// 1) + 线
let resolved = try? Self.resolveRequest(url: task.request.url!, ...)
// 2) IO deliver MainActor
ioQueue.async { [weak self] in
let result = Self.readAndBuildResponse(resolved: resolved!)
Task { @MainActor in
self?.deliver(result: result, forKey: key)
}
}
}
public func webView(_ webView: WKWebView, stop task: any WKURLSchemeTask) {
// tasksByKey IO
cancelledKeys.insert(ObjectIdentifier(task))
}
}
```
#### 7.6.3 关键设计抉择
1. **单虚拟 host = `h5`**:所有 H5 页共用此 host,保证 same-origin → localStorage / IndexedDB / sessionStorage 全局共享。**不要**用 `app://lobby` / `app://subgame` 区分,那会拆 origin
2. **`nonisolated` 常量**:项目启用了 MainActor-by-default`AppScheme` enum 所有 `static let` + `MimeMap` 函数必须显式 `nonisolated`,否则背景 IO 队列无法访问(编译期 actor 隔离错)
3. **Sendable 闭包安全**`ioQueue.async``@Sendable` 边界,闭包只能携带 Sendable 类型。`any WKURLSchemeTask` 不是 Sendable → 用 `ObjectIdentifier` key 跨边界,主线程持表查 task 派发回调
4. **取消语义严格性**WKURLSchemeTask 协议规定 `stop` 后**禁止**再向 task 发任何消息(否则 NSException 闪退)。`cancelledKeys` Set + `deliver` 内短路防御
5. **Range 请求**:解析 `Range: bytes=...` → 206 + `Content-Range`Cocos `<audio>` seek 必需,HTML5 `<video>` 同样依赖
6. **MIME 表覆盖 Cocos 常用扩展**`.atlas` / `.fnt` / `.plist` / `.wasm` / `.webp` / `.ogg` / `.m4a` / `.woff2` 全打表,未命中走 `application/octet-stream`(WKWebView 仍能下载,只是不会按内容类型行为)
7. **响应头策略**
- `Cache-Control: no-cache`:沙盒升级(`LobbyZipUpgrader` / `SubGameDownloader`)会原地覆盖文件,禁强缓存避免错版
- `Access-Control-Allow-Origin: *`:防御部分 Cocos build 的 fetch 默认 `mode: 'cors'` 走 preflight
- `Accept-Ranges: bytes`:告诉 H5 媒体元素本资源支持 Range
8. **路由 fail-fast**:未知前缀(既不是 `/lobby/` 也不是 `/subgame/`)直接 404,避免误读到沙盒任意目录
#### 7.6.4 BridgedWebView 注册时机
```swift
// Source/WebView/BridgedWebView.swift init()
let configuration = WKWebViewConfiguration()
// ... ...
// WKWebView(configuration:)
configuration.setURLSchemeHandler(
AppSchemeHandler.shared,
forURLScheme: AppScheme.scheme // "ylgame"
)
let webView = WKWebView(frame: .zero, configuration: configuration)
```
注意:WKWebViewConfiguration 是 **value copy** 但内部状态共享。注册一次后所有由这份 configuration 派生的 WKWebView 都会用同一 handler 实例。BridgedWebView 每次 init 都重新创建 configuration,但 handler 是 singleton,并发安全(主线程访问 + Sendable 闭包边界)。
#### 7.6.5 与 file:// 旧路径的等价性表
| 行为 | file:// 现状 | ylgame://h5 新方案 | 等价性 |
|------|------|------|------|
| 大厅 / 子游戏加载 | `loadFileURL(_:allowingReadAccessTo:)` | `load(URLRequest(url:))` | ✅ |
| 跨页 localStorage | null origin 共享 | host=`h5` same-origin 共享 | ✅ 业务可观察等价 |
| `app_*.js` `<script src>` 同步引入 | 沙盒文件同目录 | handler 透明读相同物理路径 | ✅ 时序等价 |
| XHR/fetch 资源 | null origin 受限 | 同源直通 | 🆙 **改善**Cocos 适配前提) |
| `<audio>` Range seek | WKWebView 原生 | handler 实现 206 | ✅ 行为等价 |
| 跨目录 `../foo.js` | allowingReadAccessTo 放行 | path 自然落回 lobbyRoot | ✅ |
| WKWebsiteDataStore | `.default()` 持久化 | 同左 | ✅ |
| `location.protocol` | `"file:"` | `"ylgame:"` | ⚠️ H5 可观察差异(项目方已 grep 验证现网 H5 不踩此红线) |
#### 7.6.6 接入 Cocos 子游戏的预检脚本(建议加入 SDK-Integration-Guide
```bash
# 子游戏 zip 解开后
grep -RnE "location\.protocol|window\.location\.(protocol|host)" $SUBGAME_DIR
grep -RnE "['\"](file|https?)://[^'\"]" $SUBGAME_DIR
grep -Rn "serviceWorker\.register" $SUBGAME_DIR
grep -Rn "document\.cookie" $SUBGAME_DIR
```
四条全为空 / 仅命中无害日志 → 该子游戏可直接接入;命中关键路径 → 评估是否单游戏开 http://localhost 逃生门(per-game 配置)。
#### 7.6.7 故障排查矩阵
| 症状 | 可能原因 |
|------|---------|
| H5 不加载,splash 永不淡出 | scheme handler 未注册 / configuration 注册时序错(必须在 WKWebView init 前) |
| H5 加载但黑屏 | 路由失败(404)→ Xcode console 看 `✗ file not found:`;检查 lobbyRoot 实际存在的文件名是否与 URL path 段对应 |
| `app_data.js` 未生效(H5 用默认值) | AppDataWriter 写盘失败 / 写入路径与 handler 读路径不一致;检查 `SandboxPaths.lobbyIndex.deletingLastPathComponent()` 是否等于 `lobbyRoot/<gameStart>/` |
| 子游戏切换后大厅 localStorage 丢失 | 子游戏与大厅 URL host 不一致 → origin 拆分;检查 `subGameIndexAppURL` 是否仍是 `ylgame://h5/...` |
| `<audio>` 中段 seek 失败 | handler Range 处理错;检查 `parseRange` 是否正确返回 206 + `Content-Range` |
| Cocos XHR 仍失败 | 个别 Cocos build 自检 `location.protocol === 'http:'`;按 7.6.6 预检脚本定位字面命中点 |
| `NSInternalInconsistencyException` "Completed task" | handler 对 stopped task 仍调了 `didReceive/didFinish`;检查 `cancelledKeys` 短路逻辑 |
---
## 8. 能力模块详细设计 ## 8. 能力模块详细设计
### 8.1 AudioKit ### 8.1 AudioKit
+5 -5
View File
@@ -78,7 +78,7 @@
| `AppDelegate.swift` | `AMapWrapper.bootstrap()` → 真实调隐私合规 3 步 + apiKey | | `AppDelegate.swift` | `AMapWrapper.bootstrap()` → 真实调隐私合规 3 步 + apiKey |
| `SceneDelegate.swift` | `openURLContexts` → 真实调 `WXApi.handleOpen(url, delegate:)` | | `SceneDelegate.swift` | `openURLContexts` → 真实调 `WXApi.handleOpen(url, delegate:)` |
| `AccreditLoginHandler` | `accreditlogin` → 真实拉起微信授权 → 反向 callback `sharelogin` 7 字段(Province 大写 P | | `AccreditLoginHandler` | `accreditlogin` → 真实拉起微信授权 → 反向 callback `sharelogin` 7 字段(Province 大写 P |
| `WechatShare` | `share()` → 真实调 `WXApi.send(SendMessageToWXReq)` | | `WechatShare` | `share()` / `shareLobby()` → 剪贴板 + 引导框 + `weixin://` 跳转(不依赖 SDK 的分享 API,与 QQ / 抖音同款交互) |
| `StartLocationHandler` | `startlocation` → 真实调 `AMapLocationManager.requestLocation` → 反向 callback `getlocationinfo` 9 字段(latitude/longitude string, province 小写 p | | `StartLocationHandler` | `startlocation` → 真实调 `AMapLocationManager.requestLocation` → 反向 callback `getlocationinfo` 9 字段(latitude/longitude string, province 小写 p |
| `BackGameDataHandler` | 子游戏 backgameData 退出时自动清 `WXApi.delegate = nil` + `LocationService.stop()` | | `BackGameDataHandler` | 子游戏 backgameData 退出时自动清 `WXApi.delegate = nil` + `LocationService.stop()` |
@@ -236,10 +236,10 @@ bridge.register("prepareaudio") { _, callback in
|---|---|---| |---|---|---|
| `Vendor/WechatSDK/WechatOpenSDK.xcframework` | ⏳ 等用户放置 | 项目方从腾讯开放平台下 `OpenSDK2.0.5+_NoPay.zip` 解压 → 放进 `Vendor/WechatSDK/` → 按 §A.1 Embed & Sign | | `Vendor/WechatSDK/WechatOpenSDK.xcframework` | ⏳ 等用户放置 | 项目方从腾讯开放平台下 `OpenSDK2.0.5+_NoPay.zip` 解压 → 放进 `Vendor/WechatSDK/` → 按 §A.1 Embed & Sign |
| `Source/SDK/WeChat/WeChatSDK.swift` | ✅ 已写 | `register()` + `handleOpenURL` 包 canImport | | `Source/SDK/WeChat/WeChatSDK.swift` | ✅ 已写 | `register()` + `handleOpenURL` 包 canImport |
| `Source/SDK/WeChat/WeChatManager.swift` | ✅ 已写 | authorize / shareLink async wrapper + WXApiDelegate | | `Source/SDK/WeChat/WeChatManager.swift` | ✅ 已写 | authorize async wrapper + WXApiDelegate(分享路径已剥离,走剪贴板见 `WechatShare` |
| `Source/Login/WeChatAuth.swift` | ✅ 已写 | OAuth2 客户端直拼 sns/oauth2 + userinfo + 7 字段 | | `Source/Login/WeChatAuth.swift` | ✅ 已写 | OAuth2 客户端直拼 sns/oauth2 + userinfo + 7 字段 |
| `Source/Bridge/Handlers/AccreditLoginHandler.swift` | ✅ 已升级 | 真实拉起授权 + sharelogin 反向 callbackProvince 大写 P | | `Source/Bridge/Handlers/AccreditLoginHandler.swift` | ✅ 已升级 | 真实拉起授权 + sharelogin 反向 callbackProvince 大写 P |
| `Source/Share/WechatShare.swift` | ✅ 已升级 | 真实链接分享(type=2/3 截图待 Phase 4.F | | `Source/Share/WechatShare.swift` | ✅ 已改造 | 剪贴板 + 引导框 + `weixin://` 跳转(与 QQ / 抖音一致,不依赖微信 SDK 分享 API |
| `Source/Bridge/Handlers/BackGameDataHandler.swift` | ✅ 已加钩子 | `WXApi.delegate = nil` 子游戏 pop 时清理 | | `Source/Bridge/Handlers/BackGameDataHandler.swift` | ✅ 已加钩子 | `WXApi.delegate = nil` 子游戏 pop 时清理 |
| `AppDelegate.swift` | ✅ 已加注册 | `WeChatSDK.register()` | | `AppDelegate.swift` | ✅ 已加注册 | `WeChatSDK.register()` |
| `SceneDelegate.swift` | ✅ 已加 openURL | `WeChatSDK.handleOpenURL(ctx.url)` | | `SceneDelegate.swift` | ✅ 已加 openURL | `WeChatSDK.handleOpenURL(ctx.url)` |
@@ -248,8 +248,8 @@ bridge.register("prepareaudio") { _, callback in
### 1.4 验收(用户 Xcode 加完 framework 后跑) ### 1.4 验收(用户 Xcode 加完 framework 后跑)
- [ ] H5 调 `bridge.callHandler('accreditlogin')` → 拉起微信 → 同意授权 → 大厅 H5 收 `sharelogin({openid,...,Province})`**Province 大写 P** - [ ] H5 调 `bridge.callHandler('accreditlogin')` → 拉起微信 → 同意授权 → 大厅 H5 收 `sharelogin({openid,...,Province})`**Province 大写 P**
- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})`拉起微信朋友圈 - [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})`写剪贴板 + 弹引导框 → 点"立即打开微信"跳转到微信首页(用户自行粘贴到朋友圈
- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 拉起微信好友选择 - [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 写剪贴板 + 弹引导框 → 跳微信首页(用户自行粘贴到对话框)
- [ ] **关键回归**:截图分享 / QQ 分享 / 抖音分享路径不受影响 - [ ] **关键回归**:截图分享 / QQ 分享 / 抖音分享路径不受影响
### 1.5 风险 ### 1.5 风险
+13 -4
View File
@@ -208,9 +208,11 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 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; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = NX5W3B4QP3; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = NX5W3B4QP3;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Vendor/AMap", "$(PROJECT_DIR)/Vendor/AMap",
@@ -242,6 +244,8 @@
OTHER_LDFLAGS = "-ObjC"; OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.skyapp.ylgamehall; PRODUCT_BUNDLE_IDENTIFIER = com.skyapp.ylgamehall;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "iOS dev";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
@@ -258,9 +262,12 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 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; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = NX5W3B4QP3; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = NX5W3B4QP3;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Vendor/AMap", "$(PROJECT_DIR)/Vendor/AMap",
@@ -292,6 +299,8 @@
OTHER_LDFLAGS = "-ObjC"; OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.skyapp.ylgamehall; PRODUCT_BUNDLE_IDENTIFIER = com.skyapp.ylgamehall;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = ios_ad_hoc;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+7 -5
View File
@@ -5,22 +5,24 @@
<key>gameid</key> <key>gameid</key>
<string>G2hw0ubng0zcoI0r4mx3H2yr4GejidwO</string> <string>G2hw0ubng0zcoI0r4mx3H2yr4GejidwO</string>
<key>channel</key> <key>channel</key>
<string>FtJf073aa0d6rI1xD8J1Y42fINTm0ziK</string> <string>aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG</string>
<key>gamedir</key> <key>gamedir</key>
<string>FtJf073aa0d6rI1xD8J1Y42fINTm0ziK</string> <string>aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG</string>
<key>gamestart</key> <key>gamestart</key>
<string>gamehall</string> <string>gamehall</string>
<key>gameconfig</key> <key>gameconfig</key>
<string>tsgames.daoqi88.cn-config_test-update_jsonv2_test</string> <string>tsgames.daoqi88.cn-config-update_jsonv2</string>
<key>market</key> <key>market</key>
<string>2</string> <string>2</string>
<key>agent</key> <key>agent</key>
<string>veRa0qrBf0df2K1G4de2tgfmVxB2jxpv</string> <string>1B2h0ccl205c390Y28m1Ajdplkuu4wgy</string>
<key>appversion</key> <key>appversion</key>
<string>43</string> <string>44</string>
<key>other</key> <key>other</key>
<string></string> <string></string>
<key>appleconfig</key> <key>appleconfig</key>
<string></string> <string></string>
<key>multishare</key>
<string>0</string>
</dict> </dict>
</plist> </plist>
@@ -4,8 +4,26 @@
* H5 端桥协议代码 Native BridgeBus@MainActor BridgeProtocol 实现 * H5 端桥协议代码 Native BridgeBus@MainActor BridgeProtocol 实现
* 一一对应详见 docs/H5-Native-Implementation-Design.md §3.2 / §3.3 * 一一对应详见 docs/H5-Native-Implementation-Design.md §3.2 / §3.3
* *
* BridgedWebView 构造时通过 WKUserScript.atDocumentStart注入到所有 * 本文件的**语义**必须与原工程 msext `WebViewJavascriptBridge_JS.m`
* 加载的 H5 页面H5 业务代码运行时window.WebViewJavascriptBridge 已就绪 * marcuswestin 原版daoqi/msext/Class/WebViewJavascriptBridge/保持一致
* 传输层可以现代化msext iframe + _fetchQueue 拉取我们用
* WKScriptMessageHandler 直推**派发时机与消息字段必须逐项对齐**
* 否则 H5 侧同一份代码在两个壳里的执行顺序不同 行为不一致
*
* 已对齐的 msext 语义每一条都曾经不一致是排查子游戏定位拿不到时发现的
*
* 1. **异步派发**msext `dispatchMessagesWithTimeoutSafety = true`
* `disableJavscriptAlertBoxSafetyTimeout` 在整个 msext 工程里**从未被调用**
* 所以 Native H5 的每一条消息都走 `setTimeout(_doDispatch)`**新的
* macrotask** 上执行早前本文件是同步派发 evaluateJavaScript 内直接
* handlerH5 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 消息 * 协议Native H5 双向 JSON 消息
* - H5 Nativewindow.webkit.messageHandlers.WVJBHandler.postMessage({...}) * - H5 Nativewindow.webkit.messageHandlers.WVJBHandler.postMessage({...})
@@ -18,18 +36,26 @@
;(function () { ;(function () {
if (window.WebViewJavascriptBridge) { return; } 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 注册的 handlername → fn(data, responseCallback) var messageHandlers = {}; // H5 注册的 handlername → fn(data, responseCallback)
var responseCallbacks = {}; // 等待 Native 响应的 JS 回调:callbackId → fn(responseData) var responseCallbacks = {}; // 等待 Native 响应的 JS 回调:callbackId → fn(responseData)
var nextCallbackId = 1; var nextCallbackId = 1;
// msext `dispatchMessagesWithTimeoutSafety`:默认 true,仅在 Native 下发
// `_disableJavascriptAlertBoxSafetyTimeout` 时置 falsemsext 从不下发)。
var dispatchMessagesWithTimeoutSafety = true;
// ── 对外 API ───────────────────────────────────────────── // ── 对外 API ─────────────────────────────────────────────
var bridge = window.WebViewJavascriptBridge = { var bridge = window.WebViewJavascriptBridge = {
/** /**
* H5 注册 handler等待 Native 主动 callHandler重名覆盖 * H5 注册 handler等待 Native 主动 callHandler重名覆盖
* @param {string} handlerName
* @param {function(data, responseCallback)} handler
*/ */
registerHandler: function (handlerName, handler) { registerHandler: function (handlerName, handler) {
messageHandlers[handlerName] = handler; messageHandlers[handlerName] = handler;
@@ -37,39 +63,31 @@
/** /**
* H5 主动调 Native handler * H5 主动调 Native handler
* @param {string} handlerName * msext `callHandler` 支持 2 参形式第二个参数是函数时视为 responseCallback
* @param {*} data 任意 JSON 可序列化值
* @param {function(responseData)} [responseCallback]
*/ */
callHandler: function (handlerName, data, responseCallback) { callHandler: function (handlerName, data, responseCallback) {
var callbackId = null; if (arguments.length === 2 && typeof data === 'function') {
if (typeof responseCallback === 'function') { responseCallback = data;
callbackId = 'cb_' + (nextCallbackId++) + '_' + Date.now(); data = null;
responseCallbacks[callbackId] = responseCallback;
} }
var message = { handlerName: handlerName }; _doSend({ handlerName: handlerName, data: data }, responseCallback);
if (data !== undefined && data !== null) { },
message.data = data;
} disableJavscriptAlertBoxSafetyTimeout: function () {
if (callbackId) { dispatchMessagesWithTimeoutSafety = false;
message.callbackId = callbackId;
}
_postMessageToNative(message);
}, },
/** /**
* Native H5 入口BridgeBus.sendToJS 会注入 * Native H5 入口BridgeBus.sendToJS 会注入
* window.WebViewJavascriptBridge._handleMessageFromObjC('<base64of JSON>') * window.WebViewJavascriptBridge._handleMessageFromObjC('<base64of JSON>')
* base64 包装是为了避免 JSON 内容里的单引号 / 反斜杠扰乱 evaluateJavaScript 的字符串字面量 * base64 包装是为了避免 JSON 内容里的单引号 / 反斜杠扰乱 evaluateJavaScript
* 的字符串字面量msext 走的是逐个 escape等价
*
* 无返回值msext 的派发是 setTimeout 异步的拿不到H5 有没有跑到
* handler的同步结果原生侧不要依赖它的返回值做诊断
*/ */
_handleMessageFromObjC: function (base64String) { _handleMessageFromObjC: function (base64String) {
try { _dispatchMessageFromObjC(base64String);
var json = _decodeBase64UTF8(base64String);
var message = JSON.parse(json);
_dispatchFromNative(message);
} catch (e) {
console.error('[WVJB] handle message from native failed:', e);
}
} }
}; };
@@ -79,42 +97,77 @@
try { try {
window.webkit.messageHandlers.WVJBHandler.postMessage(message); window.webkit.messageHandlers.WVJBHandler.postMessage(message);
} catch (e) { } catch (e) {
console.error('[WVJB] post to native failed (WVJBHandler 通道未注册?):', e); console.log('[WVJB] post to native failed (WVJBHandler 通道未注册?):', e);
} }
} }
function _dispatchFromNative(message) { function _doSend(message, responseCallback) {
// Native 响应 H5 早前 callHandler if (typeof responseCallback === 'function') {
if (message.responseId) { var callbackId = 'cb_' + (nextCallbackId++) + '_' + new Date().getTime();
var cb = responseCallbacks[message.responseId]; responseCallbacks[callbackId] = responseCallback;
if (cb) { message['callbackId'] = callbackId;
delete responseCallbacks[message.responseId];
try { cb(message.responseData); }
catch (e) { console.error('[WVJB] response callback threw:', e); }
} }
return; _postMessageToNative(message);
} }
// Native 主动调用 H5 handler /// msext `_dispatchMessageFromObjC` 逐句等价:默认走 setTimeout(新 macrotask)。
var handler = messageHandlers[message.handlerName]; function _dispatchMessageFromObjC(base64String) {
if (!handler) { if (dispatchMessagesWithTimeoutSafety) {
console.warn('[WVJB] no H5 handler registered for:', message.handlerName); 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; return;
} }
var responseCallback; var responseCallback;
if (message.responseId) {
responseCallback = responseCallbacks[message.responseId];
if (!responseCallback) { return; }
responseCallback(message.responseData);
delete responseCallbacks[message.responseId];
return;
}
if (message.callbackId) { if (message.callbackId) {
var responseId = message.callbackId; var callbackResponseId = message.callbackId;
responseCallback = function (responseData) { responseCallback = function (responseData) {
_postMessageToNative({ // msext 同款:response 消息里**带 handlerName**
responseId: responseId, _doSend({
handlerName: message.handlerName,
responseId: callbackResponseId,
responseData: responseData 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); } 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('')); }).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 旧式握手兼容 ───────────────────────────── // ── marcuswestin 旧式握手兼容 ─────────────────────────────
// 部分 H5 代码这样初始化 bridge // msext `_callWVJBCallbacks` 用 setTimeout(..., 0)**不是同步 flush**
// function setupWebViewJavascriptBridge(callback) { // 这决定 H5 的 registerHandler 相对页面其它顶层脚本的生效时机。
// if (window.WebViewJavascriptBridge) return callback(WebViewJavascriptBridge); setTimeout(function () {
// if (window.WVJBCallbacks) return window.WVJBCallbacks.push(callback); var callbacks = window.WVJBCallbacks;
// window.WVJBCallbacks = [callback]; if (!callbacks) { return; }
// /* 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;
delete window.WVJBCallbacks; delete window.WVJBCallbacks;
for (var i = 0; i < pending.length; i++) { for (var i = 0; i < callbacks.length; i++) {
try { pending[i](bridge); } try { callbacks[i](bridge); }
catch (e) { console.error('[WVJB] pending WVJBCallback threw:', e); } catch (e) { console.log('[WVJB] pending WVJBCallback threw:', e); }
}
} }
}, 0);
})(); })();
+48 -31
View File
@@ -7,22 +7,22 @@
// docs/H5-Native-Contract.md §3.1 5mediaTypeAudio + §3.2 78 // docs/H5-Native-Contract.md §3.1 5mediaTypeAudio + §3.2 78
// gameui_play_voice / gameui_stop_voice // gameui_play_voice / gameui_stop_voice
// //
// ## // ##
// //
// daoqi msext NewRootVC.m / gameController.m voicePlayer / User_id // daoqi msext NewRootVC.m / gameController.m voicePlayer / User_id
// VC // VC `voicePlayer = [[AVAudioPlayer alloc]...]`
// ivar player
// `self.User_id` stopradio H5
// `gameui_stop_voice` payload ** user_id**
// H5
// //
// - `lastUserId`mediaTypeAudio user mediaTypeAudio // user player `currentUser`
// daoqi gameController.m:438 // - play player stop player
// `self.User_id=userid;` canbofang=NO // `onFinish(currentUser)` H5 `gameui_stop_voice( user)`
// - `gameui_stop_voice` payload `lastUserId` didFinishPlaying // - / `currentUser` " user"
// player daoqi stopradio self.User_id // onFinish
// gameui_stop_voice user** daoqi ** // - stop() onFinish daoqi
// - play player // stop
// ARC/ARC-like daoqi `voicePlayer = [[AVAudioPlayer alloc]...]`
//
// - stop **** onFinishdaoqi stop
// audioPlayerDidFinishPlaying stopradio
// //
import AVFoundation import AVFoundation
@@ -36,42 +36,57 @@ public final class VoicePlayer: NSObject, AVAudioPlayerDelegate {
private var player: AVAudioPlayer? private var player: AVAudioPlayer?
/// mediaTypeAudio user /// user `player` `player != nil`
/// daoqi self.User_id mediaTypeAudio /// `player == nil` nil play / didFinishPlaying / decodeError
public private(set) var lastUserId: String = "" /// onFinish `gameui_stop_voice` payload /
private var currentUser: String?
/// AVAudioPlayer didFinishPlaying / /// / /
/// mediaTypeAudio handler closure gameui_stop_voice /// mediaTypeAudio handler closure H5 `gameui_stop_voice`
/// payload `lastUserId` player user daoqi /// payload / player user user
public var onFinish: ((String) -> Void)? public var onFinish: ((String) -> Void)?
private override init() { super.init() } private override init() { super.init() }
/// mediaTypeAudio user**** /// wav player stop player
/// daoqi gameController.m:438voicePlaying=0 `self.User_id=userid` /// `onFinish( user)` H5 player
public func recordIncomingUser(_ user: String) { /// - Throws: AVAudioPlayer /
lastUserId = user public func play(_ wav: URL, user: String) throws {
// 1. player "" H5
// delegate nil player stop / dealloc
// didFinishPlaying onFinish
if let old = player {
old.delegate = nil
old.stop()
player = nil
if let interrupted = currentUser {
currentUser = nil
Self.log.debug("interrupted user=\(interrupted, privacy: .public)")
onFinish?(interrupted)
} else {
currentUser = nil
}
} }
/// wav player daoqi // 2. player
/// `recordIncomingUser(user)` lastUserId
/// - Throws: AVAudioPlayer /
public func play(_ wav: URL) throws {
let p = try AVAudioPlayer(contentsOf: wav) let p = try AVAudioPlayer(contentsOf: wav)
p.delegate = self p.delegate = self
guard p.prepareToPlay(), p.play() else { guard p.prepareToPlay(), p.play() else {
throw PlayerError.startFailed throw PlayerError.startFailed
} }
player = p player = p
Self.log.debug("play \(wav.lastPathComponent, privacy: .public) user=\(self.lastUserId, privacy: .public)") currentUser = user
Self.log.debug("play \(wav.lastPathComponent, privacy: .public) user=\(user, privacy: .public)")
} }
/// onFinish /// onFinish
/// 使 H5 BackGameData 退 /// 使 H5 BackGameData 退
/// didFinishPlaying /// didFinishPlaying
public func stop() { public func stop() {
player?.delegate = nil
player?.stop() player?.stop()
player = nil player = nil
currentUser = nil
} }
public enum PlayerError: Error, Sendable { public enum PlayerError: Error, Sendable {
@@ -110,8 +125,10 @@ public final class VoicePlayer: NSObject, AVAudioPlayerDelegate {
/// player self.player + onFinish /// player self.player + onFinish
private func handleFinish(playerIdentity: ObjectIdentifier, success: Bool) { private func handleFinish(playerIdentity: ObjectIdentifier, success: Bool) {
guard let current = player, ObjectIdentifier(current) == playerIdentity else { return } guard let current = player, ObjectIdentifier(current) == playerIdentity else { return }
let user = currentUser
player = nil player = nil
Self.log.debug("didFinish user=\(self.lastUserId, privacy: .public) success=\(success, privacy: .public)") currentUser = nil
onFinish?(lastUserId) Self.log.debug("didFinish user=\(user ?? "<nil>", privacy: .public) success=\(success, privacy: .public)")
if let user { onFinish?(user) }
} }
} }
+156 -7
View File
@@ -8,6 +8,75 @@
import Foundation import Foundation
import WebKit 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 线 /// H5 Native 线
/// ///
@@ -24,14 +93,33 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
/// JS `window.WebViewJavascriptBridge` /// JS `window.WebViewJavascriptBridge`
public static let jsBridgeName = "WebViewJavascriptBridge" public static let jsBridgeName = "WebViewJavascriptBridge"
/// `lobby` / `subGame` / WebView
///
public let label: String
private weak var webView: WKWebView? private weak var webView: WKWebView?
private var handlers: [String: BridgeHandler] = [:] private var handlers: [String: BridgeHandler] = [:]
private var pendingCallbacks: [String: BridgeCallback] = [:] private var pendingCallbacks: [String: BridgeCallback] = [:]
private var nativeCallbackCounter: UInt64 = 0 private var nativeCallbackCounter: UInt64 = 0
/// msext `WebViewJavascriptBridgeBase.startupMessageQueue`
/// `_queueMessage` bridge loaded `injectJavascriptFile` flush
///
/// `window.WebViewJavascriptBridge` ****
/// `sendToJS 'getnetwork' H5no-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` /// WebView + `WVJBHandler`
public init(webView: WKWebView, controller: WKUserContentController) { public init(webView: WKWebView,
controller: WKUserContentController,
label: String = "webview") {
self.webView = webView self.webView = webView
self.label = label
super.init() super.init()
controller.add(self, name: Self.scriptMessageHandlerName) 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) { public func register(_ name: String, handler: @escaping BridgeHandler) {
handlers[name] = handler handlers[name] = handler
diagLog("[\(self.label)] register handler '\(name)'")
} }
public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) { public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) {
@@ -53,6 +142,10 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
pendingCallbacks[cbId] = callback pendingCallbacks[cbId] = callback
payload["callbackId"] = cbId 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) sendToJS(payload: payload)
} }
@@ -73,6 +166,27 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
// MARK: - // MARK: -
private func handleIncoming(_ msg: [String: Any]) { 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 handlerhandler
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
}
// responseIdJS Native callHandler // responseIdJS Native callHandler
if let responseId = msg["responseId"] as? String { if let responseId = msg["responseId"] as? String {
let respData = msg["responseData"].flatMap { BridgeData(jsonObject: $0) } let respData = msg["responseData"].flatMap { BridgeData(jsonObject: $0) }
@@ -95,9 +209,19 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
// handler responseData JS // handler responseData JS
let responseCallback: BridgeCallback? = makeResponseCallback(for: callbackId) let responseCallback: BridgeCallback? = makeResponseCallback(for: callbackId)
diagLog("[\(self.label)] ← H5 call '\(name)' data=\(String(describing: msg["data"]))")
guard let handler = handlers[name] else { guard let handler = handlers[name] else {
print("[BridgeBus] no handler registered for '\(name)'") // msext `WebViewJavascriptBridgeBase.flushMessageQueue`
responseCallback?(nil) // 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 return
} }
@@ -124,17 +248,42 @@ public final class BridgeBus: NSObject, BridgeProtocol, WKScriptMessageHandler {
sendToJS(payload: payload) sendToJS(payload: payload)
} }
/// msext `WebViewJavascriptBridgeBase._queueMessage:`
/// `startupMessageQueue`
private func sendToJS(payload: [String: Any]) { 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), guard let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8) let json = String(data: data, encoding: .utf8)
else { else {
print("[BridgeBus] JSON encode failed for payload: \(payload)") diagLog("[\(self.label)] JSON encode failed for payload: \(String(describing: payload))")
return return
} }
// base64 JSON / JS // base64 JSON / JS
// msext escape ///U+2028/U+2029
let base64 = Data(json.utf8).base64EncodedString() let base64 = Data(json.utf8).base64EncodedString()
let js = "if (window.\(Self.jsBridgeName)) window.\(Self.jsBridgeName)._handleMessageFromObjC('\(base64)');" let js = "window.\(Self.jsBridgeName)._handleMessageFromObjC('\(base64)');"
webView.evaluateJavaScript(js, completionHandler: nil) 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)")
}
}
} }
} }
+15 -2
View File
@@ -43,6 +43,14 @@ public typealias BridgeCallback = @Sendable (BridgeData?) -> Void
public enum BridgeData: Sendable { public enum BridgeData: Sendable {
case string(String) case string(String)
case number(Double) case number(Double)
/// JSON ****
///
/// `.number(Double)``JSONSerialization` Double 17
/// `28.636486` `28.636486000000001` JS `JSON.parse`
/// IEEE754 doubleH5 JSON ****
/// `getlocationinfo` msext UIWebView
/// `RootVC.m:1998` `\"latitude\":%f`
case decimal(Decimal)
case bool(Bool) case bool(Bool)
case null case null
case array([BridgeData]) case array([BridgeData])
@@ -89,6 +97,7 @@ extension BridgeData {
switch self { switch self {
case .string(let s): return s case .string(let s): return s
case .number(let d): return d case .number(let d): return d
case .decimal(let d): return d as NSDecimalNumber
case .bool(let b): return b case .bool(let b): return b
case .null: return NSNull() case .null: return NSNull()
case .array(let arr): return arr.map { $0.jsonObject } case .array(let arr): return arr.map { $0.jsonObject }
@@ -124,6 +133,7 @@ extension BridgeData {
return String(Int(d)) return String(Int(d))
} }
return String(d) return String(d)
case .decimal(let d): return "\(d)"
case .bool(let b): return b ? "true" : "false" case .bool(let b): return b ? "true" : "false"
case .null: return nil case .null: return nil
case .array, .object: return nil case .array, .object: return nil
@@ -131,8 +141,11 @@ extension BridgeData {
} }
nonisolated public var asDouble: Double? { nonisolated public var asDouble: Double? {
if case .number(let d) = self { return d } switch self {
return nil case .number(let d): return d
case .decimal(let d): return (d as NSDecimalNumber).doubleValue
default: return nil
}
} }
nonisolated public var asInt: Int? { nonisolated public var asInt: Int? {
+114 -19
View File
@@ -2,8 +2,9 @@
// H5ErrorRelay.swift // H5ErrorRelay.swift
// ylgamehall // ylgamehall
// //
// H5 WebView `console.error` / `window.onerror` / // H5 / WebView `console.error` / `console.warn` /
// `unhandledrejection` `webkit.messageHandlers.h5error` print // `window.onerror` / `unhandledrejection` `webkit.messageHandlers.h5error`
// printDebug `console.log` / `console.info`
// //
// Phase 9.2Design §11.2msext / // Phase 9.2Design §11.2msext /
// B H5 Xcode console // B H5 Xcode console
@@ -31,56 +32,127 @@ public final class H5ErrorRelay: NSObject {
/// hook JS + message handler UserContentController /// hook JS + message handler UserContentController
/// BridgedWebView.init WVJB /// 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( let userScript = WKUserScript(
source: Self.javaScriptSource, source: Self.javaScriptSource,
injectionTime: .atDocumentStart, // JS hook injectionTime: .atDocumentStart, // JS hook
forMainFrameOnly: true // iframe forMainFrameOnly: true // iframe
) )
controller.addUserScript(userScript) 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], guard let dict = body as? [String: Any],
let kind = dict["kind"] as? String else { return } let kind = dict["kind"] as? String else { return }
let tag = "[H5:\(label)"
switch kind { switch kind {
case "console.log", "console.info":
// Debug javaScriptSource #if DEBUG
// print diagLogH5 `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": case "console.error":
let args = (dict["args"] as? [String]) ?? [] 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": case "onerror":
let msg = (dict["msg"] as? String) ?? "" let msg = (dict["msg"] as? String) ?? ""
let src = (dict["src"] as? String) ?? "" let src = (dict["src"] as? String) ?? ""
let line = (dict["line"] as? Int) ?? 0 let line = (dict["line"] as? Int) ?? 0
let col = (dict["col"] as? Int) ?? 0 let col = (dict["col"] as? Int) ?? 0
let stack = dict["stack"] as? String 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": case "unhandledrejection":
let reason = (dict["reason"] as? String) ?? "" let reason = (dict["reason"] as? String) ?? ""
let stack = dict["stack"] 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: 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/catchhook /// send try/catchhook
private static let javaScriptSource = """ ///
/// `console.log` / `console.info` Debug Release
/// JSNative IPC H5
private static var javaScriptSource: String {
#if DEBUG
let verboseHooks = verboseConsoleHookJS
#else
let verboseHooks = ""
#endif
return """
(function(){ (function(){
function send(payload){ function send(payload){
try { window.webkit.messageHandlers.h5error.postMessage(payload); } catch(e){} 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; var origErr = console.error;
console.error = function(){ console.error = function(){
try { try { send({ kind:'console.error', args: collect(arguments) }); } catch(e){}
var args = [];
for (var i=0; i<arguments.length; i++) { args.push(String(arguments[i])); }
send({ kind:'console.error', args: args });
} catch(e){}
if (origErr) { origErr.apply(console, arguments); } 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
// 线 / jsJS ev.target window
// listener window window target AT_TARGET
// capture listener
window.addEventListener('error', function(ev){ 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({ send({
kind:'onerror', kind:'onerror',
msg: String(ev.message || ''), msg: String(ev.message || ''),
@@ -89,7 +161,7 @@ public final class H5ErrorRelay: NSObject {
col: ev.colno || 0, col: ev.colno || 0,
stack: (ev.error && ev.error.stack) ? String(ev.error.stack) : null stack: (ev.error && ev.error.stack) ? String(ev.error.stack) : null
}); });
}); }, true);
window.addEventListener('unhandledrejection', function(ev){ window.addEventListener('unhandledrejection', function(ev){
var r = ev.reason; var r = ev.reason;
send({ 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 proxyweak target retain cycle // MARK: - WKScriptMessageHandler proxyweak target retain cycle
@@ -107,9 +200,11 @@ public final class H5ErrorRelay: NSObject {
@MainActor @MainActor
private final class MessageProxy: NSObject, WKScriptMessageHandler { private final class MessageProxy: NSObject, WKScriptMessageHandler {
private weak var target: H5ErrorRelay? private weak var target: H5ErrorRelay?
private let label: String
init(target: H5ErrorRelay) { init(target: H5ErrorRelay, label: String) {
self.target = target self.target = target
self.label = label
super.init() super.init()
} }
@@ -117,6 +212,6 @@ private final class MessageProxy: NSObject, WKScriptMessageHandler {
didReceive message: WKScriptMessage) { didReceive message: WKScriptMessage) {
// WKScriptMessageHandler MessageProxy / H5ErrorRelay // WKScriptMessageHandler MessageProxy / H5ErrorRelay
// MainActor Task // MainActor Task
target?.handle(message.body) target?.handle(message.body, label: label)
} }
} }
@@ -21,7 +21,7 @@
// //
// Phase 4.E SDK2.x static delegate API handleOpenURL/sendAuthReq // Phase 4.E SDK2.x static delegate API handleOpenURL/sendAuthReq
// SDK WeChatManager singleton pop // SDK WeChatManager singleton pop
// Phase 5 `LocationService.shared.stop()`canImport // Phase 5 `location.stop()`canImport
// Xcode Embed AMapLocationKit // Xcode Embed AMapLocationKit
// Phase 3.B/C/D / `AudioRecorder.shared.cancel()` opencore-amr // Phase 3.B/C/D / `AudioRecorder.shared.cancel()` opencore-amr
// `QiniuUploader.cancelInFlight()` Task.cancel // `QiniuUploader.cancelInFlight()` Task.cancel
@@ -34,7 +34,9 @@ import Foundation
public enum BackGameDataHandler { public enum BackGameDataHandler {
public static func register(on bridge: any BridgeProtocol) { /// - Parameter location: **** LocationServicemsext cleanUpAction
/// gameController manager
public static func register(on bridge: any BridgeProtocol, location: LocationService) {
// 18backgameData // 18backgameData
// data H5 getWebdata // data H5 getWebdata
// cb: "backgameData" // cb: "backgameData"
@@ -57,7 +59,7 @@ public enum BackGameDataHandler {
#if canImport(AMapLocationKit) #if canImport(AMapLocationKit)
// msext gameController.m:2473-2478 cleanUpAction // msext gameController.m:2473-2478 cleanUpAction
LocationService.shared.stop() location.stop(caller: "backgameData")
#endif #endif
// Phase 3.C / Phase 3.B QiniuUploader.cancelInFlight // Phase 3.C / Phase 3.B QiniuUploader.cancelInFlight
@@ -261,12 +261,11 @@ public enum RemoteAudioHandler {
return // cb daoqi file return return // cb daoqi file return
} }
// 2. lastUserId daoqi // 2. VoicePlayer.onFinish mediaTypeAudio bridge
// `self.User_id = userid;` // [weak bridge] bridge onFinish
VoicePlayer.shared.recordIncomingUser(user) // onFinish / / play
// payload `finishedUser` / user
// 3. VoicePlayer.onFinish mediaTypeAudio bridge // VoicePlayer currentUser user
// [weak bridge] bridge onFinish
VoicePlayer.shared.onFinish = { [weak bridge] finishedUser in VoicePlayer.shared.onFinish = { [weak bridge] finishedUser in
Task { @MainActor in Task { @MainActor in
bridge?.call( bridge?.call(
@@ -277,7 +276,7 @@ public enum RemoteAudioHandler {
} }
} }
// 4. AMR // 3. AMR
let amrData: Data let amrData: Data
do { do {
let (d, _) = try await URLSession.shared.data(from: downloadURL) let (d, _) = try await URLSession.shared.data(from: downloadURL)
@@ -288,7 +287,7 @@ public enum RemoteAudioHandler {
// `if (![[NSFileManager defaultManager] fileExistsAtPath:filename]) return;` // `if (![[NSFileManager defaultManager] fileExistsAtPath:filename]) return;`
} }
// 5. .amr // 4. .amr
let tempName = VoiceRecorder.currentTimeString() let tempName = VoiceRecorder.currentTimeString()
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let amrLocal = docs.appendingPathComponent(tempName).appendingPathExtension("amr") let amrLocal = docs.appendingPathComponent(tempName).appendingPathExtension("amr")
@@ -300,7 +299,7 @@ public enum RemoteAudioHandler {
return return
} }
// 6. AMR WAV // 5. AMR WAV
// daoqi tempName + "_AmrToWav.wav"NewRootVC.m:341 // daoqi tempName + "_AmrToWav.wav"NewRootVC.m:341
let wavLocal = docs let wavLocal = docs
.appendingPathComponent("\(tempName)_AmrToWav") .appendingPathComponent("\(tempName)_AmrToWav")
@@ -317,14 +316,16 @@ public enum RemoteAudioHandler {
transcoded = false transcoded = false
} }
// 7. voicePlaying + + gameui_play_voice // 6. voicePlaying + + gameui_play_voice
// + enabled daoqi // + enabled daoqi
// NewRootVC.m:343-353 if (canbofang) { play + callHandler } // NewRootVC.m:343-353 if (canbofang) { play + callHandler }
// playerVoicePlayer.play player
// onFinish gameui_stop_voice H5
let canPlay = await VoiceCenter.shared.isEnabled let canPlay = await VoiceCenter.shared.isEnabled
log.debug("mediaTypeAudio user=\(user, privacy: .public) transcoded=\(transcoded, privacy: .public) canPlay=\(canPlay, privacy: .public)") log.debug("mediaTypeAudio user=\(user, privacy: .public) transcoded=\(transcoded, privacy: .public) canPlay=\(canPlay, privacy: .public)")
if transcoded, canPlay { if transcoded, canPlay {
do { do {
try VoicePlayer.shared.play(wavLocal) try VoicePlayer.shared.play(wavLocal, user: user)
// gameui_play_voice user dict // gameui_play_voice user dict
bridge.call( bridge.call(
"gameui_play_voice", "gameui_play_voice",
@@ -337,7 +338,7 @@ public enum RemoteAudioHandler {
} }
} }
// 8. mediaTypeAudio cb daoqi NewRootVC.m:357**** // 7. mediaTypeAudio cb daoqi NewRootVC.m:357****
// 使 / canbofang=NO return // 使 / canbofang=NO return
callback?(.string("Response from mediaTypeAudio")) callback?(.string("Response from mediaTypeAudio"))
} }
@@ -17,43 +17,82 @@
// //
import Foundation import Foundation
import os.log
// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor let MainActor
// handler @Sendable nonisolatedLogger Sendable
private nonisolated let startLocLog = Logger(subsystem: "ylgamehall", category: "Location")
public enum StartLocationHandler { public enum StartLocationHandler {
public static func register(on bridge: any BridgeProtocol) { /// - Parameters:
bridge.register("startlocation") { _, callback in /// - label: `lobby` / `subGame`
/// - location: **** LocationServicemsext /
/// `AMapLocationManager` LocationService.init
public static func register(on bridge: any BridgeProtocol,
label: String = "?",
location: LocationService) {
bridge.register("startlocation") { data, callback in
// msext cb // msext cb
callback?(.string("startlocation")) callback?(.string("startlocation"))
diagLog("[\(label)] ← startlocation 入参 type=\(data?.asLooseString ?? "nil")")
#if canImport(AMapLocationKit) #if canImport(AMapLocationKit)
// SDK callback 9 // msext NewRootVC.m:411-421 / gameController.m:533-543
// data == 1 Phase 5.4 // 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 Task { @MainActor in
do { // outcomemsext completionBlock
let payload = try await LocationService.shared.requestOnce() // .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([ 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.js2026-07-19
// `Func.startlocation` / `Func.getlocation`
// `"errorCode":0` gamehall
// 05_Func.js2026-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), "address": .string(payload.address),
"city": .string(payload.city), "city": .string(payload.city),
"cityCode": .string(payload.cityCode), "cityCode": .string(payload.cityCode),
"country": .string(payload.country), "country": .string(payload.country),
"district": .string(payload.district), "district": .string(payload.district),
"latitude": .string(payload.latitude), // string "latitude": .decimal(payload.latitude), // 6
"longitude": .string(payload.longitude), // string "longitude": .decimal(payload.longitude), // 6
"province": .string(payload.province), // p "province": .string(payload.province), // p
"street": .string(payload.street) "street": .string(payload.street)
]), callback: nil) ]), callback: nil)
} catch LocationError.authorizationDenied { }
// msext gameController.m:2507 }
bridge.call("getlocationinfo", data: .object([
"errorCode": .number(12), if isContinuous {
"errorMsg": .string("缺少定位权限") location.startContinuous(caller: label, emit: dispatch)
]), callback: nil) } else {
} catch { location.requestOnce(caller: label, emit: dispatch)
// 12msext
bridge.call("getlocationinfo", data: .object([
"errorCode": .number(12),
"errorMsg": .string("缺少定位权限")
]), callback: nil)
} }
} }
#endif #endif
@@ -90,6 +90,7 @@ public final class AppCoordinator {
AudioPlayer.shared.stopAllBackground() AudioPlayer.shared.stopAllBackground()
let subGame = SubGameViewController(request: request) let subGame = SubGameViewController(request: request)
diagLog("[coordinator] push 子游戏(栈深 \(nav.viewControllers.count)\(nav.viewControllers.count + 1)")
nav.pushViewController(subGame, animated: true) nav.pushViewController(subGame, animated: true)
return true return true
} }
@@ -97,6 +98,7 @@ public final class AppCoordinator {
/// pop data H5 backgameData /// pop data H5 backgameData
/// WebContainer `getWebdata` callback /// WebContainer `getWebdata` callback
public func popSubGame(returningData data: String) { public func popSubGame(returningData data: String) {
diagLog("[coordinator] pop 子游戏回大厅")
navigationController?.popViewController(animated: true) navigationController?.popViewController(animated: true)
NotificationCenter.default.post( NotificationCenter.default.post(
name: .subGameDidReturn, name: .subGameDidReturn,
+235 -33
View File
@@ -11,6 +11,10 @@
import Foundation import Foundation
import CoreLocation import CoreLocation
import os.log
/// letLogger Sendable便 AMap
private let locLog = Logger(subsystem: "ylgamehall", category: "Location")
#if canImport(AMapLocationKit) #if canImport(AMapLocationKit)
import AMapLocationKit import AMapLocationKit
@@ -28,78 +32,276 @@ public struct LocationPayload: Sendable {
public let cityCode: String public let cityCode: String
public let country: String public let country: String
public let district: String public let district: String
public let latitude: String /// **JSON ** `%f` 6
public let longitude: String /// `Decimal` `Double` init §3.2 B
public let latitude: Decimal
public let longitude: Decimal
public let province: String public let province: String
public let street: String public let street: String
} }
public enum LocationError: Error, Sendable { #if canImport(AMapLocationKit)
case sdkNotLinked extension LocationPayload {
case authorizationDenied /// msext `@{...}`
case timeout ///
case underlying(any Error) /// 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 Bmsext 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 doubleH5
// 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 @MainActor
public final class LocationService { 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) #if canImport(AMapLocationKit)
/// msext `configLocationManager`
private let manager: AMapLocationManager = { private let manager: AMapLocationManager = {
let m = AMapLocationManager() let m = AMapLocationManager()
m.desiredAccuracy = kCLLocationAccuracyHundredMeters m.desiredAccuracy = kCLLocationAccuracyHundredMeters
m.pausesLocationUpdatesAutomatically = false
m.locationTimeout = 6 m.locationTimeout = 6
m.reGeocodeTimeout = 3 m.reGeocodeTimeout = 3
m.locatingWithReGeocode = true m.locatingWithReGeocode = true
return m 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 #endif
/// + msext locAction + completionBlock /// requestOnce
public func requestOnce() async throws -> LocationPayload { 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 12H5
///
/// `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) #if canImport(AMapLocationKit)
return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<LocationPayload, Error>) in requestSeq += 1
manager.requestLocation(withReGeocode: true) { loc, regeo, err in 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? { if let err = err as NSError? {
// msext 12 errorCodehandler emit(.failure) //
if err.code == AMapLocationErrorCode.locateFailed.rawValue { if err.code == AMapLocationErrorCode.locateFailed.rawValue {
cont.resume(throwing: LocationError.authorizationDenied) diagLog("[\(caller)] requestOnce #\(seq) locateFailed,按 msext 就此结束")
} else {
cont.resume(throwing: LocationError.underlying(err))
}
return return
} }
guard let loc, let regeo else { diagLog("[\(caller)] requestOnce #\(seq) 有 error 但非 locateFailed,按 msext 继续尝试补发真实定位")
cont.resume(throwing: LocationError.timeout) }
// 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 return
} }
cont.resume(returning: LocationPayload( guard let payload = LocationPayload(location: loc, reGeocode: regeo) else {
address: regeo.formattedAddress ?? "", diagLog("[\(caller)] requestOnce #\(seq) 逆地理有 nil 字段,按 msext@{} 抛 NSException)整条不发")
city: regeo.city ?? "", return
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 ?? ""
))
} }
emit(.success(payload)) //
}
}
// AMapLocationManager.hRequestmsext
// H5 continuation
// 便
if !accepted {
diagLog("[\(caller)] requestOnce #\(seq) requestLocation 返回 NO(未挂上请求),按 msext 静默")
} }
#else #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 #endif
} }
/// msext gameController.m:2473-2478 cleanUpAction /// msext gameController.m:2473-2478 cleanUpAction
public func stop() { public func stop(caller: String = "?") {
#if canImport(AMapLocationKit) #if canImport(AMapLocationKit)
// `stopUpdatingLocation` cancel manager
// manager msext
// cleanUpAction manager
diagLog("[\(caller)] stop()stopUpdatingLocation + delegate=nilowner=\(owner)")
manager.stopUpdatingLocation() manager.stopUpdatingLocation()
manager.delegate = nil manager.delegate = nil
#endif #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
@@ -10,19 +10,25 @@ import Foundation
// MARK: - Codable 4 agent game channel market // MARK: - Codable 4 agent game channel market
/// appVersion / appDownload / gameVersion / gameZip /
/// showmessage / audioDomain / audioBucket
/// 4 fallback Agent/Game/Channel/Market
nonisolated public struct RemoteConfig: Codable, Sendable { nonisolated public struct RemoteConfig: Codable, Sendable {
public let appVersion: String?
public let appDownload: String?
public let gameVersion: String?
public let gameZip: String?
public let showmessage: String? public let showmessage: String?
/// CDN ** http:// **访 URL
/// fallback 4 fallback
/// agent game channel market
/// VersionResolver.resolveAudio(...) BootError.audioConfigMissing
public let audioDomain: String? public let audioDomain: String?
/// bucket putPolicy.scope audioDomain 4 fallback
public let audioBucket: String? public let audioBucket: String?
public let agentlist: [Agent]? public let agentlist: [Agent]?
public init(from decoder: any Decoder) throws { public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
appVersion = try c.decodeFlexibleStringIfPresent(forKey: .appVersion)
appDownload = try c.decodeFlexibleStringIfPresent(forKey: .appDownload)
gameVersion = try c.decodeFlexibleStringIfPresent(forKey: .gameVersion)
gameZip = try c.decodeFlexibleStringIfPresent(forKey: .gameZip)
showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage) showmessage = try c.decodeFlexibleStringIfPresent(forKey: .showmessage)
audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain) audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain)
audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket) audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket)
@@ -31,8 +37,10 @@ nonisolated public struct RemoteConfig: Codable, Sendable {
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
// case rawValue JSONDecoder.convertFromSnakeCase // case rawValue JSONDecoder.convertFromSnakeCase
// JSON audio_domain / audio_bucket audioDomain / audioBucket // JSON app_version / audio_domain appVersion / audioDomain
case showmessage, agentlist, audioDomain, audioBucket case showmessage, agentlist
case appVersion, appDownload, gameVersion, gameZip
case audioDomain, audioBucket
} }
} }
+32 -45
View File
@@ -2,24 +2,27 @@
// VersionResolver.swift // VersionResolver.swift
// ylgamehall // ylgamehall
// //
// RemoteConfig 5 appVersion/appDownload/gameVersion/ // RemoteConfig 7 appVersion / appDownload /
// gameZip/showmessage // gameVersion / gameZip / showmessage / audioDomain / audioBucket
// //
// daoqi `NewRootVC.m:689-810` // daoqi `NewRootVC.m:689-810` 5 fallback
// agentlist[agentid] gamelist[gameid] channellist[channelid] marketlist[marketid] // rootRemoteConfig
// agentlist[agentid] gamelist[gameid]
// channellist[channelid] marketlist[marketid]
// //
// market退 // market退""
// msext "" if // msext "" if
//
// //
// 5 `pickString` / `pickInt` // 7 `pickString` / `pickInt`
// " / chulishengji" // `?? config.xxx`
// //
import Foundation import Foundation
// MARK: - // MARK: -
/// 4 fallback 5 /// 4 fallback chain + pickString/Int
public struct ResolvedVersion: Sendable, Equatable { public struct ResolvedVersion: Sendable, Equatable {
/// IPA BundleConfig.appVersion 0 /// IPA BundleConfig.appVersion 0
public let appVersion: Int public let appVersion: Int
@@ -31,6 +34,10 @@ public struct ResolvedVersion: Sendable, Equatable {
public let gameZip: String? public let gameZip: String?
/// msext #2 /// msext #2
public let showmessage: String? public let showmessage: String?
/// CDN http:// 访 URL nil
public let audioDomain: String?
/// bucket putPolicy.scopenil
public let audioBucket: String?
} }
// MARK: - 4 // MARK: - 4
@@ -51,6 +58,9 @@ extension Agent: RemoteConfigNode {}
extension Game: RemoteConfigNode {} extension Game: RemoteConfigNode {}
extension Channel: RemoteConfigNode {} extension Channel: RemoteConfigNode {}
extension Market: RemoteConfigNode {} extension Market: RemoteConfigNode {}
// RemoteConfig fallback ""
// fallback
extension RemoteConfig: RemoteConfigNode {}
// MARK: - VersionResolver // MARK: - VersionResolver
@@ -77,53 +87,30 @@ public enum VersionResolver {
marketId: marketId marketId: marketId
) )
// 7 "chain "
// chain RemoteConfig ""
return ResolvedVersion( return ResolvedVersion(
appVersion: pickInt(chain, \.appVersion), appVersion: pickInt(chain, \.appVersion),
appDownload: pickString(chain, \.appDownload), appDownload: pickString(chain, \.appDownload),
gameVersion: pickInt(chain, \.gameVersion), gameVersion: pickInt(chain, \.gameVersion),
gameZip: pickString(chain, \.gameZip), gameZip: pickString(chain, \.gameZip),
// showmessage: 退 config.showmessage showmessage: pickString(chain, \.showmessage),
showmessage: pickString(chain, \.showmessage) ?? config.showmessage audioDomain: pickString(chain, \.audioDomain),
) audioBucket: pickString(chain, \.audioBucket)
}
// MARK: - 4 fallback +
/// CDN / bucket 4 fallback
/// agent game channel market `audio_domain` / `audio_bucket`
/// fallback RemoteConfig `audio_domain` / `audio_bucket`
///
/// - Returns: `(domain, bucket)` + nil
public static func resolveAudio(
config: RemoteConfig,
agentId: String,
channelId: String,
marketId: String,
gameId: String
) -> (domain: String?, bucket: String?) {
let chain = buildChain(
config: config,
agentId: agentId,
gameId: gameId,
channelId: channelId,
marketId: marketId
)
return (
domain: pickString(chain, \.audioDomain) ?? config.audioDomain,
bucket: pickString(chain, \.audioBucket) ?? config.audioBucket
) )
} }
// MARK: - // MARK: -
/// 沿 agent game channel market /// 沿 root agent game channel market
/// RemoteConfig ""
/// ///
/// 0..4 /// 1..5
/// - `[]` agent /// - `[root]` agent
/// - `[agent]` agent game /// - `[root, agent]` agent game
/// - `[agent, game]` /// - `[root, agent, game]`
/// - ... /// - ...
/// - `[agent, game, channel, market]` /// - `[root, agent, game, channel, market]`
private static func buildChain( private static func buildChain(
config: RemoteConfig, config: RemoteConfig,
agentId: String, agentId: String,
@@ -131,7 +118,7 @@ public enum VersionResolver {
channelId: String, channelId: String,
marketId: String marketId: String
) -> [RemoteConfigNode] { ) -> [RemoteConfigNode] {
var chain: [RemoteConfigNode] = [] var chain: [RemoteConfigNode] = [config]
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId }) guard let agent = config.agentlist?.first(where: { $0.agentid == agentId })
else { return chain } else { return chain }
+18 -3
View File
@@ -2,16 +2,19 @@
// BundleConfig.swift // BundleConfig.swift
// ylgamehall // ylgamehall
// //
// Bundle ChannelConfig.plist 10 string // Bundle ChannelConfig.plist 11 string
// docs/H5-Native-Implementation-Design.md §7 / ADR-007 // docs/H5-Native-Implementation-Design.md §7 / ADR-007
// //
// qiniudomain 2026-06-27 CDN //
// - qiniudomain 2026-06-27 CDN
// RemoteConfig audio_domain // RemoteConfig audio_domain
// - multishare sharefriend=1
// ("1" = SharePanel / "0" = )
// //
import Foundation import Foundation
/// 10 `BundleConfig.shared` App /// 11 `BundleConfig.shared` App
/// ///
/// `plutil` `.app/ChannelConfig.plist` + /// `plutil` `.app/ChannelConfig.plist` +
/// Xcode build ADR-007 /// Xcode build ADR-007
@@ -30,6 +33,17 @@ nonisolated public final class BundleConfig: Sendable {
public let appVersion: String public let appVersion: String
public let other: String public let other: String
public let appleConfig: String public let appleConfig: String
/// sharefriend=1
/// "1" SharePanel / QQ /
/// "0"
/// "1"
public let multiShare: String
/// SharePanel `multiShare != "0"`
/// /
public var isMultiShareEnabled: Bool {
multiShare != "0"
}
/// bundle plist fixture /// bundle plist fixture
public init(bundle: Bundle = .main) { public init(bundle: Bundle = .main) {
@@ -44,6 +58,7 @@ nonisolated public final class BundleConfig: Sendable {
appVersion = dict["appversion"] ?? "" appVersion = dict["appversion"] ?? ""
other = dict["other"] ?? "" other = dict["other"] ?? ""
appleConfig = dict["appleconfig"] ?? "" appleConfig = dict["appleconfig"] ?? ""
multiShare = dict["multishare"] ?? ""
} }
private static func loadPlist(bundle: Bundle) -> [String: String] { private static func loadPlist(bundle: Bundle) -> [String: String] {
@@ -68,4 +68,30 @@ public enum SandboxPaths {
.appendingPathComponent(start) .appendingPathComponent(start)
.appendingPathComponent("index.html") .appendingPathComponent("index.html")
} }
// MARK: - App scheme URLPhase 1.F WKURLSchemeHandler
//
// AppSchemeHandler scheme URL +
// host (`ylgame://h5`) same-origin localStorage
// (`lobby` / `subgame/<dir>`)handler
// lobbyRoot / subGameRoot(_:)
/// H5 app:// URL`ylgame://h5/lobby/<gameStart>/index.html`
nonisolated public static var lobbyIndexAppURL: URL {
var c = URLComponents()
c.scheme = AppScheme.scheme
c.host = AppScheme.host
c.path = "/\(AppScheme.lobbyPrefix)/\(BundleConfig.shared.gameStart)/index.html"
// path / force-unwrap
return c.url!
}
/// H5 app:// URL`ylgame://h5/subgame/<dir>/<start>/index.html`
nonisolated public static func subGameIndexAppURL(_ dir: String, _ start: String) -> URL {
var c = URLComponents()
c.scheme = AppScheme.scheme
c.host = AppScheme.host
c.path = "/\(AppScheme.subGamePrefix)/\(dir)/\(start)/index.html"
return c.url!
}
} }
@@ -2,10 +2,11 @@
// WeChatManager.swift // WeChatManager.swift
// ylgamehall // ylgamehall
// //
// SDK delegate + authorize + share // SDK delegate + authorize
// WXApiDelegate async/awaitstate UUID // WXApiDelegate onResp async/awaitstate UUID
// //
// Design §8.5msext WXApiManager.m // + WechatShare.swift
// SDK SendMessageToWXReq OAuth
// //
import Foundation import Foundation
@@ -23,8 +24,6 @@ public final class WeChatManager: NSObject {
/// resolve authorize continuationstate UUID /// resolve authorize continuationstate UUID
private var authPending: [String: CheckedContinuation<WXAuthCodePayload, Error>] = [:] private var authPending: [String: CheckedContinuation<WXAuthCodePayload, Error>] = [:]
/// resolve share continuationFIFO ID
private var sharePending: [CheckedContinuation<Void, Error>] = []
public override init() { public override init() {
super.init() super.init()
@@ -84,125 +83,6 @@ public final class WeChatManager: NSObject {
return top return top
} }
// MARK: - Share
public enum ShareScene {
case session //
case timeline //
}
/// `mediaTagName` msext WXApiRequestHandler `TagName:`
/// WXMediaMessage.mediaTagName
/// UIImage Sendable 使 MainActor actor
public struct ShareLink {
public let url: String
public let title: String
public let desc: String
public let thumb: UIImage?
public let mediaTagName: String?
public init(
url: String, title: String, desc: String,
thumb: UIImage?, mediaTagName: String? = nil
) {
self.url = url; self.title = title; self.desc = desc
self.thumb = thumb; self.mediaTagName = mediaTagName
}
}
/// type=2
/// - `imageData`JPEG
/// - `thumb` SDK `setThumbImage:` 32KB
/// - `messageExt`H5 description `WXMediaMessage.description`
/// `.messageExt` msext `WXApiRequestHandler sendImageData:MessageExt:`
/// - `mediaTagName` msext `TagName:`
public struct ShareImage {
public let imageData: Data
public let thumb: UIImage
public let messageExt: String
public let mediaTagName: String?
public init(
imageData: Data, thumb: UIImage,
messageExt: String = "", mediaTagName: String? = nil
) {
self.imageData = imageData
self.thumb = thumb
self.messageExt = messageExt
self.mediaTagName = mediaTagName
}
}
/// FIFO ID
public func shareLink(_ link: ShareLink, scene: ShareScene) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
sharePending.append(cont)
let media = WXWebpageObject()
media.webpageUrl = link.url
let message = WXMediaMessage()
message.title = link.title
message.description = link.desc
message.mediaObject = media
if let mediaTagName = link.mediaTagName {
message.mediaTagName = mediaTagName
}
if let thumb = link.thumb {
message.setThumbImage(thumb)
}
let req = SendMessageToWXReq()
req.bText = false
req.message = message
switch scene {
case .session: req.scene = Int32(WXSceneSession.rawValue)
case .timeline: req.scene = Int32(WXSceneTimeline.rawValue)
}
// 1.x: sendReq BOOL SceneDelegate.openURLContexts
// WXApi.handleOpen(url:delegate:WeChatManager.shared) onResp
// ObjC selector +sendReq:Swift send(_:) Req
// +[WXApi sendReq:]
WXApi.send(req)
}
}
/// FIFO sharePending msext WechatShareManager
/// `sendImageData:TagName:MessageExt:Action:ThumbImage:InScene:`
/// TagName mediaTagName
/// MessageExt description+ messageExtSDK
/// ThumbImage setThumbImage
public func shareImage(_ img: ShareImage, scene: ShareScene) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
sharePending.append(cont)
let media = WXImageObject()
media.imageData = img.imageData
let message = WXMediaMessage()
message.description = img.messageExt
message.mediaObject = media
message.setThumbImage(img.thumb)
if !img.messageExt.isEmpty {
message.messageExt = img.messageExt
}
if let mediaTagName = img.mediaTagName {
message.mediaTagName = mediaTagName
}
let req = SendMessageToWXReq()
req.bText = false
req.message = message
switch scene {
case .session: req.scene = Int32(WXSceneSession.rawValue)
case .timeline: req.scene = Int32(WXSceneTimeline.rawValue)
}
// ObjC selector +sendReq:Swift send(_:) Req
// +[WXApi sendReq:]
WXApi.send(req)
}
}
// MARK: - Internal: resolve callbacks // MARK: - Internal: resolve callbacks
fileprivate func resolveAuth(state: String, code: String?, errCode: Int) { fileprivate func resolveAuth(state: String, code: String?, errCode: Int) {
@@ -213,16 +93,6 @@ public final class WeChatManager: NSObject {
cont.resume(throwing: WeChatError.authFailed(errCode)) cont.resume(throwing: WeChatError.authFailed(errCode))
} }
} }
fileprivate func resolveShare(errCode: Int) {
guard !sharePending.isEmpty else { return }
let cont = sharePending.removeFirst()
if errCode == 0 {
cont.resume(returning: ())
} else {
cont.resume(throwing: WeChatError.shareFailed(errCode))
}
}
} }
// MARK: - WXApiDelegate // MARK: - WXApiDelegate
@@ -242,11 +112,8 @@ extension WeChatManager: WXApiDelegate {
Task { @MainActor in Task { @MainActor in
Self.shared.resolveAuth(state: state, code: code, errCode: errCode) Self.shared.resolveAuth(state: state, code: code, errCode: errCode)
} }
} else if resp is SendMessageToWXResp {
Task { @MainActor in
Self.shared.resolveShare(errCode: errCode)
}
} }
// WechatShare SendMessageToWXResp
} }
} }
@@ -260,5 +127,4 @@ public struct WXAuthCodePayload: Sendable {
public enum WeChatError: Error, Sendable { public enum WeChatError: Error, Sendable {
case sdkNotLinked case sdkNotLinked
case authFailed(Int) case authFailed(Int)
case shareFailed(Int)
} }
+11 -2
View File
@@ -6,7 +6,10 @@
// ViewController **** mode // ViewController **** mode
// //
// dispatchSubGame msext gameController.m:575-597 // dispatchSubGame msext gameController.m:575-597
// - sharefriend == "1" SharePanel /QQ/ // - sharefriend == "1" BundleConfig.isMultiShareEnabled
// SharePanel /QQ/
// - sharefriend == "1" multishare
//
// - WechatShare.share() timeline // - WechatShare.share() timeline
// //
// dispatchLobby msext NewRootVC.m:453-537 SharePanel // dispatchLobby msext NewRootVC.m:453-537 SharePanel
@@ -27,8 +30,14 @@ public enum ShareCenter {
public static func dispatchSubGame(_ content: ShareContent) { public static func dispatchSubGame(_ content: ShareContent) {
switch content.sharefriend { switch content.sharefriend {
case "1": case "1":
// / SharePanel // /
if BundleConfig.shared.isMultiShareEnabled {
SharePanel.show(content: content) SharePanel.show(content: content)
} else {
// multishare = "0" ,(scene
// / ,)
WechatShare.shared.share(content, scene: .friend)
}
default: default:
// sharefriend == "2" msext WechatShareManager // sharefriend == "2" msext WechatShareManager
+1 -1
View File
@@ -22,7 +22,7 @@ public final class SharePanel: UIView {
private static let log = Logger(subsystem: "ylgamehall", category: "SharePanel") private static let log = Logger(subsystem: "ylgamehall", category: "SharePanel")
private static let panelHeight: CGFloat = 200 private static let panelHeight: CGFloat = 150
private static let buttonSize: CGFloat = 60 private static let buttonSize: CGFloat = 60
private static let animationDuration: TimeInterval = 0.25 private static let animationDuration: TimeInterval = 0.25
+151 -112
View File
@@ -2,161 +2,200 @@
// WechatShare.swift // WechatShare.swift
// ylgamehall // ylgamehall
// //
// SharePlatform + shareLobby() // SharePlatform + + `weixin://` URL Scheme
// QQShare / DouyinShare ** OpenSDK API**
// //
// msext ViewController handler //
// - OpenSDK sendReq " plist + AppID "
// QQ / " URL Scheme"
// SDK +
// - OpenSDK WeChatSDK.register / WeChatAuth.authorize
// SDK WeChatManager
// - scene friend / timeline SharePlatform
//
// //
// share() daoqi msext `WechatShareManager.shareWithContent:` // ViewController handler
// gameController.m 2 type
// - type == "2" thumb = app icon
// - else thumb = app icon
// //
// shareLobby() daoqi msext `NewRootVC.m:471-534` 3 type // share() gameController.m2 type
// - type == "1" thumb = app icon msext // - type == "2" UIPasteboard.image
// sharelogo.png fallback app icon // - else UIPasteboard.string
// - type == "2" thumb = 40%
// - NSData + thumb = 40%
// //
// fire-and-forgetH5 sharesuccess FriendsShareHandler // shareLobby() NewRootVC.m3 type
// dispatch_after 1s completion(YES) // - type == "1"
// - type == "2"
// - UIPasteboard.image
//
// fire-and-forgetH5 sharesuccess FriendsShareHandler
//
// //
import Foundation import Foundation
import UIKit import UIKit
import os.log
@MainActor @MainActor
public final class WechatShare: SharePlatform { public final class WechatShare: SharePlatform {
public static let shared = WechatShare() public static let shared = WechatShare()
private static let log = Logger(subsystem: "ylgamehall", category: "WechatShare")
private static let wechatScheme = "weixin://"
public let name = "WeChat" public let name = "WeChat"
public var isInstalled: Bool { public var isInstalled: Bool {
WXApi.isWXAppInstalled() guard let url = URL(string: Self.wechatScheme) else { return false }
return UIApplication.shared.canOpenURL(url)
} }
public init() {} public init() {}
// MARK: - gameController.m
public func share(_ content: ShareContent, scene: ShareScene) { public func share(_ content: ShareContent, scene: ShareScene) {
guard WXApi.isWXAppInstalled() else { return } guard isInstalled else { return }
let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session
let thumb = Self.appIconImage()
Task { @MainActor in
do {
if content.type == "2" { if content.type == "2" {
// msext WechatShareManager isScreenshotShare shareScreenshot()
let shot = try ImageProvider.captureScreenshot()
guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return }
try await WeChatManager.shared.shareImage(
.init(
imageData: imageData,
thumb: thumb,
messageExt: content.desc,
mediaTagName: "\(Self.appTagPrefix)游戏截图分享"
),
scene: wxScene
)
} else { } else {
// msext else type=="1" shareLink(content)
try await WeChatManager.shared.shareLink(
.init(
url: content.webpageUrl,
title: content.title,
desc: content.desc,
thumb: thumb,
mediaTagName: "\(Self.appTagPrefix)游戏分享"
),
scene: wxScene
)
}
} catch {
// SDK H5 sharesuccess
}
} }
} }
// MARK: - NewRootVC.m // MARK: - NewRootVC.m
/// H5 friendsShare msext NewRootVC.m
/// :471-534 3 SharePanel
public func shareLobby(_ content: ShareContent, scene: ShareScene) { public func shareLobby(_ content: ShareContent, scene: ShareScene) {
guard WXApi.isWXAppInstalled() else { return } guard isInstalled else { return }
let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session
let appIcon = Self.appIconImage()
Task { @MainActor in
do {
switch content.type { switch content.type {
case "1": case "1":
// msext sharelogo.png fallback app icon shareLink(content)
try await WeChatManager.shared.shareLink(
.init(
url: content.webpageUrl,
title: content.title,
desc: content.desc,
thumb: appIcon,
mediaTagName: "\(Self.appTagPrefix)游戏下载链接"
),
scene: wxScene
)
case "2": case "2":
// thumb = 40% msext imageByScalingAndCroppingForSize:0.4x shareScreenshot()
let shot = try ImageProvider.captureScreenshot()
guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return }
try await WeChatManager.shared.shareImage(
.init(
imageData: imageData,
thumb: ImageProvider.scaledThumb(shot, scale: 0.4),
messageExt: content.desc,
mediaTagName: "\(Self.appTagPrefix)游戏截图分享"
),
scene: wxScene
)
default: default:
// type == "" / "3" / NSData + shareRemoteImage(content)
// thumb = 40% messageExt/mediaTagName
// msext NewRootVC.m:530 H5 desc
guard let url = URL(string: content.webpageUrl) else { return }
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { return }
try await WeChatManager.shared.shareImage(
.init(
imageData: data,
thumb: ImageProvider.scaledThumb(image, scale: 0.4),
messageExt: "\(Self.appTagPrefix)公众号二维码分享",
mediaTagName: "\(Self.appTagPrefix)公众号二维码分享"
),
scene: wxScene
)
} }
}
// MARK: -
/// UIPasteboard.image ""
private func shareScreenshot() {
do {
let shot = try ImageProvider.captureScreenshot()
UIPasteboard.general.image = shot
Self.log.debug("WechatShare 截图已复制到剪贴板")
} catch { } catch {
// sharesuccess Self.log.error("WechatShare 截图失败 \(error.localizedDescription, privacy: .public)")
return
} }
let message = """
🎯
1
2
3
"""
showGuidanceAlert(title: "微信分享准备完成", message: message)
}
// MARK: -
/// title + "\n\n" + desc UIPasteboard.string
/// DouyinShare 1:1 url
/// ""
private func shareLink(_ content: ShareContent) {
var shareText = ""
if !content.title.isEmpty {
shareText = content.title
}
if !content.desc.isEmpty {
shareText = shareText.isEmpty ? content.desc : "\(shareText)\n\n\(content.desc)"
}
if shareText.isEmpty {
shareText = "来自进贤聚友棋牌的精彩内容分享"
}
UIPasteboard.general.string = shareText
Self.log.debug("WechatShare 链接已复制到剪贴板")
let message = """
🎯
1
2
3
"""
showGuidanceAlert(title: "微信分享准备完成", message: message)
}
// MARK: - shareLobby ""
/// UIPasteboard.image msext NewRootVC.m:526-534
/// "" SDK NSData
/// UIImage
private func shareRemoteImage(_ content: ShareContent) {
guard let url = URL(string: content.webpageUrl) else {
Self.log.error("WechatShare 远端图 URL 解析失败")
return
}
Task { @MainActor in
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
Self.log.error("WechatShare 远端图解码失败")
return
}
UIPasteboard.general.image = image
Self.log.debug("WechatShare 远端图已复制到剪贴板")
} catch {
Self.log.error("WechatShare 远端图下载失败 \(error.localizedDescription, privacy: .public)")
return
}
let message = """
🎯
1
2
3
"""
showGuidanceAlert(title: "微信分享准备完成", message: message)
} }
} }
// MARK: - / // MARK: -
/// msext WechatShareManager "" daoqi /// " / " Alert QQShare / DouyinShare UX
/// SGDefineInfo.h gamehallname WXMediaMessage.mediaTagName private func showGuidanceAlert(title: String, message: String) {
private static let appTagPrefix = "进贤聚友棋牌" let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "立即打开微信", style: .default) { _ in
/// app icon Info.plist `CFBundleIcons` `CFBundlePrimaryIcon` if let url = URL(string: Self.wechatScheme),
/// `CFBundleIconFiles` msext UIApplication.shared.canOpenURL(url) {
/// WechatShareManager `getAppIconImage` 1x1 UIApplication.shared.open(url)
private static func appIconImage() -> UIImage {
if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
let files = primary["CFBundleIconFiles"] as? [String],
let last = files.last,
let icon = UIImage(named: last) {
return icon
} }
if let icon = UIImage(named: "AppIcon") { return icon } })
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) alert.addAction(UIAlertAction(title: "稍后分享", style: .cancel))
return renderer.image { _ in }
guard let topVC = Self.topViewController() else { return }
topVC.present(alert, animated: true)
}
private static func topViewController() -> UIViewController? {
let scene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first(where: { $0.activationState == .foregroundActive })
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
guard var top = scene?.windows.first(where: \.isKeyWindow)?.rootViewController
?? scene?.windows.first?.rootViewController else { return nil }
while let presented = top.presentedViewController { top = presented }
if let nav = top as? UINavigationController, let visible = nav.visibleViewController {
return visible
}
return top
} }
} }
@@ -0,0 +1,415 @@
//
// AppSchemeHandler.swift
// ylgamehall
//
// WKURLSchemeHandler `ylgame://h5/...` scheme
// loadFileURL + origin
// (`ylgame://h5`)
// 1) localStorage scheme + host = security origin
// 2) file:// XHR/fetch null-origin Cocos H5 build
// 3) `<audio>/<video>` Range Cocos seek
//
//
// ylgame://h5/lobby/<rest...> SandboxPaths.lobbyRoot/<rest...>
// ylgame://h5/subgame/<dir>/<rest...> SandboxPaths.subGameRoot(dir)/<rest...>
//
// / Phase docs/H5-Native-Implementation-Design.md §17/
// docs/Development-Plan.md Phase 1.F
//
import Foundation
@preconcurrency import WebKit
import os.log
// MARK: -
/// scheme + host + ** nonisolated**
/// `SandboxPaths`nonisolated URL
/// MainActor-by-default nonisolated static
/// MainActor IO 访
public enum AppScheme {
/// scheme = "ylgame" app scheme
nonisolated public static let scheme = "ylgame"
/// host = "h5"** H5 + host**
/// same-origin localStorage / IndexedDB / sessionStorage
nonisolated public static let host = "h5"
/// `/lobby/...`
nonisolated public static let lobbyPrefix = "lobby"
/// `/subgame/<dir>/...`
nonisolated public static let subGamePrefix = "subgame"
}
// MARK: - Handler
/// URL scheme WKURLSchemeHandler**** BridgedWebView
/// WKWebViewConfiguration WKWebViewConfiguration
/// configuration scheme
@MainActor
public final class AppSchemeHandler: NSObject, WKURLSchemeHandler {
public static let shared = AppSchemeHandler()
private static let log = Logger(subsystem: "ylgamehall", category: "AppSchemeHandler")
/// IO file IO + Range parsing 线
private let ioQueue = DispatchQueue(
label: "ylgamehall.app-scheme-handler.io",
qos: .userInitiated,
attributes: .concurrent
)
/// task**线访**start / stop / deliver MainActor
/// ObjectIdentifier key IO Sendable key
/// Sendable `any WKURLSchemeTask` `@Sendable`
/// WKURLSchemeTask stop task NSException
/// `cancelledKeys` `tasksByKey` 使
private var tasksByKey: [ObjectIdentifier: any WKURLSchemeTask] = [:]
private var cancelledKeys: Set<ObjectIdentifier> = []
private override init() {
super.init()
}
// MARK: - WKURLSchemeHandler
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
let key = ObjectIdentifier(urlSchemeTask)
tasksByKey[key] = urlSchemeTask
guard let url = urlSchemeTask.request.url else {
finish(key: key, withError: AppSchemeError.invalidURL)
return
}
let rangeHeader = urlSchemeTask.request.value(forHTTPHeaderField: "Range")
// + 线
let resolved: ResolvedRequest
do {
resolved = try Self.resolveRequest(url: url, rangeHeader: rangeHeader)
} catch {
finish(key: key, withError: error)
return
}
// + Sendable `key` / `resolved`
// Sendable `urlSchemeTask`
ioQueue.async { [weak self] in
let result = Self.readAndBuildResponse(resolved: resolved)
Task { @MainActor in
self?.deliver(result: result, forKey: key)
}
}
}
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
let key = ObjectIdentifier(urlSchemeTask)
// io deliver tasksByKey deliver
// / finish IO
cancelledKeys.insert(key)
}
// MARK: - Routingnonisolated
fileprivate struct ResolvedRequest {
let fileURL: URL
let url: URL
let rangeHeader: String?
}
/// `ylgame://h5/<prefix>/<rest>`
/// - lobby rest lobbyRoot
/// - subgame dir subGameRoot(dir)
nonisolated fileprivate static func resolveRequest(
url: URL,
rangeHeader: String?
) throws -> ResolvedRequest {
guard url.scheme?.lowercased() == AppScheme.scheme,
url.host?.lowercased() == AppScheme.host
else {
throw AppSchemeError.invalidURL
}
// path "/lobby/gameStart/index.html" "/subgame/<dir>/<start>/index.html"
let segments = url.path
.split(separator: "/", omittingEmptySubsequences: true)
.map(String.init)
guard let prefix = segments.first else {
throw AppSchemeError.invalidURL
}
let fileURL: URL
switch prefix {
case AppScheme.lobbyPrefix:
let rest = segments.dropFirst().joined(separator: "/")
fileURL = SandboxPaths.lobbyRoot.appendingPathComponent(rest)
case AppScheme.subGamePrefix:
guard segments.count >= 2 else {
throw AppSchemeError.invalidURL
}
let dir = segments[1]
let rest = segments.dropFirst(2).joined(separator: "/")
fileURL = SandboxPaths.subGameRoot(dir).appendingPathComponent(rest)
default:
throw AppSchemeError.invalidURL
}
return ResolvedRequest(fileURL: fileURL, url: url, rangeHeader: rangeHeader)
}
// MARK: - Reading + response buildingIO
fileprivate enum BuildResult {
case ok(HTTPURLResponse, Data)
case fail(AppSchemeError)
}
nonisolated fileprivate static func readAndBuildResponse(
resolved: ResolvedRequest
) -> BuildResult {
let fileURL = resolved.fileURL
let fm = FileManager.default
guard fm.fileExists(atPath: fileURL.path),
let attrs = try? fm.attributesOfItem(atPath: fileURL.path),
let size = (attrs[.size] as? NSNumber)?.intValue
else {
return .fail(.fileNotFound(fileURL.path))
}
let mime = MimeMap.mime(forExtension: fileURL.pathExtension)
// Range 206
if let rangeHeader = resolved.rangeHeader,
let (start, end) = parseRange(rangeHeader, totalSize: size) {
do {
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
try handle.seek(toOffset: UInt64(start))
let length = end - start + 1
let data = try handle.read(upToCount: length) ?? Data()
let response = makePartialResponse(
url: resolved.url,
mime: mime,
start: start,
end: end,
totalSize: size,
contentLength: data.count
)
return .ok(response, data)
} catch {
return .fail(.readFailed(error))
}
}
// 200
do {
let data = try Data(contentsOf: fileURL, options: [.mappedIfSafe])
let response = makeFullResponse(
url: resolved.url,
mime: mime,
contentLength: data.count
)
return .ok(response, data)
} catch {
return .fail(.readFailed(error))
}
}
nonisolated private static func makeFullResponse(
url: URL,
mime: String,
contentLength: Int
) -> HTTPURLResponse {
let headers: [String: String] = [
"Content-Type": mime,
"Content-Length": "\(contentLength)",
// CORS scheme preflight Cocos build
// fetch mode='cors' preflight *
"Access-Control-Allow-Origin": "*",
// lobby zip
"Cache-Control": "no-cache",
"Accept-Ranges": "bytes"
]
return HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
}
nonisolated private static func makePartialResponse(
url: URL,
mime: String,
start: Int,
end: Int,
totalSize: Int,
contentLength: Int
) -> HTTPURLResponse {
let headers: [String: String] = [
"Content-Type": mime,
"Content-Length": "\(contentLength)",
"Content-Range": "bytes \(start)-\(end)/\(totalSize)",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Accept-Ranges": "bytes"
]
return HTTPURLResponse(
url: url,
statusCode: 206,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
}
/// Range `bytes=start-end` / `bytes=start-` / `bytes=-suffix`
/// multi-rangeCocos
nonisolated private static func parseRange(
_ header: String,
totalSize: Int
) -> (Int, Int)? {
guard header.lowercased().hasPrefix("bytes=") else { return nil }
let spec = String(header.dropFirst("bytes=".count))
if spec.contains(",") { return nil }
let parts = spec.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2 else { return nil }
let startStr = String(parts[0])
let endStr = String(parts[1])
// bytes=-N N
if startStr.isEmpty, let suffix = Int(endStr), suffix > 0 {
let start = max(totalSize - suffix, 0)
return (start, totalSize - 1)
}
guard let start = Int(startStr), start >= 0, start < totalSize else { return nil }
if endStr.isEmpty {
return (start, totalSize - 1)
}
guard let end = Int(endStr), end >= start, end < totalSize else { return nil }
return (start, end)
}
// MARK: - Delivery线
private func deliver(result: BuildResult, forKey key: ObjectIdentifier) {
// task stop task NSException
// 使
guard let urlSchemeTask = tasksByKey.removeValue(forKey: key) else { return }
if cancelledKeys.remove(key) != nil { return }
switch result {
case .ok(let response, let data):
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
Self.log.debug("\(response.statusCode, privacy: .public) \(response.url?.absoluteString ?? "", privacy: .public) \(data.count, privacy: .public)B")
case .fail(let error):
urlSchemeTask.didFailWithError(error.toNSError)
Self.log.error("\(error.debugDescription, privacy: .public)")
}
}
private func finish(key: ObjectIdentifier, withError error: any Error) {
guard let urlSchemeTask = tasksByKey.removeValue(forKey: key) else { return }
if cancelledKeys.remove(key) != nil { return }
let ns = (error as? AppSchemeError)?.toNSError ?? (error as NSError)
urlSchemeTask.didFailWithError(ns)
}
}
// MARK: - Errors
public enum AppSchemeError: Error, CustomDebugStringConvertible, Sendable {
case invalidURL
case fileNotFound(String)
case readFailed(any Error)
public var debugDescription: String {
switch self {
case .invalidURL: return "invalid url"
case .fileNotFound(let p): return "file not found: \(p)"
case .readFailed(let e): return "read failed: \(e)"
}
}
var toNSError: NSError {
switch self {
case .invalidURL:
return NSError(
domain: "AppSchemeHandler",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "invalid url"]
)
case .fileNotFound(let p):
return NSError(
domain: "AppSchemeHandler",
code: 404,
userInfo: [NSLocalizedDescriptionKey: "not found: \(p)"]
)
case .readFailed(let e):
return e as NSError
}
}
}
// MARK: - MIME map
/// MIME Cocos H5 build
/// `application/octet-stream`WKWebView
/// `nonisolated` IO MainActor-by-default
enum MimeMap {
nonisolated static func mime(forExtension ext: String) -> String {
let lower = ext.lowercased()
return table[lower] ?? "application/octet-stream"
}
nonisolated private static let table: [String: String] = [
//
"html": "text/html; charset=utf-8",
"htm": "text/html; charset=utf-8",
"js": "text/javascript; charset=utf-8",
"mjs": "text/javascript; charset=utf-8",
"css": "text/css; charset=utf-8",
"json": "application/json; charset=utf-8",
"txt": "text/plain; charset=utf-8",
"xml": "application/xml; charset=utf-8",
"atlas": "text/plain; charset=utf-8", // Cocos atlas
"fnt": "text/plain; charset=utf-8", // Cocos bitmap font
"plist": "application/x-plist",
"wasm": "application/wasm",
//
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"svg": "image/svg+xml",
"ico": "image/x-icon",
"bmp": "image/bmp",
//
"mp3": "audio/mpeg",
"ogg": "audio/ogg",
"oga": "audio/ogg",
"wav": "audio/wav",
"m4a": "audio/mp4",
"aac": "audio/aac",
//
"mp4": "video/mp4",
"m4v": "video/mp4",
"webm": "video/webm",
"mov": "video/quicktime",
//
"ttf": "font/ttf",
"otf": "font/otf",
"woff": "font/woff",
"woff2": "font/woff2",
"eot": "application/vnd.ms-fontobject",
"bin": "application/octet-stream"
]
}
+18 -3
View File
@@ -17,7 +17,9 @@ public final class BridgedWebView: UIView {
public let webView: WKWebView public let webView: WKWebView
public let bridge: BridgeBus public let bridge: BridgeBus
public init() { /// - Parameter label: `lobby` / `subGame`
///
public init(label: String = "webview") {
// WKWebViewConfiguration §4.1 // WKWebViewConfiguration §4.1
let configuration = WKWebViewConfiguration() let configuration = WKWebViewConfiguration()
configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.defaultWebpagePreferences.allowsContentJavaScript = true
@@ -30,6 +32,18 @@ public final class BridgedWebView: UIView {
configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.mediaTypesRequiringUserActionForPlayback = []
configuration.processPool = SharedProcessPool.shared configuration.processPool = SharedProcessPool.shared
// URL scheme Phase 1.F
// + H5 `ylgame://h5/...` AppSchemeHandler
// docs/H5-Native-Implementation-Design.md §17
// - file:// origin nullXHR/fetch scheme origin
// - + `ylgame://h5` host same-origin
// localStorage file://
// - Cocos H5
configuration.setURLSchemeHandler(
AppSchemeHandler.shared,
forURLScheme: AppScheme.scheme
)
// documentStart WebViewJavascriptBridge.js // documentStart WebViewJavascriptBridge.js
// .atDocumentStart H5 window.WebViewJavascriptBridge // .atDocumentStart H5 window.WebViewJavascriptBridge
if let url = Bundle.main.url(forResource: "WebViewJavascriptBridge", if let url = Bundle.main.url(forResource: "WebViewJavascriptBridge",
@@ -49,7 +63,7 @@ public final class BridgedWebView: UIView {
// H5 Phase 9.2 A H5 // H5 Phase 9.2 A H5
// console.error / window.onerror / unhandledrejection // console.error / window.onerror / unhandledrejection
// webkit.messageHandlers.h5error print // webkit.messageHandlers.h5error print
H5ErrorRelay.shared.install(into: configuration.userContentController) H5ErrorRelay.shared.install(into: configuration.userContentController, label: label)
// WKWebView + BridgeBus // WKWebView + BridgeBus
let webView = WKWebView(frame: .zero, configuration: configuration) let webView = WKWebView(frame: .zero, configuration: configuration)
@@ -59,7 +73,8 @@ public final class BridgedWebView: UIView {
} }
#endif #endif
let bridge = BridgeBus(webView: webView, let bridge = BridgeBus(webView: webView,
controller: configuration.userContentController) controller: configuration.userContentController,
label: label)
self.webView = webView self.webView = webView
self.bridge = bridge self.bridge = bridge
@@ -33,7 +33,11 @@ public final class SubGameViewController: UIViewController {
// MARK: - UI // 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() private let splash = SplashOverlay()
// MARK: - Handlers // MARK: - Handlers
@@ -88,7 +92,7 @@ public final class SubGameViewController: UIViewController {
DeviceInfoHandler.register(on: bridge) DeviceInfoHandler.register(on: bridge)
BrowserHandler.register(on: bridge) BrowserHandler.register(on: bridge)
OpenSaomaHandler.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 // assetsRoot = H5 msext gameController.m:364
// closure effectiveGameDir boot pipeline // closure effectiveGameDir boot pipeline
@@ -118,7 +122,7 @@ public final class SubGameViewController: UIViewController {
OpenurlTitleDataHandler.register(on: bridge) OpenurlTitleDataHandler.register(on: bridge)
// backgameData退 + callback getWebdata // backgameData退 + callback getWebdata
BackGameDataHandler.register(on: bridge) BackGameDataHandler.register(on: bridge, location: locationService)
// 3 stub H5 "no handler" // 3 stub H5 "no handler"
// Agora VideoRoomHandlers 3 stub // Agora VideoRoomHandlers 3 stub
@@ -245,13 +249,13 @@ public final class SubGameViewController: UIViewController {
try writer.writeBattery(BatteryMonitor.shared.currentLevel) try writer.writeBattery(BatteryMonitor.shared.currentLevel)
try writer.writeNetwork(NetworkMonitor.shared.currentCode) try writer.writeNetwork(NetworkMonitor.shared.currentCode)
// 3. H5allowingReadAccessTo subGameRoot // 3. H5Phase 1.F loadFileURL AppSchemeHandler
// URLylgame://h5/subgame/<dir>/<start>/index.html
// host"h5" same-origin localStorage
// subGameRoot(<dir>) allowingReadAccessTo
splash.update(text: "进入子游戏...", progress: nil) splash.update(text: "进入子游戏...", progress: nil)
let indexURL = SandboxPaths.subGameIndex(effectiveGameDir, request.gameStart) let indexURL = SandboxPaths.subGameIndexAppURL(effectiveGameDir, request.gameStart)
bridgedWebView.webView.loadFileURL( bridgedWebView.webView.load(URLRequest(url: indexURL))
indexURL,
allowingReadAccessTo: SandboxPaths.subGameRoot(effectiveGameDir)
)
} }
/// resolve /// resolve
@@ -408,5 +412,26 @@ extension SubGameViewController: WKNavigationDelegate {
self?.splash.removeFromSuperview() 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 // 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() private let splash = SplashOverlay()
// MARK: - Handlers ( handler enum register) // MARK: - Handlers ( handler enum register)
@@ -77,7 +81,7 @@ public final class WebContainerViewController: UIViewController {
DeviceInfoHandler.register(on: bridge) // §3.1 21 + §3.2 1 DeviceInfoHandler.register(on: bridge) // §3.1 21 + §3.2 1
BrowserHandler.register(on: bridge) // §3.1 16 BrowserHandler.register(on: bridge) // §3.1 16
OpenSaomaHandler.register(on: bridge) // §3.1 22 stub OpenSaomaHandler.register(on: bridge) // §3.1 22 stub
StartLocationHandler.register(on: bridge) // §3.1 20Phase 5 Phase 2 stub StartLocationHandler.register(on: bridge, label: "lobby", location: locationService) // §3.1 20Phase 5 Phase 2 stub
// Phase 3.A + 3.C/3.D stub // Phase 3.A + 3.C/3.D stub
// assetsRoot = H5 lobbyIndex lobby // assetsRoot = H5 lobbyIndex lobby
@@ -194,6 +198,21 @@ public final class WebContainerViewController: UIViewController {
// snapshot NWPath """" kind // snapshot NWPath """" kind
let path = await Self.currentPathSnapshot() let path = await Self.currentPathSnapshot()
let kind = Self.classifyBootError(error, path: path) let kind = Self.classifyBootError(error, path: path)
// iOS ,pipeline
// ; timeout "", fail NWPath
// wifiDenied / cellularDenied ""
// ""("")
//
// kind.isPermissionDenied , NWPath5s
// satisfied pipeline();
// "" 5s
if kind.isPermissionDenied,
await Self.waitForNetworkPermissionResolution(timeout: 5.0) {
await runBootPipeline()
return
}
presentBootError(error, kind: kind) presentBootError(error, kind: kind)
} }
} }
@@ -216,14 +235,15 @@ public final class WebContainerViewController: UIViewController {
title = "当前未联网" title = "当前未联网"
message = "请检查 Wi-Fi 或蜂窝数据后重试。" message = "请检查 Wi-Fi 或蜂窝数据后重试。"
actionTitle = "重试" actionTitle = "重试"
case .wifiDenied: case .wifiDenied, .cellularDenied:
title = "未授权使用无线局域网" // iOS Settings 线combined toggle :
// " / WLAN / WLAN ",
// NWPath wifiDenied vs cellularDenied
// BootErrorKind (),
// ("线"),""
title = "未授权使用无线数据"
message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,授权后将自动重试。" message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,授权后将自动重试。"
actionTitle = "前往设置" actionTitle = "前往设置"
case .cellularDenied:
title = "未授权使用蜂窝数据"
message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,或连接 Wi-Fi 后重试。"
actionTitle = "前往设置"
case .networkTimeout: case .networkTimeout:
title = "网络较慢" title = "网络较慢"
message = "连接超时,请稍后重试。" message = "连接超时,请稍后重试。"
@@ -347,24 +367,9 @@ public final class WebContainerViewController: UIViewController {
case .parsed(let cfg): case .parsed(let cfg):
let bc = BundleConfig.shared let bc = BundleConfig.shared
// audio_domain / audio_bucket 4 fallback // 4 fallback + + showmessage
// agent game channel market RemoteConfig // VersionResolver.resolve(...) agent game channel market
// ,使 // fallback RemoteConfig
let audio = VersionResolver.resolveAudio(
config: cfg,
agentId: bc.agent,
channelId: bc.channel,
marketId: bc.market,
gameId: bc.gameId
)
print("[QiniuConfig] resolved audio domain=\(audio.domain ?? "<nil>") bucket=\(audio.bucket ?? "<nil>")")
guard let audioDomain = audio.domain, !audioDomain.isEmpty,
let audioBucket = audio.bucket, !audioBucket.isEmpty
else {
throw BootError.audioConfigMissing
}
await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)
let r = VersionResolver.resolve( let r = VersionResolver.resolve(
config: cfg, config: cfg,
agentId: bc.agent, agentId: bc.agent,
@@ -372,6 +377,16 @@ public final class WebContainerViewController: UIViewController {
marketId: bc.market, marketId: bc.market,
gameId: bc.gameId gameId: bc.gameId
) )
print("[VersionResolver] resolved appVer=\(r.appVersion) gameVer=\(r.gameVersion) audioDomain=\(r.audioDomain ?? "<nil>") audioBucket=\(r.audioBucket ?? "<nil>")")
// audio_domain / audio_bucket ,
// 使
guard let audioDomain = r.audioDomain, !audioDomain.isEmpty,
let audioBucket = r.audioBucket, !audioBucket.isEmpty
else {
throw BootError.audioConfigMissing
}
await QiniuConfig.shared.update(cdnDomain: audioDomain, bucketName: audioBucket)
// #2showmessage // #2showmessage
if let msg = r.showmessage, !msg.isEmpty { if let msg = r.showmessage, !msg.isEmpty {
@@ -412,12 +427,13 @@ public final class WebContainerViewController: UIViewController {
// docs/H5-Native-Implementation-Design.md §7.5 // docs/H5-Native-Implementation-Design.md §7.5
try writeAppDataFiles(resolved: resolved) try writeAppDataFiles(resolved: resolved)
// 7. H5allowingReadAccessTo lobbyRoot // 7. H5Phase 1.F loadFileURL AppSchemeHandler
// URLylgame://h5/lobby/<gameStart>/index.html
// AppSchemeHandler /lobby/<rest> lobbyRoot/<rest>
// H5 ../foo.js lobbyRoot file://
// allowingReadAccessTo
splash.update(text: "加载大厅...", progress: nil) splash.update(text: "加载大厅...", progress: nil)
bridgedWebView.webView.loadFileURL( bridgedWebView.webView.load(URLRequest(url: SandboxPaths.lobbyIndexAppURL))
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
} }
/// 4 app_*.js loadFileURL monitor /// 4 app_*.js loadFileURL monitor
@@ -559,27 +575,66 @@ public final class WebContainerViewController: UIViewController {
} }
private func showIPAUpgradeAlert(downloadURL: String) { private func showIPAUpgradeAlert(downloadURL: String) {
// Safari exit(0) // + App
// iOS itms-services:// source app // App App 退
// exit Safari URL
//
// //
// Safari App Safari // iOS itms-services App inactive
// iOS IPA App // App dismissApp active
// // **** App selector
// `suspend` Home /
// / AppStore API
//
// URL itms-services / .plist / Safari makeInstallURL
// Safari suspend Safarisuspend Safari
let alert = UIAlertController( let alert = UIAlertController(
title: "需要升级", title: "需要升级",
message: "检测到新版本,点击「确定」前往下载。\n\n安装确认弹窗出现后请点击「安装」,然后从多任务列表上滑退出本 App,更新即可自动完成", message: "检测到新版本,点击「确定」开始升级,系统会自动下载并安装",
preferredStyle: .alert preferredStyle: .alert
) )
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
if let url = URL(string: downloadURL) { guard let url = Self.makeInstallURL(from: downloadURL) else { return }
UIApplication.shared.open(url) UIApplication.shared.open(url)
if url.scheme?.lowercased() == "itms-services" {
// 0.3s itms-services URL schedule
// App
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
Self.suspendAppToHomeScreen()
}
} }
}) })
present(alert, animated: true) present(alert, animated: true)
} }
/// App App
/// selector `suspend` Home /
/// `UIControl.sendAction` selector `perform()`
private static func suspendAppToHomeScreen() {
let sel = NSSelectorFromString("suspend")
UIControl().sendAction(sel, to: UIApplication.shared, for: nil)
}
/// `appDownload` iOS Safari
///
/// raw URL
/// - `itms-services://...` iOS
/// - `https?://...*.plist` manifest
/// `itms-services://?action=download-manifest&url=<encoded>`iOS
/// - / Safari
/// URL Safari
private static func makeInstallURL(from raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
let lower = trimmed.lowercased()
if lower.hasPrefix("itms-services://") {
return URL(string: trimmed)
}
if (lower.hasPrefix("https://") || lower.hasPrefix("http://")),
lower.hasSuffix(".plist") {
let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? trimmed
return URL(string: "itms-services://?action=download-manifest&url=\(encoded)")
}
return URL(string: trimmed)
}
/// NWPath.unsatisfiedReason """" /// NWPath.unsatisfiedReason """"
/// iOS 14+ NSURLError -1009 path wifiDenied / cellularDenied /// iOS 14+ NSURLError -1009 path wifiDenied / cellularDenied
/// fallback NSURLError code /// fallback NSURLError code
@@ -663,6 +718,34 @@ public final class WebContainerViewController: UIViewController {
monitor.start(queue: DispatchQueue.global(qos: .userInitiated)) monitor.start(queue: DispatchQueue.global(qos: .userInitiated))
} }
} }
/// `timeout` NWPath
/// `.satisfied`""
/// true ( pipeline);false
/// ()
private static func waitForNetworkPermissionResolution(timeout: TimeInterval) async -> Bool {
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
let monitor = NWPathMonitor()
nonisolated(unsafe) var resumed = false
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
guard !resumed else { return }
resumed = true
monitor.cancel()
cont.resume(returning: false)
}
monitor.pathUpdateHandler = { path in
guard !resumed else { return }
if path.status == .satisfied {
resumed = true
monitor.cancel()
cont.resume(returning: true)
}
}
monitor.start(queue: DispatchQueue.global(qos: .userInitiated))
}
}
} }
// MARK: - WKNavigationDelegate // MARK: - WKNavigationDelegate
@@ -677,5 +760,30 @@ extension WebContainerViewController: WKNavigationDelegate {
self?.splash.removeFromSuperview() 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 进程终止(多为内存不足),页面已白屏")
}
} }