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 极光降级)