Compare commits
10
Commits
a54d1511be
...
8a7d543cdc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7d543cdc | ||
|
|
842ca2b9dc | ||
|
|
d76064c4c5 | ||
|
|
113b4d4960 | ||
|
|
c1e51d2183 | ||
|
|
04e702a99b | ||
|
|
960adc5491 | ||
|
|
befe205bf9 | ||
|
|
692c849e0d | ||
|
|
485f0c6679 |
@@ -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 两层管理依赖,禁止引入 CocoaPods(ADR-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`)不一致即视为契约违反。
|
||||
@@ -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-range(Cocos 不发)
|
||||
- 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 AppSchemeHandler(WKURLSchemeHandler 单例,ylgame://h5 路由 + Range + MIME 表 + 异步 IO + 取消语义,ADR-010)
|
||||
- [x] 1.19 SandboxPaths.lobbyIndexAppURL / subGameIndexAppURL(URLComponents 拼装)
|
||||
- [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)
|
||||
@@ -1259,8 +1298,12 @@ ResolvedVersion(
|
||||
| 微信 AppID | `Info.plist` 的 CFBundleURLTypes (URLName=weixin 首个 scheme) | iOS 系统级 URL Scheme 注册,运行期不可注入,Info.plist 是事实唯一可写位置 |
|
||||
| 应用级凭证(微信 AppSecret、七牛 AccessKey、七牛 SecretKey) | `AppSecrets.plist` | 跨渠道相同的应用全局凭证;与渠道差异化字段语义分离 |
|
||||
| 七牛 CDN 域名、bucket 名 | RemoteConfig 顶层 / 4 层节点任一处 `audio_domain` / `audio_bucket`(4 层 fallback + 顶层兜底) | 后台运维管理,按渠道差异化下发 |
|
||||
- **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolveAudio(...)` 解析(与版本字段同款 4 层 fallback:agent → game → channel → market 倒序找第一个非空,整链空时 fallback 到顶层 `audio_domain` / `audio_bucket`),再 `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 算法),节点与顶层任一处声明即可
|
||||
- **远端注入时序**:`WebContainerViewController` 在 `parsed` 分支(即 RemoteConfig 拉到、IPA 校验前)调 `VersionResolver.resolve(...)` 一次得到所有字段(含 `audioDomain` / `audioBucket`),再 `await QiniuConfig.shared.update(cdnDomain:bucketName:)` 注入;缺失抛 `BootError.audioConfigMissing`,与 showmessage 同等致命,弹 modal 永停
|
||||
- **统一接口(硬约束)**:远端 RemoteConfig 所有字段的获取**必须**走 `VersionResolver.resolve(...)` 单一入口、走同一套 5 层 fallback:root(顶层)→ 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 属性
|
||||
- `QiniuConfig`:从 `enum` 改为 `actor`,`accessKey`/`secretKey` 仍 nonisolated(直接读 `AppSecrets.shared`),`cdnDomain`/`bucketName` 进 actor 状态;`update(...)` / `publicURL(...)` async
|
||||
@@ -1276,5 +1319,49 @@ ResolvedVersion(
|
||||
|
||||
---
|
||||
|
||||
### ADR-010:WebView 加载从 `file://` 迁到 `ylgame://h5/...`(WKURLSchemeHandler,2026-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 是 null,XHR/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-27(ADR-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-29(ADR-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 极光降级)
|
||||
|
||||
@@ -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 渠道注入机制("空目录名注入")
|
||||
@@ -378,9 +379,9 @@ window.rootViewController = NavgationController
|
||||
**流程**:
|
||||
1. 下载 AMR 到本地
|
||||
2. `VoiceConverter ConvertAmrToWav` 转 WAV
|
||||
3. `AVAudioPlayer` 播放
|
||||
3. `AVAudioPlayer` 播放(若当前已有语音在播,先停旧、给 H5 发 `gameui_stop_voice(被打断 user)` 再起新播放)
|
||||
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` — 语音播放总开关
|
||||
|
||||
@@ -562,7 +563,7 @@ window.rootViewController = NavgationController
|
||||
| 5 | `shakeEnd` | motionEnded 摇一摇结束 | `nil` | ✓ | ✓ |
|
||||
| 6 | `getlocationinfo` | 定位成功/失败 | 见下方表 B | ✓ | ✓ |
|
||||
| 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": "<秒数字符串>" }` | ✓ | ✓ |
|
||||
| 10 | `sharelogin` | 微信授权 OAuth 拿到 userinfo 后 | 见下方表 C | ✓ | ✓ |
|
||||
| 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 下完全不可达,**不能作为对齐依据**,请忽略。)
|
||||
|
||||
**`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]...]` 直接换 ivar(MRC 下旧 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 B,A 还没播完 B 就来),现在 H5 会先收 `gameui_stop_voice(A)` 再收 `gameui_play_voice(B)`,B 播完再收 `gameui_stop_voice(B)`。daoqi 旧路径下 A 那次停止信号永远收不到。
|
||||
|
||||
**表 A — `getphoneinfo`**:
|
||||
```json
|
||||
{
|
||||
@@ -696,9 +712,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 +1053,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`
|
||||
|
||||
@@ -508,10 +508,10 @@ public enum OverlayBridge {
|
||||
|
||||
| getter | 返回类型 | 业务语义 | 数据源 |
|
||||
|---|---|---|---|
|
||||
| `getchannelName()` | string | 渠道 ID(10 项渠道注入之一)| `BundleConfig.shared.channel` |
|
||||
| `getmarketname()` | string | 市场 ID(10 项渠道注入之一)| `BundleConfig.shared.market` |
|
||||
| `getchannelName()` | string | 渠道 ID(11 项渠道注入之一)| `BundleConfig.shared.channel` |
|
||||
| `getmarketname()` | string | 市场 ID(11 项渠道注入之一)| `BundleConfig.shared.market` |
|
||||
| `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` 真实返回值,并对齐) |
|
||||
| `getbattery()` | double | 当前电池电量 0.0–1.0 | `UIDevice.current.batteryLevel`(启动期 snapshot 一次) |
|
||||
| `getnetwork()` | int | 当前网络类型(0 无 / 1 WiFi / 2 蜂窝) | `NWPathMonitor` 当前 path(loadFileURL 前 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**,本地查询零延迟。
|
||||
|
||||
**数据快照时机**:
|
||||
- 静态字段(渠道 / market / other / appVersion 等 10 项渠道注入):app 启动期读 `ChannelConfig.plist` 后即不变,全程一次即可
|
||||
- 静态字段(渠道 / market / other / appVersion 等 11 项渠道注入):app 启动期读 `ChannelConfig.plist` 后即不变,全程一次即可
|
||||
- 动态字段(getbattery / getnetwork):**每次 `loadFileURL` 前重新 snapshot 注入**(精度足够,原 msext 自身也只在 `viewDidLoad` 取一次,H5 业务里"启动时刻电量值"被复用整个会话)
|
||||
- 已安装子游戏列表(getGameinstall):每次 loadFileURL 前扫描沙盒 + 注入 → SwitchOverGameData 解压新子游戏后自然在下次 loadFileURL 刷新
|
||||
|
||||
@@ -552,7 +552,7 @@ public enum SettingsBridgePolyfill {
|
||||
window.__nativeSnapshot = \(json);
|
||||
window.settings = window.settings || {};
|
||||
|
||||
// ── 10 项渠道注入(静态,启动期一次性快照)────────────
|
||||
// ── 11 项渠道注入(静态,启动期一次性快照)────────────
|
||||
window.settings.getchannelName = function() { return window.__nativeSnapshot.channel || ""; };
|
||||
window.settings.getmarketname = function() { return window.__nativeSnapshot.market || ""; };
|
||||
window.settings.getOther = function() { return window.__nativeSnapshot.other || ""; };
|
||||
@@ -583,7 +583,7 @@ public enum SettingsBridgePolyfill {
|
||||
}
|
||||
|
||||
public struct Snapshot: Sendable {
|
||||
public let channelConfig: [String: String] // 10 项渠道注入完整字典
|
||||
public let channelConfig: [String: String] // 11 项渠道注入完整字典
|
||||
public let channel: String
|
||||
public let market: 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 全局变量)完全不变,实现内部更简洁。
|
||||
|
||||
> **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
|
||||
<dict>
|
||||
@@ -1834,6 +1836,7 @@ Phase 6 子游戏(`SwitchOverGameData` 入参的 `Gamedirectory` / `gamedownlo
|
||||
<key>appversion</key> <string>43</string>
|
||||
<key>other</key> <string></string>
|
||||
<key>appleconfig</key> <string></string>
|
||||
<key>multishare</key> <string>1</string>
|
||||
</dict>
|
||||
```
|
||||
|
||||
@@ -2400,6 +2403,133 @@ console.log("app_gamesname =", app_gamesname); // 期望 Array
|
||||
|
||||
---
|
||||
|
||||
### 7.6 WebView 加载方式:`WKURLSchemeHandler`(Phase 1.F,ADR-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
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
| `AppDelegate.swift` | `AMapWrapper.bootstrap()` → 真实调隐私合规 3 步 + apiKey |
|
||||
| `SceneDelegate.swift` | `openURLContexts` → 真实调 `WXApi.handleOpen(url, delegate:)` |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `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/Bridge/Handlers/AccreditLoginHandler.swift` | ✅ 已升级 | 真实拉起授权 + sharelogin 反向 callback(Province 大写 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 时清理 |
|
||||
| `AppDelegate.swift` | ✅ 已加注册 | `WeChatSDK.register()` |
|
||||
| `SceneDelegate.swift` | ✅ 已加 openURL | `WeChatSDK.handleOpenURL(ctx.url)` |
|
||||
@@ -248,8 +248,8 @@ bridge.register("prepareaudio") { _, callback in
|
||||
### 1.4 验收(用户 Xcode 加完 framework 后跑)
|
||||
|
||||
- [ ] H5 调 `bridge.callHandler('accreditlogin')` → 拉起微信 → 同意授权 → 大厅 H5 收 `sharelogin({openid,...,Province})`,**Province 大写 P**
|
||||
- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})` → 拉起微信朋友圈
|
||||
- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 拉起微信好友选择
|
||||
- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})` → 写剪贴板 + 弹引导框 → 点"立即打开微信"跳转到微信首页(用户自行粘贴到朋友圈)
|
||||
- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 写剪贴板 + 弹引导框 → 跳微信首页(用户自行粘贴到对话框)
|
||||
- [ ] **关键回归**:截图分享 / QQ 分享 / 抖音分享路径不受影响
|
||||
|
||||
### 1.5 风险
|
||||
|
||||
@@ -11,16 +11,18 @@
|
||||
<key>gamestart</key>
|
||||
<string>gamehall</string>
|
||||
<key>gameconfig</key>
|
||||
<string>tsgames.daoqi88.cn-config_test-update_jsonv2_test</string>
|
||||
<string>tsgames.daoqi88.cn-config-update_jsonv2</string>
|
||||
<key>market</key>
|
||||
<string>2</string>
|
||||
<key>agent</key>
|
||||
<string>veRa0qrBf0df2K1G4de2tgfmVxB2jxpv</string>
|
||||
<key>appversion</key>
|
||||
<string>43</string>
|
||||
<string>44</string>
|
||||
<key>other</key>
|
||||
<string></string>
|
||||
<key>appleconfig</key>
|
||||
<string></string>
|
||||
<key>multishare</key>
|
||||
<string>0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -7,22 +7,22 @@
|
||||
// 契约:docs/H5-Native-Contract.md §3.1 [5]mediaTypeAudio + §3.2 [7][8]
|
||||
// gameui_play_voice / gameui_stop_voice
|
||||
//
|
||||
// ## 与原项目的行为对齐
|
||||
// ## 与原项目的行为差异(用户反馈驱动的修正)
|
||||
//
|
||||
// daoqi msext NewRootVC.m / gameController.m 把 voicePlayer / User_id 作
|
||||
// 为 VC 成员变量;本类把同款状态封装为单例。关键行为:
|
||||
// daoqi msext NewRootVC.m / gameController.m 把 voicePlayer / User_id 作为
|
||||
// VC 成员变量,覆盖式播放时直接 `voicePlayer = [[AVAudioPlayer alloc]...]`
|
||||
// 替换 ivar,没有触发任何停旧 player 的回调;自然结束时再用最新的
|
||||
// `self.User_id`(已被新一条消息覆盖)调 stopradio → 发给 H5 的
|
||||
// `gameui_stop_voice` payload 实际是**新一条消息的 user_id**,导致旧消息
|
||||
// 对应的 H5 语音动画永远停不掉。
|
||||
//
|
||||
// - `lastUserId`:mediaTypeAudio 入参 user 字段(每次 mediaTypeAudio 进来
|
||||
// 都更新,无论是否真的播放——对齐 daoqi gameController.m:438
|
||||
// `self.User_id=userid;` 在 canbofang=NO 时也会执行)
|
||||
// - `gameui_stop_voice` payload 用 `lastUserId`,不是 didFinishPlaying 传
|
||||
// 入的 player 关联值(对齐 daoqi stopradio 用 self.User_id 的行为;多条
|
||||
// 消息连发时 gameui_stop_voice 发最新 user,**这是 daoqi 的行为**)
|
||||
// - 覆盖式 play:前一条还在播时收到第二条,直接覆盖 player(让旧的被
|
||||
// ARC/ARC-like 释放);对齐 daoqi `voicePlayer = [[AVAudioPlayer alloc]...]`
|
||||
// 赋值行为
|
||||
// - 主动 stop **不**触发 onFinish(daoqi 没有主动 stop 路径,只有
|
||||
// audioPlayerDidFinishPlaying 触发 stopradio)
|
||||
// 本类把 user 与 player 一一绑定(`currentUser`):
|
||||
// - 覆盖式 play:在替换 player 之前主动 stop 旧 player 并触发
|
||||
// `onFinish(currentUser)`,让 H5 收到正确的 `gameui_stop_voice(被打断 user)`
|
||||
// - 自然结束 / 解码失败:用 `currentUser` 而非任何后写入的"最近 user"
|
||||
// 触发 onFinish,保证停的是真正结束那条
|
||||
// - 主动 stop():不触发 onFinish(保留 daoqi 语义;本项目目前也没有外部
|
||||
// 主动 stop 调用路径)
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
@@ -36,42 +36,57 @@ public final class VoicePlayer: NSObject, AVAudioPlayerDelegate {
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
|
||||
/// 最近一次 mediaTypeAudio 入参中的 user 字段。
|
||||
/// 对齐 daoqi self.User_id 行为(成员变量,每次 mediaTypeAudio 进来都更新)。
|
||||
public private(set) var lastUserId: String = ""
|
||||
/// 当前正在播放的语音对应的 user。与 `player` 一一绑定:`player != nil` 时一定有值,
|
||||
/// `player == nil` 时为 nil。覆盖式 play / didFinishPlaying / decodeError 都用这个
|
||||
/// 值触发 onFinish,确保 `gameui_stop_voice` payload 与真正结束 / 被中断的语音对应。
|
||||
private var currentUser: String?
|
||||
|
||||
/// 播放完成回调(AVAudioPlayer didFinishPlaying 触发,成功/失败都触发)。
|
||||
/// mediaTypeAudio handler 注册此 closure 触发反向调用 gameui_stop_voice。
|
||||
/// payload 是 `lastUserId`(不是 player 关联的 user,对齐 daoqi 行为)。
|
||||
/// 播放完成 / 被中断 / 解码失败统一回调。
|
||||
/// mediaTypeAudio handler 注册此 closure → 反向调 H5 `gameui_stop_voice`。
|
||||
/// payload 始终是与刚结束 / 被打断 player 绑定的 user(不是入参中最新的 user)。
|
||||
public var onFinish: ((String) -> Void)?
|
||||
|
||||
private override init() { super.init() }
|
||||
|
||||
/// 记录 mediaTypeAudio 入参的 user,**无论是否实际播放都要调**。
|
||||
/// 对齐 daoqi gameController.m:438(voicePlaying=0 时仍执行 `self.User_id=userid`)。
|
||||
public func recordIncomingUser(_ user: String) {
|
||||
lastUserId = user
|
||||
}
|
||||
/// 播放 wav。若当前已有正在播放的 player,先 stop 旧 player 并触发
|
||||
/// `onFinish(被打断的 user)`,让 H5 把旧动画停掉;再启动新 player。
|
||||
/// - Throws: AVAudioPlayer 创建 / 启动失败
|
||||
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 行为)。
|
||||
/// 调用方应在调用前调 `recordIncomingUser(user)` 更新 lastUserId。
|
||||
/// - Throws: AVAudioPlayer 创建/启动失败
|
||||
public func play(_ wav: URL) throws {
|
||||
// 2. 创建并启动新 player
|
||||
let p = try AVAudioPlayer(contentsOf: wav)
|
||||
p.delegate = self
|
||||
guard p.prepareToPlay(), p.play() else {
|
||||
throw PlayerError.startFailed
|
||||
}
|
||||
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)。
|
||||
/// 外部清理使用(如 H5 BackGameData 退出子游戏时);
|
||||
/// 业务正常路径下不会主动调,由 didFinishPlaying 自然完成。
|
||||
public func stop() {
|
||||
player?.delegate = nil
|
||||
player?.stop()
|
||||
player = nil
|
||||
currentUser = nil
|
||||
}
|
||||
|
||||
public enum PlayerError: Error, Sendable {
|
||||
@@ -110,8 +125,10 @@ public final class VoicePlayer: NSObject, AVAudioPlayerDelegate {
|
||||
/// 的 player 与当前 self.player 同身份时才清理 + 触发 onFinish。
|
||||
private func handleFinish(playerIdentity: ObjectIdentifier, success: Bool) {
|
||||
guard let current = player, ObjectIdentifier(current) == playerIdentity else { return }
|
||||
let user = currentUser
|
||||
player = nil
|
||||
Self.log.debug("didFinish user=\(self.lastUserId, privacy: .public) success=\(success, privacy: .public)")
|
||||
onFinish?(lastUserId)
|
||||
currentUser = nil
|
||||
Self.log.debug("didFinish user=\(user ?? "<nil>", privacy: .public) success=\(success, privacy: .public)")
|
||||
if let user { onFinish?(user) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,12 +261,11 @@ public enum RemoteAudioHandler {
|
||||
return // 参数不全 → 不触发 cb(对齐 daoqi 入参空时走到 file 不存在 return)
|
||||
}
|
||||
|
||||
// 2. 记录 lastUserId(无论是否实际播放都更新,对齐 daoqi
|
||||
// `self.User_id = userid;` 总是执行的行为)
|
||||
VoicePlayer.shared.recordIncomingUser(user)
|
||||
|
||||
// 3. 注册 VoicePlayer.onFinish(每次 mediaTypeAudio 更新,绑定当前 bridge;
|
||||
// [weak bridge] 让 bridge 释放后 onFinish 自动失效)
|
||||
// 2. 注册 VoicePlayer.onFinish(每次 mediaTypeAudio 更新,绑定当前 bridge;
|
||||
// [weak bridge] 让 bridge 释放后 onFinish 自动失效)。
|
||||
// onFinish 触发时机:自然结束 / 解码失败 / 被新一次 play 打断;
|
||||
// payload `finishedUser` 是被结束 / 被打断那条语音绑定的 user
|
||||
// (由 VoicePlayer 内部 currentUser 跟踪),不是入参里最新的 user。
|
||||
VoicePlayer.shared.onFinish = { [weak bridge] finishedUser in
|
||||
Task { @MainActor in
|
||||
bridge?.call(
|
||||
@@ -277,7 +276,7 @@ public enum RemoteAudioHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 异步下载 AMR
|
||||
// 3. 异步下载 AMR
|
||||
let amrData: Data
|
||||
do {
|
||||
let (d, _) = try await URLSession.shared.data(from: downloadURL)
|
||||
@@ -288,7 +287,7 @@ public enum RemoteAudioHandler {
|
||||
// `if (![[NSFileManager defaultManager] fileExistsAtPath:filename]) return;`)
|
||||
}
|
||||
|
||||
// 5. 写到沙盒 .amr 临时文件
|
||||
// 4. 写到沙盒 .amr 临时文件
|
||||
let tempName = VoiceRecorder.currentTimeString()
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let amrLocal = docs.appendingPathComponent(tempName).appendingPathExtension("amr")
|
||||
@@ -300,7 +299,7 @@ public enum RemoteAudioHandler {
|
||||
return
|
||||
}
|
||||
|
||||
// 6. AMR → WAV 转码
|
||||
// 5. AMR → WAV 转码
|
||||
// daoqi 文件名约定:tempName + "_AmrToWav.wav"(NewRootVC.m:341)
|
||||
let wavLocal = docs
|
||||
.appendingPathComponent("\(tempName)_AmrToWav")
|
||||
@@ -317,14 +316,16 @@ public enum RemoteAudioHandler {
|
||||
transcoded = false
|
||||
}
|
||||
|
||||
// 7. voicePlaying 总开关 + 实际播放 + gameui_play_voice
|
||||
// 6. voicePlaying 总开关 + 实际播放 + gameui_play_voice
|
||||
// (仅转码成功 + 开关 enabled 时触发,对齐 daoqi
|
||||
// NewRootVC.m:343-353 if (canbofang) { play + callHandler })
|
||||
// NewRootVC.m:343-353 if (canbofang) { play + callHandler })。
|
||||
// 若当前已有正在播放的 player,VoicePlayer.play 会先停旧 player 并
|
||||
// 通过 onFinish 反向调 gameui_stop_voice 让 H5 停旧动画,再起新播放。
|
||||
let canPlay = await VoiceCenter.shared.isEnabled
|
||||
log.debug("mediaTypeAudio user=\(user, privacy: .public) transcoded=\(transcoded, privacy: .public) canPlay=\(canPlay, privacy: .public)")
|
||||
if transcoded, canPlay {
|
||||
do {
|
||||
try VoicePlayer.shared.play(wavLocal)
|
||||
try VoicePlayer.shared.play(wavLocal, user: user)
|
||||
// gameui_play_voice 数据是 user 字符串(非 dict)
|
||||
bridge.call(
|
||||
"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 路径不触发)
|
||||
callback?(.string("Response from mediaTypeAudio"))
|
||||
}
|
||||
|
||||
@@ -10,19 +10,25 @@ import Foundation
|
||||
|
||||
// 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 {
|
||||
public let appVersion: String?
|
||||
public let appDownload: String?
|
||||
public let gameVersion: String?
|
||||
public let gameZip: String?
|
||||
public let showmessage: String?
|
||||
/// 七牛 CDN 域名(**不带 http:// 前缀**),录音上传后拼公开访问 URL 用。
|
||||
/// 顶层值作为整链最浅层 fallback;与版本字段同款 4 层 fallback:
|
||||
/// agent → game → channel → market 任一层均可声明,最深层赢;都不写则用顶层。
|
||||
/// 启动期由 VersionResolver.resolveAudio(...) 解析,缺失抛 BootError.audioConfigMissing。
|
||||
public let audioDomain: String?
|
||||
/// 七牛 bucket 名(putPolicy.scope)。语义同 audioDomain,同款 4 层 fallback。
|
||||
public let audioBucket: String?
|
||||
public let agentlist: [Agent]?
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
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)
|
||||
audioDomain = try c.decodeFlexibleStringIfPresent(forKey: .audioDomain)
|
||||
audioBucket = try c.decodeFlexibleStringIfPresent(forKey: .audioBucket)
|
||||
@@ -31,8 +37,10 @@ nonisolated public struct RemoteConfig: Codable, Sendable {
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
// 驼峰 case 名(不带 rawValue),依赖 JSONDecoder.convertFromSnakeCase 自动把
|
||||
// JSON 的 audio_domain / audio_bucket 归一化到 audioDomain / audioBucket
|
||||
case showmessage, agentlist, audioDomain, audioBucket
|
||||
// JSON 的 app_version / audio_domain 等归一化为 appVersion / audioDomain
|
||||
case showmessage, agentlist
|
||||
case appVersion, appDownload, gameVersion, gameZip
|
||||
case audioDomain, audioBucket
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,27 @@
|
||||
// VersionResolver.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 把远端 RemoteConfig 化简为 5 个目标字段(appVersion/appDownload/gameVersion/
|
||||
// gameZip/showmessage)的纯函数实现。
|
||||
// 把远端 RemoteConfig 化简为 7 个目标字段(appVersion / appDownload /
|
||||
// gameVersion / gameZip / showmessage / audioDomain / audioBucket)的纯函数实现。
|
||||
//
|
||||
// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径):
|
||||
// agentlist[agentid] → gamelist[gameid] → channellist[channelid] → marketlist[marketid]
|
||||
// 契约结构(参 daoqi `NewRootVC.m:689-810` 主路径,扩展为 5 层 fallback):
|
||||
// root(RemoteConfig 顶层)
|
||||
// → agentlist[agentid] → gamelist[gameid]
|
||||
// → channellist[channelid] → marketlist[marketid]
|
||||
//
|
||||
// 查找语义:从最深层(market)向上回退取第一个非空值,等价于
|
||||
// msext 那段从根向下"非空即覆盖"的 if 链,但写法更直接、可复用。
|
||||
// 查找语义:从最深层(market)向上回退取第一个非空值,"根"(顶层)作为最浅一层
|
||||
// 自动包含在链中 —— 等价于 msext 那段从根向下"非空即覆盖"的 if 链,但写法更直接、
|
||||
// 可复用,且天然支持顶层声明字段(任何字段都能放顶层兜底)。
|
||||
//
|
||||
// 统一接口:所有 5 个字段都用同一套 `pickString` / `pickInt` 倒序查找,
|
||||
// 不再有"双子树合并 / chulishengji"等历史包袱的多处实现。
|
||||
// 统一接口:所有 7 个字段都用同一套 `pickString` / `pickInt` 倒序查找,
|
||||
// 无任何字段级特殊兜底逻辑(如 `?? config.xxx`),所有字段平等。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - 决策输出
|
||||
|
||||
/// 4 层 fallback 解析后的 5 个目标字段。
|
||||
/// 4 层 fallback 解析后的所有目标字段(所有字段统一走同一套 chain + pickString/Int)。
|
||||
public struct ResolvedVersion: Sendable, Equatable {
|
||||
/// 远端期望的原生 IPA 版本(与 BundleConfig.appVersion 比较)。0 表示远端未指定。
|
||||
public let appVersion: Int
|
||||
@@ -31,6 +34,10 @@ public struct ResolvedVersion: Sendable, Equatable {
|
||||
public let gameZip: String?
|
||||
/// 运营公告。非空时阻塞后续升级判断(msext 杀手锏 #2)。
|
||||
public let showmessage: String?
|
||||
/// 七牛 CDN 域名(不带 http:// 前缀),录音上传后拼公开访问 URL 用。nil 表示远端未指定。
|
||||
public let audioDomain: String?
|
||||
/// 七牛 bucket 名(putPolicy.scope)。nil 表示远端未指定。
|
||||
public let audioBucket: String?
|
||||
}
|
||||
|
||||
// MARK: - 节点协议(4 个层级共用同一查找接口)
|
||||
@@ -51,6 +58,9 @@ extension Agent: RemoteConfigNode {}
|
||||
extension Game: RemoteConfigNode {}
|
||||
extension Channel: RemoteConfigNode {}
|
||||
extension Market: RemoteConfigNode {}
|
||||
// RemoteConfig 顶层也作为节点参与 fallback 链,处于最浅一层("根")。
|
||||
// 这样所有字段的 fallback 完全统一:节点链倒序找第一个非空 → 链上即包含根。
|
||||
extension RemoteConfig: RemoteConfigNode {}
|
||||
|
||||
// MARK: - VersionResolver
|
||||
|
||||
@@ -77,53 +87,30 @@ public enum VersionResolver {
|
||||
marketId: marketId
|
||||
)
|
||||
|
||||
// 所有 7 字段统一走"chain 倒序找第一个非空",无任何字段级特殊兜底。
|
||||
// chain 头部就是 RemoteConfig 顶层("根"),最深层赢、根作为最浅层兜底。
|
||||
return ResolvedVersion(
|
||||
appVersion: pickInt(chain, \.appVersion),
|
||||
appVersion: pickInt(chain, \.appVersion),
|
||||
appDownload: pickString(chain, \.appDownload),
|
||||
gameVersion: pickInt(chain, \.gameVersion),
|
||||
gameZip: pickString(chain, \.gameZip),
|
||||
// showmessage:链上任一层都不写 → 回退顶层 config.showmessage
|
||||
showmessage: pickString(chain, \.showmessage) ?? config.showmessage
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
gameVersion: pickInt(chain, \.gameVersion),
|
||||
gameZip: pickString(chain, \.gameZip),
|
||||
showmessage: pickString(chain, \.showmessage),
|
||||
audioDomain: pickString(chain, \.audioDomain),
|
||||
audioBucket: pickString(chain, \.audioBucket)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 节点链匹配
|
||||
|
||||
/// 沿 agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。
|
||||
/// 沿 root → agent → game → channel → market 顺序逐层匹配,遇到不匹配的层立刻截断。
|
||||
/// 链头始终是 RemoteConfig 顶层("根"),作为最浅一层兜底。
|
||||
///
|
||||
/// 返回数组从根到叶有序,长度 0..4:
|
||||
/// - `[]` :agent 未匹配
|
||||
/// - `[agent]` :agent 命中、game 未匹配
|
||||
/// - `[agent, game]` :再深一层未命中
|
||||
/// 返回数组从根到叶有序,长度 1..5:
|
||||
/// - `[root]` :agent 未匹配(链最短为根)
|
||||
/// - `[root, agent]` :agent 命中、game 未匹配
|
||||
/// - `[root, agent, game]` :再深一层未命中
|
||||
/// - ...
|
||||
/// - `[agent, game, channel, market]`:全链命中
|
||||
/// - `[root, agent, game, channel, market]`:全链命中
|
||||
private static func buildChain(
|
||||
config: RemoteConfig,
|
||||
agentId: String,
|
||||
@@ -131,7 +118,7 @@ public enum VersionResolver {
|
||||
channelId: String,
|
||||
marketId: String
|
||||
) -> [RemoteConfigNode] {
|
||||
var chain: [RemoteConfigNode] = []
|
||||
var chain: [RemoteConfigNode] = [config]
|
||||
|
||||
guard let agent = config.agentlist?.first(where: { $0.agentid == agentId })
|
||||
else { return chain }
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
// BundleConfig.swift
|
||||
// ylgamehall
|
||||
//
|
||||
// 渠道注入配置:从 Bundle 内的 ChannelConfig.plist 读 10 个 string 值。
|
||||
// 渠道注入配置:从 Bundle 内的 ChannelConfig.plist 读 11 个 string 值。
|
||||
// 设计模式与契约见 docs/H5-Native-Implementation-Design.md §7 / ADR-007。
|
||||
//
|
||||
// 注:原有 qiniudomain 字段已于 2026-06-27 移除,七牛 CDN 域名改由
|
||||
// RemoteConfig 顶层 audio_domain 单一权威源动态下发。
|
||||
// 注:
|
||||
// - 原有 qiniudomain 字段已于 2026-06-27 移除,七牛 CDN 域名改由
|
||||
// RemoteConfig 顶层 audio_domain 单一权威源动态下发。
|
||||
// - 新增 multishare 字段:控制子游戏 sharefriend=1 好友分享的形态
|
||||
// ("1" = SharePanel 三选一 / "0" = 仅微信直发)。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 渠道注入配置(10 项),由 `BundleConfig.shared` 在 App 启动时一次性加载,运行期不变。
|
||||
/// 渠道注入配置(11 项),由 `BundleConfig.shared` 在 App 启动时一次性加载,运行期不变。
|
||||
///
|
||||
/// 多渠道分发:构建后用 `plutil` 修改 `.app/ChannelConfig.plist` + 重签,
|
||||
/// 不需要重新 Xcode build。详见 ADR-007。
|
||||
@@ -30,6 +33,17 @@ nonisolated public final class BundleConfig: Sendable {
|
||||
public let appVersion: String
|
||||
public let other: 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。
|
||||
public init(bundle: Bundle = .main) {
|
||||
@@ -44,6 +58,7 @@ nonisolated public final class BundleConfig: Sendable {
|
||||
appVersion = dict["appversion"] ?? ""
|
||||
other = dict["other"] ?? ""
|
||||
appleConfig = dict["appleconfig"] ?? ""
|
||||
multiShare = dict["multishare"] ?? ""
|
||||
}
|
||||
|
||||
private static func loadPlist(bundle: Bundle) -> [String: String] {
|
||||
|
||||
@@ -68,4 +68,30 @@ public enum SandboxPaths {
|
||||
.appendingPathComponent(start)
|
||||
.appendingPathComponent("index.html")
|
||||
}
|
||||
|
||||
// MARK: - App scheme URL(Phase 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
|
||||
// ylgamehall
|
||||
//
|
||||
// 微信 SDK 持久 delegate + authorize + share 异步包装。
|
||||
// 把 WXApiDelegate 的回调 → async/await,state UUID 配对多发起方。
|
||||
// 微信 SDK 持久 delegate + authorize 异步包装。
|
||||
// 把 WXApiDelegate 的 onResp 回调 → async/await,state UUID 配对多发起方。
|
||||
//
|
||||
// Design §8.5;msext WXApiManager.m 等价。
|
||||
// 注:分享路径已改走剪贴板 + 引导框(详见 WechatShare.swift),不再依赖
|
||||
// 微信 SDK 的 SendMessageToWXReq;本类只剩登录 OAuth 链路。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -23,8 +24,6 @@ public final class WeChatManager: NSObject {
|
||||
|
||||
/// 待 resolve 的 authorize continuation(state UUID 配对)
|
||||
private var authPending: [String: CheckedContinuation<WXAuthCodePayload, Error>] = [:]
|
||||
/// 待 resolve 的 share continuation(FIFO 串行 — 微信回包不带配对 ID)
|
||||
private var sharePending: [CheckedContinuation<Void, Error>] = []
|
||||
|
||||
public override init() {
|
||||
super.init()
|
||||
@@ -84,125 +83,6 @@ public final class WeChatManager: NSObject {
|
||||
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(接收方预览可见)+ messageExt(SDK 内部回包)
|
||||
/// 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
|
||||
|
||||
fileprivate func resolveAuth(state: String, code: String?, errCode: Int) {
|
||||
@@ -213,16 +93,6 @@ public final class WeChatManager: NSObject {
|
||||
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
|
||||
@@ -242,11 +112,8 @@ extension WeChatManager: WXApiDelegate {
|
||||
Task { @MainActor in
|
||||
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 {
|
||||
case sdkNotLinked
|
||||
case authFailed(Int)
|
||||
case shareFailed(Int)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
// ViewController 各注册一份且**行为不同**,新外壳按页面 mode 分发:
|
||||
//
|
||||
// • dispatchSubGame — 对齐 msext gameController.m:575-597(活路径):
|
||||
// - sharefriend == "1" → SharePanel 三选一(微信/QQ/抖音)
|
||||
// - sharefriend == "1" 且 BundleConfig.isMultiShareEnabled
|
||||
// → SharePanel 三选一(微信/QQ/抖音)
|
||||
// - sharefriend == "1" 且 multishare 关闭
|
||||
// → 直发微信好友(渠道级降级为单一平台)
|
||||
// - 其他 → WechatShare.share() 朋友圈 timeline
|
||||
//
|
||||
// • dispatchLobby — 对齐 msext NewRootVC.m:453-537:直发微信,无 SharePanel
|
||||
@@ -27,8 +30,14 @@ public enum ShareCenter {
|
||||
public static func dispatchSubGame(_ content: ShareContent) {
|
||||
switch content.sharefriend {
|
||||
case "1":
|
||||
// 好友/私聊 → SharePanel 三选一
|
||||
SharePanel.show(content: content)
|
||||
// 好友/私聊:渠道级开关决定弹面板还是直发微信
|
||||
if BundleConfig.shared.isMultiShareEnabled {
|
||||
SharePanel.show(content: content)
|
||||
} else {
|
||||
// multishare = "0" → 三分享关闭,直接走微信好友(scene 参数在
|
||||
// 剪贴板路径下用于文案 / 语义标注,不实际影响粘贴目标)
|
||||
WechatShare.shared.share(content, scene: .friend)
|
||||
}
|
||||
|
||||
default:
|
||||
// sharefriend == "2" 或缺失或其他 → msext WechatShareManager
|
||||
|
||||
@@ -22,7 +22,7 @@ public final class SharePanel: UIView {
|
||||
|
||||
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 animationDuration: TimeInterval = 0.25
|
||||
|
||||
|
||||
@@ -2,161 +2,200 @@
|
||||
// WechatShare.swift
|
||||
// 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:`
|
||||
// (子游戏 gameController.m 活路径)2 type 分支:
|
||||
// - type == "2" → 截图分享(thumb = app icon)
|
||||
// - else → 链接分享(thumb = app icon)
|
||||
// 两条对外入口对应两个 ViewController 同名 handler 的差异(仅剪贴板内容分支不同):
|
||||
//
|
||||
// • shareLobby() — 对齐 daoqi msext `NewRootVC.m:471-534` 3 type 分支:
|
||||
// - type == "1" → 链接分享(thumb = app icon;原 msext
|
||||
// 用 sharelogo.png,按用户决定 fallback 到 app icon)
|
||||
// - type == "2" → 截图分享(thumb = 截图 40% 缩放)
|
||||
// - 其他 → 远端图分享(NSData 直发 + thumb = 远端图 40% 缩放)
|
||||
// • share() — 子游戏路径(gameController.m),2 type 分支:
|
||||
// - type == "2" → 截图:UIPasteboard.image
|
||||
// - else → 链接:UIPasteboard.string
|
||||
//
|
||||
// fire-and-forget:H5 端的 sharesuccess 已在 FriendsShareHandler 立即触发,
|
||||
// 此处不再回传结果(与原 dispatch_after 1s completion(YES) 实质等价)。
|
||||
// • shareLobby() — 大厅路径(NewRootVC.m),3 type 分支:
|
||||
// - type == "1" → 链接
|
||||
// - type == "2" → 截图
|
||||
// - 其他 → 远端图(下载 → UIPasteboard.image)
|
||||
//
|
||||
// fire-and-forget:H5 端 sharesuccess 已在 FriendsShareHandler 立即触发,
|
||||
// 此处不再回传结果。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
@MainActor
|
||||
public final class WechatShare: SharePlatform {
|
||||
|
||||
public static let shared = WechatShare()
|
||||
|
||||
private static let log = Logger(subsystem: "ylgamehall", category: "WechatShare")
|
||||
|
||||
private static let wechatScheme = "weixin://"
|
||||
|
||||
public let name = "WeChat"
|
||||
|
||||
public var isInstalled: Bool {
|
||||
WXApi.isWXAppInstalled()
|
||||
guard let url = URL(string: Self.wechatScheme) else { return false }
|
||||
return UIApplication.shared.canOpenURL(url)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - 子游戏路径(gameController.m)
|
||||
|
||||
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" {
|
||||
// 截图分享(msext WechatShareManager isScreenshotShare 分支)
|
||||
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 {
|
||||
// 链接分享(msext else 分支,含 type=="1" 与任何其它值)
|
||||
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 已乐观发出,对齐原项目)
|
||||
}
|
||||
if content.type == "2" {
|
||||
shareScreenshot()
|
||||
} else {
|
||||
shareLink(content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 大厅路径(NewRootVC.m)
|
||||
|
||||
/// 大厅页 H5 调 friendsShare 的微信分享路径,对齐 msext NewRootVC.m
|
||||
/// :471-534 完整 3 分支结构。直发微信无 SharePanel。
|
||||
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()
|
||||
switch content.type {
|
||||
case "1":
|
||||
shareLink(content)
|
||||
case "2":
|
||||
shareScreenshot()
|
||||
default:
|
||||
shareRemoteImage(content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 截图分享
|
||||
|
||||
/// 截图 → UIPasteboard.image → 弹引导框(不自动拉起微信,等用户点"立即打开")。
|
||||
private func shareScreenshot() {
|
||||
do {
|
||||
let shot = try ImageProvider.captureScreenshot()
|
||||
UIPasteboard.general.image = shot
|
||||
Self.log.debug("WechatShare 截图已复制到剪贴板")
|
||||
} catch {
|
||||
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 {
|
||||
switch content.type {
|
||||
case "1":
|
||||
// 链接分享:原 msext 用 sharelogo.png;用户决定 fallback 到 app icon
|
||||
try await WeChatManager.shared.shareLink(
|
||||
.init(
|
||||
url: content.webpageUrl,
|
||||
title: content.title,
|
||||
desc: content.desc,
|
||||
thumb: appIcon,
|
||||
mediaTagName: "\(Self.appTagPrefix)游戏下载链接"
|
||||
),
|
||||
scene: wxScene
|
||||
)
|
||||
case "2":
|
||||
// 截图分享:thumb = 截图 40% 缩放(对齐 msext imageByScalingAndCroppingForSize:0.4x)
|
||||
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:
|
||||
// 远端图分享(type == "" / "3" / 其他):下载 NSData 直发 +
|
||||
// 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
|
||||
)
|
||||
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 {
|
||||
// 与子游戏路径同样吞掉错误(sharesuccess 已乐观发出)
|
||||
Self.log.error("WechatShare 远端图下载失败 \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
let message = """
|
||||
✅ 图片已复制到剪贴板
|
||||
|
||||
🎯 分享到好友、群聊或朋友圈步骤:
|
||||
1️⃣ 打开微信,选择好友、群聊或朋友圈
|
||||
2️⃣ 在输入框长按粘贴图片
|
||||
3️⃣ 点击发送即可分享
|
||||
"""
|
||||
showGuidanceAlert(title: "微信分享准备完成", message: message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 共享常量 / 工具
|
||||
// MARK: - 引导弹窗
|
||||
|
||||
/// msext WechatShareManager 字面常量 "进贤聚友棋牌"(与 daoqi
|
||||
/// SGDefineInfo.h gamehallname 同源),用于 WXMediaMessage.mediaTagName 拼接。
|
||||
private static let appTagPrefix = "进贤聚友棋牌"
|
||||
/// 弹"立即打开微信 / 稍后分享"二选一 Alert,与 QQShare / DouyinShare 同款 UX。
|
||||
private func showGuidanceAlert(title: String, message: String) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "立即打开微信", style: .default) { _ in
|
||||
if let url = URL(string: Self.wechatScheme),
|
||||
UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "稍后分享", style: .cancel))
|
||||
|
||||
/// app icon 缩略图。读 Info.plist `CFBundleIcons` → `CFBundlePrimaryIcon`
|
||||
/// → `CFBundleIconFiles` 的最后一个(最高分辨率),与原 msext
|
||||
/// WechatShareManager `getAppIconImage` 等价。失败兜底返回 1x1 透明图。
|
||||
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
|
||||
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
|
||||
}
|
||||
if let icon = UIImage(named: "AppIcon") { return icon }
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1))
|
||||
return renderer.image { _ in }
|
||||
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: - Routing(nonisolated 纯函数)
|
||||
|
||||
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 building(IO 队列调用)
|
||||
|
||||
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-range(逗号分隔),Cocos 不发。
|
||||
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 是 null,XHR/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. 加载子游戏 H5(allowingReadAccessTo 给 subGameRoot 才能跨目录引用资源)
|
||||
// 3. 加载子游戏 H5(Phase 1.F:从 loadFileURL 迁到 AppSchemeHandler)
|
||||
// URL:ylgame://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:
|
||||
|
||||
@@ -194,6 +194,21 @@ public final class WebContainerViewController: UIViewController {
|
||||
// 异步 snapshot NWPath 区分"真断网"和"权限被拒",文案和按钮语义按 kind 精确分流。
|
||||
let path = await Self.currentPathSnapshot()
|
||||
let kind = Self.classifyBootError(error, path: path)
|
||||
|
||||
// 首次启动权限授予竞争:iOS 弹权限对话框时,pipeline 的第一个网络请求
|
||||
// 已经在跑;如果用户没在请求 timeout 前点"允许",请求会 fail 且 NWPath
|
||||
// 报 wifiDenied / cellularDenied —— 但用户可能"正准备点允许"。此时立
|
||||
// 即弹"前往设置"会让用户困惑("我明明点了允许还弹")。
|
||||
//
|
||||
// 策略:只在 kind.isPermissionDenied 时,短暂观察 NWPath;5s 内若变
|
||||
// satisfied → 静默重跑 pipeline(等价用户点允许后我们自动重试);超时
|
||||
// 或仍拒绝 → 走原逻辑弹"前往设置"。真拒绝的长尾场景多等 5s 可接受。
|
||||
if kind.isPermissionDenied,
|
||||
await Self.waitForNetworkPermissionResolution(timeout: 5.0) {
|
||||
await runBootPipeline()
|
||||
return
|
||||
}
|
||||
|
||||
presentBootError(error, kind: kind)
|
||||
}
|
||||
}
|
||||
@@ -216,14 +231,15 @@ public final class WebContainerViewController: UIViewController {
|
||||
title = "当前未联网"
|
||||
message = "请检查 Wi-Fi 或蜂窝数据后重试。"
|
||||
actionTitle = "重试"
|
||||
case .wifiDenied:
|
||||
title = "未授权使用无线局域网"
|
||||
case .wifiDenied, .cellularDenied:
|
||||
// 文案与 iOS Settings → 本应用 → 「无线数据」combined toggle 对齐:
|
||||
// 该开关有"关闭 / WLAN / WLAN 与蜂窝数据"三档,是用户唯一可操作的
|
||||
// 入口。NWPath 的 wifiDenied vs cellularDenied 内部区分保留在
|
||||
// BootErrorKind 里(供日志排查),但用户文案不再区分——两种情况用户
|
||||
// 都要走同一条路径(打开"无线数据"),精确区分反而让"文案对不上设置项"。
|
||||
title = "未授权使用无线数据"
|
||||
message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,授权后将自动重试。"
|
||||
actionTitle = "前往设置"
|
||||
case .cellularDenied:
|
||||
title = "未授权使用蜂窝数据"
|
||||
message = "请前往 iOS 设置 → 本应用 → 打开「无线数据」,或连接 Wi-Fi 后重试。"
|
||||
actionTitle = "前往设置"
|
||||
case .networkTimeout:
|
||||
title = "网络较慢"
|
||||
message = "连接超时,请稍后重试。"
|
||||
@@ -347,24 +363,9 @@ public final class WebContainerViewController: UIViewController {
|
||||
case .parsed(let cfg):
|
||||
let bc = BundleConfig.shared
|
||||
|
||||
// 七牛 audio_domain / audio_bucket:与版本字段同款 4 层 fallback:
|
||||
// agent → game → channel → market 任一层声明都赢,整链没写则用 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)
|
||||
|
||||
// 单一接口、单一 4 层 fallback:所有字段(版本 + 音频 + showmessage)
|
||||
// 共用 VersionResolver.resolve(...),统一从 agent → game → channel → market
|
||||
// 倒序找第一个非空,整链都未声明的字段 fallback 到 RemoteConfig 顶层。
|
||||
let r = VersionResolver.resolve(
|
||||
config: cfg,
|
||||
agentId: bc.agent,
|
||||
@@ -372,6 +373,16 @@ public final class WebContainerViewController: UIViewController {
|
||||
marketId: bc.market,
|
||||
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)
|
||||
|
||||
// 运营杀手锏 #2:showmessage 非空 → 弹窗永停
|
||||
if let msg = r.showmessage, !msg.isEmpty {
|
||||
@@ -412,12 +423,13 @@ public final class WebContainerViewController: UIViewController {
|
||||
// 详见 docs/H5-Native-Implementation-Design.md §7.5
|
||||
try writeAppDataFiles(resolved: resolved)
|
||||
|
||||
// 7. 加载本地 H5(allowingReadAccessTo 必须给 lobbyRoot 才能跨目录引用资源)
|
||||
// 7. 加载本地 H5(Phase 1.F:从 loadFileURL 迁到 AppSchemeHandler)
|
||||
// URL:ylgame://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 已启动)。
|
||||
@@ -559,27 +571,66 @@ public final class WebContainerViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func showIPAUpgradeAlert(downloadURL: String) {
|
||||
// ⚠️ 不要在跳 Safari 后自动 exit(0):
|
||||
// iOS 的 itms-services:// 系统安装确认弹窗依赖 source app 仍在内存中
|
||||
// 才能弹出(系统安全机制);提前 exit 会让 Safari 收到 URL 但确认窗
|
||||
// 不显示,整个安装流程被悄悄中断。
|
||||
// 行为目标:点「确定」直接弹系统安装窗 + App 自动切后台(用户回到桌面),
|
||||
// 用户在桌面上点「安装」即可看到 App 图标下载进度,App 进程留在内存不退出。
|
||||
//
|
||||
// 正确做法:跳 Safari 后让 App 自然留在后台。用户在 Safari 点「安装」
|
||||
// 后,iOS 开始下载 → 替换 IPA 时会自动结束旧 App 进程(或要求用户上滑),
|
||||
// 无需主动介入。
|
||||
// iOS 实际行为:调 itms-services 时系统弹安装确认窗,但本 App 仍处于 inactive
|
||||
// 状态(弹窗是模态覆盖在 App 上),用户点「安装」后弹窗 dismiss、App 回到 active
|
||||
// —— 系统**不会主动**切后台。要让用户回桌面,需要本 App 主动调私有 selector
|
||||
// `suspend` 把自己切后台(等价于用户按 Home / 上滑)。
|
||||
// 本项目企业签 / 渠道分发,不上架 AppStore,可以用私有 API。
|
||||
//
|
||||
// URL 形态:itms-services 直链 / .plist 直链 / 其它(Safari)—— 详见 makeInstallURL。
|
||||
// Safari 路径不调 suspend:用户已经在 Safari,suspend 反而破坏 Safari 引导页体验。
|
||||
let alert = UIAlertController(
|
||||
title: "需要升级",
|
||||
message: "检测到新版本,点击「确定」前往下载。\n\n安装确认弹窗出现后请点击「安装」,然后从多任务列表上滑退出本 App,更新即可自动完成。",
|
||||
message: "检测到新版本,点击「确定」开始升级,系统会自动下载并安装。",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in
|
||||
if let url = URL(string: downloadURL) {
|
||||
UIApplication.shared.open(url)
|
||||
guard let url = Self.makeInstallURL(from: downloadURL) else { return }
|
||||
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)
|
||||
}
|
||||
|
||||
/// 把本 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 区分"真断网"和"权限被拒"
|
||||
/// (iOS 14+ 两类都报 NSURLError -1009,但 path 里有 wifiDenied / cellularDenied
|
||||
/// 标记),fallback 看 NSURLError code。
|
||||
@@ -663,6 +714,34 @@ public final class WebContainerViewController: UIViewController {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user