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>
This commit is contained in:
joywayer
2026-06-29 08:58:00 +08:00
co-authored by Claude Opus 4.7
parent 960adc5491
commit 04e702a99b
8 changed files with 686 additions and 17 deletions
+84 -1
View File
@@ -319,6 +319,40 @@ Contract Design Plan(本文档)
- BuildProject 通过
- [ ] **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 页(首次会先经历"远程配置拉取"流程)
@@ -326,6 +360,7 @@ Contract Design Plan(本文档)
- H5 收到 `responseCallback("vibrator")`
- 模拟 `game_version` 远端 +1,重启 App 后能看到 zip 重新下载 + 解压 + 加载新版本
- 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.16 SceneDelegate → WebContainerViewController(删除 M0 占位 RootViewController.swift
- [ ] 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
- [x] 2.1 剪贴板(ClipboardHandler.swift
@@ -1280,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-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 极光降级)
+10 -5
View File
@@ -46,7 +46,8 @@ iOS 26 时代 iOS 9 路径再也跑不到了(最低支持 iOS 9.0,但所有
- H5 源码以 `gamehall.zip`(≈ 11 MB)形式打入 Bundle
- 首次启动解压到沙盒 `Library/Caches/{gamedir}/``gamedir` 是渠道注入值)
- 启动入口固定为:`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`
### 0.3 渠道注入机制("空目录名注入")
@@ -696,9 +697,11 @@ _webView.scrollView.showsHorizontalScrollIndicator = NO;
```
- 不设置自定义 User-Agent
- 不显式管理 Cookie
- LocalStorage 由 H5 自管(WKWebView 默认走 WebsiteDataStore
- 加载方式:**`loadFileURL:fileURL allowingReadAccessToURL:dirURL`**file://),不走 HTTP
- 不显式管理 Cookie(业务确认现网 H5 不用 cookies)
- LocalStorage 由 H5 自管(WKWebView 默认走 `WKWebsiteDataStore.default()` 持久化);**跨 H5 共享语义**:大厅 + 所有子游戏共享同一 localStorage 命名空间(业务依赖)。msext 现网 file:// 下因 null origin 天然共享;新外壳 Phase 1.F 起用 `ylgame://h5/...` 单虚拟 host same-origin 保留此语义
- 加载方式:
- **msext 现网**`loadFileURL:fileURL allowingReadAccessToURL:dirURL`file://),不走 HTTP
- **新外壳(Phase 1.F 起)**`webView.load(URLRequest(url: ylgame://h5/...))` + `WKURLSchemeHandler` 反向映射;行为对 H5 等价(详见 Design §7.6.5 等价性表 / Plan ADR-010
### 4.2 JS 注入(最终路径:原生 `writeToFile` 写文件,与 msext 1:1 等价)
@@ -1035,7 +1038,9 @@ Library/Caches/
- [ ] 重装 + 热启动:< 1 秒进入大厅
- [ ] 渠道注入:把 `msext/channel/xxx` 目录名改成 `yyy`,重打包后大厅显示的渠道 ID 变为 `yyy`
- [ ] iOS 18 / 19 / 26 上启动不卡死
- [ ] **`app_*.js` 4 个文件在沙盒**`{gamedir}/{gamestart}/``app_data.js` / `app_battery.js` / `app_network.js` / `app_gamesname.js` 全部存在、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 前缀)必须精确
- [ ] **值是实际渠道值而非占位**H5 console 读 `app_channel` 应为 `ChannelConfig.plist` 的 channel 值(不是 H5 团队 zip 内可能预留的占位字符串)
- [ ] **大厅 vs 子游戏字面差异**:大厅 H5 内 `app_Launchtype === 0`(数字 0,非字符串 `"0"`)、子游戏 H5 内 `app_Launchtype === 1`
+127
View File
@@ -2400,6 +2400,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.1 AudioKit
@@ -68,4 +68,30 @@ public enum SandboxPaths {
.appendingPathComponent(start)
.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!
}
}
@@ -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"
]
}
@@ -30,6 +30,18 @@ public final class BridgedWebView: UIView {
configuration.mediaTypesRequiringUserActionForPlayback = []
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
// .atDocumentStart H5 window.WebViewJavascriptBridge
if let url = Bundle.main.url(forResource: "WebViewJavascriptBridge",
@@ -245,13 +245,13 @@ public final class SubGameViewController: UIViewController {
try writer.writeBattery(BatteryMonitor.shared.currentLevel)
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)
let indexURL = SandboxPaths.subGameIndex(effectiveGameDir, request.gameStart)
bridgedWebView.webView.loadFileURL(
indexURL,
allowingReadAccessTo: SandboxPaths.subGameRoot(effectiveGameDir)
)
let indexURL = SandboxPaths.subGameIndexAppURL(effectiveGameDir, request.gameStart)
bridgedWebView.webView.load(URLRequest(url: indexURL))
}
/// resolve
@@ -407,12 +407,13 @@ public final class WebContainerViewController: UIViewController {
// docs/H5-Native-Implementation-Design.md §7.5
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)
bridgedWebView.webView.loadFileURL(
SandboxPaths.lobbyIndex,
allowingReadAccessTo: SandboxPaths.lobbyRoot
)
bridgedWebView.webView.load(URLRequest(url: SandboxPaths.lobbyIndexAppURL))
}
/// 4 app_*.js loadFileURL monitor