接入原生返回栈状态消费
H5 新增 navigation.canGoBack hook 并在直达二级页时补齐返回锚点 路由导航保留完整原生宿主上下文并标记应用历史状态 补齐 HostBridge 返回栈消费测试、门禁和文档
This commit is contained in:
@@ -2585,6 +2585,13 @@
|
||||
- 影响范围:`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
|
||||
- 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-19 H5 原生壳返回锚点与完整运行态保留
|
||||
|
||||
- 背景:Expo / Tauri 壳已经通过 `navigation.canGoBack` 事件告知 H5 当前可回退状态,但 H5 如果直达二级页且本地 history 没有应用导航条目,Android 返回键或桌面后退菜单会缺少可落回的平台首页;同时 H5 页面内导航若只保留小程序 query,会让原生壳中的后续页面丢失 `hostShell`、平台、版本、桥接版本和 capability 清单。
|
||||
- 决策:`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 必须同时覆盖微信小程序来源字段和原生壳 `hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion`、`hostCapabilities`;`pushAppHistoryPath()` / `replaceAppHistoryPath()` 写入应用 history state 并保留完整宿主上下文。H5 通过 `useHostNavigationCanGoBack()` 只在宿主同时声明 `host.events` 与 `navigation.canGoBack` 时消费返回栈事件;原生壳内直达非平台首页、非 runtime 的二级 H5 route 且当前 history state 没有应用导航标记时,App 先把当前条目替换成 `/` 返回锚点,再把当前路径推回 history。H5 不读取任意原生 back-forward list。
|
||||
- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/routing/appPageRoutes.ts`、`src/hooks/useHostNavigationCanGoBack.ts`、`src/App.tsx`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。
|
||||
- 验证方式:`npm run test -- packages/shared/src/contracts/hostBridge.test.ts src/routing/appPageRoutes.test.ts src/hooks/useHostNavigationCanGoBack.test.tsx src/App.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-18 桌面壳窗口状态持久化
|
||||
|
||||
- 背景:Tauri 桌面壳已经具备系统托盘、单实例、深链和受控 HostBridge 能力,但用户调整主窗口尺寸、位置或最大化状态后,重启桌面 App 仍回到固定初始窗口配置;如果直接保存完整窗口状态,又可能把托盘隐藏后的可见性状态带到下次启动。
|
||||
|
||||
@@ -216,7 +216,7 @@ npm run build
|
||||
npm run check:native-shells
|
||||
```
|
||||
|
||||
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
|
||||
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、H5 `navigation.canGoBack` 消费 hook 与直达二级页返回锚点测试、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口、H5 页面内导航保留完整原生宿主上下文和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
|
||||
创作 Agent 原生壳文档导入优先走 `file.importDocument`,旧壳只声明 `file.importText` 时才回退文本导入;相关变更必须让根级和单端门禁覆盖共享 method、capability profile、文档 MIME / 5 MiB 上限、读取前 size 校验,以及 H5 base64 转 `File` 后继续走后端文档解析的链路。
|
||||
创作 Agent 参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给既有 `onReferenceImageChange` 校验链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
|
||||
汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
|
||||
|
||||
@@ -84,7 +84,7 @@ H5 进入原生 App 壳时由壳层附加稳定 query:
|
||||
&hostCapabilities=host.getRuntime,...
|
||||
```
|
||||
|
||||
这些字段名和值不在各壳里单独定义。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 是 H5 runtime parser 的字段名来源,`HOST_BRIDGE_NATIVE_APP_QUERY_KEY` / `HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` / `HOST_BRIDGE_NATIVE_APP_QUERY` 是 Expo 与 Tauri 原生壳入口 query 来源,`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 是微信 WebView `clientType=mini_program` / `clientRuntime=wechat_mini_program` 来源,`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 是 H5 页面内导航保留宿主上下文的字段来源。Expo 直接导入共享常量,Tauri Rust 和微信 CommonJS 镜像由检查脚本反查共享契约。
|
||||
这些字段名和值不在各壳里单独定义。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 是 H5 runtime parser 的字段名来源,`HOST_BRIDGE_NATIVE_APP_QUERY_KEY` / `HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` / `HOST_BRIDGE_NATIVE_APP_QUERY` 是 Expo 与 Tauri 原生壳入口 query 来源,`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 是微信 WebView `clientType=mini_program` / `clientRuntime=wechat_mini_program` 来源,`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 是 H5 页面内导航保留宿主上下文的字段来源,必须同时覆盖微信小程序来源字段和原生壳 `hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion`、`hostCapabilities` 完整运行态字段。Expo 直接导入共享常量,Tauri Rust 和微信 CommonJS 镜像由检查脚本反查共享契约。
|
||||
|
||||
消息 envelope 统一为 JSON:
|
||||
|
||||
@@ -308,6 +308,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:移动壳 `navigation.canGoBack` 不再只读取 `react-native-webview` 的原生跨文档导航状态。Expo 壳会在 WebView `injectedJavaScriptBeforeContentLoaded` 中注入固定脚本,追踪当前 H5 文档内 `pushState` / `replaceState` / `popstate` 写入的路由栈,并通过内部 `genarrative.mobile.historyState` 消息回传;壳层把原生 WebView back-forward 状态与 H5 当前文档路由栈状态合成为 HostBridge `navigation.canGoBack` 事件。Android 返回键优先执行固定 `window.history.back(); true;` 回退 H5 SPA 路由,只有 H5 当前文档不可回退时才调用 WebView 原生 `goBack()`;该内部消息不是 HostBridge request method,也不开放 H5 到原生的通用事件写入通道。
|
||||
|
||||
2026-06-19 追加:H5 主 App 开始消费 `navigation.canGoBack` 事件。`useHostNavigationCanGoBack()` 只有在宿主同时声明 `host.events` 与 `navigation.canGoBack` 时才订阅返回栈状态,并会在宿主 runtime 刷新后重新确认能力;原生壳内直达非平台首页、非 runtime 的二级 H5 route 且当前 history state 没有应用导航标记时,App 会先把当前条目替换成 `/` 返回锚点,再把当前路径连同保留的宿主 query 推回 history,保证 Android 返回键、桌面菜单后退或宿主回退事件能落回平台首页。H5 不读取任意原生 back-forward list,普通浏览器、小程序、runtime 路由、已有应用 history 或旧壳缺能力时不注入该锚点。
|
||||
|
||||
2026-06-18 追加:移动壳声明并实现 `file.importText`,通过 Expo DocumentPicker 打开系统文档选择器,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB;读取文本内容前必须先通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI,也不开放通用文件系统。H5 创作 Agent 工作台在原生壳声明该能力时优先打开宿主系统选择器,再把返回文本转换成现有浏览器 `File` 并继续调用 `/api/runtime/creation-agent/document-inputs/parse`,不在前端绕过后端文档解析、大小校验或 docx 处理。
|
||||
|
||||
2026-06-19 追加:H5 创作 Agent 工作台在移动壳声明 `file.exportText` 时提供会话 Markdown 导出入口。H5 只把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装成 `text/markdown` 文本,先按共享 5 MiB 上限计算 UTF-8 byte,再通过 `exportHostTextFile()` 交给 Expo 系统分享 / 保存面板;宿主取消、缺能力或 unsupported 时不做浏览器下载回退,保持原生壳文件保存只走受控 HostBridge 能力。
|
||||
@@ -443,6 +445,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:原生壳注入消息来源进入门禁。Expo 和 Tauri 注入给 H5 的 HostBridge response / event 都显式带 `origin: window.location.origin` 和 `source: window`;H5 `nativeAppHostBridge` listener 会忽略带非当前窗口 source 或非当前页面 origin 的 message。这样后续 AI sandbox iframe 即使能向父页面 `postMessage` 同形 envelope,也不能结算宿主请求或伪造宿主事件;GameBridge 继续走单独 allowlist。
|
||||
|
||||
2026-06-19 追加:H5 页面内应用导航会保留完整原生宿主上下文。`pushAppHistoryPath()` 和 `replaceAppHistoryPath()` 必须通过共享 `HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 补齐 `clientType`、`clientRuntime`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`,并用应用 history state 标记 H5 自己写入的导航条目。这样直达二级页补返回锚点、平台内页面切换和原生壳 runtime 能力刷新不会因为 H5 自己跳转而掉回普通浏览器运行态。
|
||||
|
||||
2026-06-18 追加:HostBridge request id 进入宿主侧 replay 门禁。Expo 壳会缓存已完成响应并让进行中的同 id 请求共用同一执行结果;Tauri 壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 对同 id 请求做等待 / 回放。重复 id 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。
|
||||
|
||||
2026-06-18 追加:HostBridge request envelope 校验收紧。共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 method 白名单和 request id 规则。空 id、控制字符 id、超长 id 和未知 method 都在进入 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。
|
||||
|
||||
@@ -45,7 +45,7 @@ AI H5 sandbox
|
||||
|
||||
Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗口配置,并在创建 WebView 前补写 `native_app`、`tauri_desktop` 和真实 capability 上下文;缺少主窗口配置时启动直接失败,不允许按 `windows[0]` 兜底或无主窗口静默运行。
|
||||
|
||||
宿主上下文 query 的字段名和值以 `packages/shared/src/contracts/hostBridge.ts` 为源。`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime 识别可读取的 `clientRuntime`、`clientType`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`;`HOST_BRIDGE_NATIVE_APP_QUERY_KEY`、`HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` 与 `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query;`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记;`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要跨路径保留的宿主字段。Expo 移动壳直接引用共享常量,Tauri Rust 和微信小程序 CommonJS 运行时镜像由 `npm run check:native-shells` 反查;H5 `getHostRuntime()` 和路由保留列表不得重新手写这些字段。
|
||||
宿主上下文 query 的字段名和值以 `packages/shared/src/contracts/hostBridge.ts` 为源。`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime 识别可读取的 `clientRuntime`、`clientType`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`;`HOST_BRIDGE_NATIVE_APP_QUERY_KEY`、`HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` 与 `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query;`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记;`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要跨路径保留的宿主字段,必须同时覆盖微信小程序来源字段和原生壳 `hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion`、`hostCapabilities` 完整运行态字段。Expo 移动壳直接引用共享常量,Tauri Rust 和微信小程序 CommonJS 运行时镜像由 `npm run check:native-shells` 反查;H5 `getHostRuntime()` 和路由保留列表不得重新手写这些字段。
|
||||
|
||||
## 首批能力
|
||||
|
||||
@@ -53,6 +53,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
|
||||
- `getHostAppearanceColorScheme()`:原生 App 宿主的受控外观查询入口。H5 可通过 `appearance.getColorScheme` 读取宿主当前 `light` / `dark` / `unknown` 配色模式;Expo 移动壳通过 React Native `Appearance.getColorScheme()` 读取系统偏好,Tauri 桌面壳通过主窗口 `theme()` 读取窗口主题。该能力只读,不改变 H5 主题,也不覆盖用户或系统偏好。
|
||||
- `subscribeHostAppLifecycle()`:原生 App 宿主的受控生命周期事件入口。Expo 移动壳和 Tauri 桌面壳都声明 `host.events`,表示宿主会通过 HostBridge message 派发事件;其中 Expo 移动壳通过 React Native `AppState` 派发 `app.lifecycle`,Tauri 桌面壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发同名事件。桌面壳不会把 hidden、minimized 或 tray 扩成新的 `state`,而是读取 `is_visible()`、`is_minimized()`、`is_focused()` 后统一归一为 `active` / `inactive` / `background`,并只把 `hidden`、`minimized`、`focused`、`blurred` 放进 `nativeState` 用于排障。`host.events` 不作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只依赖统一的 `active` / `inactive` / `background` 状态和 `focused` 布尔值,原生细分状态只放在 `nativeState` 用于排障,不作为业务分支依据。H5 统一通过 `useHostLifecycleActive()` 把宿主状态折算为运行态可播放状态;WebAudio 背景音乐和固定玩法 `<audio>` 背景音乐都必须按该状态暂停 / 恢复,宿主进入后台、inactive 或窗口失焦时暂停,回到 active 且 focused 后只在原运行态、音源和用户音量仍允许时恢复。
|
||||
- `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`:原生 App 宿主的受控网络状态入口。Expo 移动壳通过 `expo-network` 查询并订阅真实系统网络状态;Tauri 桌面壳从 `WEB_APP_ORIGIN` 解析主站 host / port 后做短超时 TCP 可达性查询,并在主 WebView 内监听 `online` / `offline` 注入变化事件。H5 只依赖统一的 `isConnected`、`isInternetReachable` 和连接类型,不直接读取平台私有网络 API。平台外部生成队列概览通过 `useHostNetworkOnline()` 消费该状态,宿主未声明网络能力时保持原轮询行为,宿主明确离线或不可达时暂停轮询,恢复在线后重新刷新;该状态不替代后端队列事实或生成结果回读。
|
||||
- `subscribeHostNavigationCanGoBack()` / `useHostNavigationCanGoBack()`:原生 App 宿主的受控返回栈状态入口。H5 只有在宿主同时声明 `host.events` 和 `navigation.canGoBack` 时才订阅该事件;Expo 移动壳和 Tauri 桌面壳注入的状态只表示当前 H5 文档路由栈或宿主已归一后的返回状态,不让 H5 读取任意原生 back-forward list。主 App 在原生壳内直达非平台首页、非 runtime 的二级 H5 route 且当前 history state 没有应用导航标记时,会先把当前条目替换为 `/` 返回锚点,再把当前路径连同已保留的宿主 query 推回 history;普通浏览器、小程序、runtime 路由、已有应用 history 或不支持返回栈事件的裁剪壳不触发该补齐。
|
||||
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
|
||||
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
|
||||
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
|
||||
|
||||
@@ -61,6 +61,11 @@ describe('HostBridge shared contract helpers', () => {
|
||||
'clientType',
|
||||
'clientRuntime',
|
||||
'miniProgramEnv',
|
||||
'hostShell',
|
||||
'hostPlatform',
|
||||
'hostVersion',
|
||||
'bridgeVersion',
|
||||
'hostCapabilities',
|
||||
]);
|
||||
expect(HOST_BRIDGE_NATIVE_APP_QUERY_KEY).toEqual({
|
||||
clientRuntime: 'clientRuntime',
|
||||
|
||||
@@ -31,6 +31,11 @@ export const HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS = [
|
||||
'clientType',
|
||||
'clientRuntime',
|
||||
'miniProgramEnv',
|
||||
'hostShell',
|
||||
'hostPlatform',
|
||||
'hostVersion',
|
||||
'bridgeVersion',
|
||||
'hostCapabilities',
|
||||
] as const;
|
||||
|
||||
export const HOST_BRIDGE_NATIVE_APP_QUERY_KEY = {
|
||||
|
||||
@@ -59,6 +59,7 @@ const h5HostBridgeScannedFacadeImports = new Set([
|
||||
]);
|
||||
const h5HostBridgeCallChainWrapperFiles = [
|
||||
'src/hooks/useHostLifecycleActive.ts',
|
||||
'src/hooks/useHostNavigationCanGoBack.ts',
|
||||
'src/hooks/useHostNetworkOnline.ts',
|
||||
'src/components/platform-entry/platformProfileHostClipboard.ts',
|
||||
'src/components/platform-entry/platformHostBridgeSync.ts',
|
||||
@@ -85,6 +86,7 @@ const h5HostBridgeRequiredCallChainFiles = [
|
||||
'src/components/square-hole-result/SquareHoleResultView.tsx',
|
||||
'src/hooks/useBackgroundMusic.ts',
|
||||
'src/hooks/useHostLifecycleActive.ts',
|
||||
'src/hooks/useHostNavigationCanGoBack.ts',
|
||||
'src/hooks/useHostNetworkOnline.ts',
|
||||
'src/main.tsx',
|
||||
'src/services/appTitle.ts',
|
||||
@@ -325,6 +327,7 @@ const h5HostBridgeTests = [
|
||||
'src/services/host-bridge/hostBridge.test.ts',
|
||||
'src/services/host-bridge/nativeAppHostBridge.test.ts',
|
||||
'src/App.test.tsx',
|
||||
'src/hooks/useHostNavigationCanGoBack.test.tsx',
|
||||
'src/components/bark-battle-creation/BarkBattleResultView.test.tsx',
|
||||
'src/components/common/CreativeAudioInputPanel.test.tsx',
|
||||
'src/components/common/PublishShareModal.test.tsx',
|
||||
@@ -1227,6 +1230,11 @@ function assertWechatMiniProgramRouteParity() {
|
||||
sharedRuntimeContextQueryKey.clientType,
|
||||
sharedRuntimeContextQueryKey.clientRuntime,
|
||||
sharedRuntimeContextQueryKey.miniProgramEnv,
|
||||
sharedRuntimeContextQueryKey.hostShell,
|
||||
sharedRuntimeContextQueryKey.hostPlatform,
|
||||
sharedRuntimeContextQueryKey.hostVersion,
|
||||
sharedRuntimeContextQueryKey.bridgeVersion,
|
||||
sharedRuntimeContextQueryKey.hostCapabilities,
|
||||
],
|
||||
'preserved HostBridge runtime context query keys',
|
||||
);
|
||||
|
||||
+40
-1
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import App from './App';
|
||||
@@ -28,8 +28,10 @@ vi.mock('./components/platform-entry/PlatformEntryFlowShell', () => ({
|
||||
PlatformEntryFlowShell: ({
|
||||
handleCustomWorldSelect,
|
||||
setSelectionStage,
|
||||
selectionStage,
|
||||
}: PlatformEntryFlowShellProps) => (
|
||||
<div>
|
||||
<div data-testid="selection-stage">{selectionStage}</div>
|
||||
<div data-testid="share-capability">
|
||||
{canUseNativeHostCapability('share.open') ? 'enabled' : 'disabled'}
|
||||
</div>
|
||||
@@ -172,4 +174,41 @@ describe('App title sync', () => {
|
||||
'enabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('原生壳直达二级页面时补齐 H5 返回锚点', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/creation/puzzle?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack',
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: vi.fn(async () => {
|
||||
throw new Error('unsupported');
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'puzzle-agent-workspace',
|
||||
);
|
||||
expect(window.location.pathname).toBe('/creation/puzzle');
|
||||
expect(window.location.search).toBe(
|
||||
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events%2Cnavigation.canGoBack',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
window.history.back();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
expect(window.location.search).toBe(
|
||||
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events%2Cnavigation.canGoBack',
|
||||
);
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe('platform');
|
||||
});
|
||||
});
|
||||
|
||||
+36
@@ -13,11 +13,14 @@ import type {
|
||||
CustomWorldRuntimeLaunchOptions,
|
||||
SelectionStage,
|
||||
} from './components/platform-entry/platformEntryTypes';
|
||||
import { useHostNavigationCanGoBack } from './hooks/useHostNavigationCanGoBack';
|
||||
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
isAppHistoryState,
|
||||
normalizeAppPath,
|
||||
pushAppHistoryPath,
|
||||
replaceAppHistoryPath,
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
@@ -61,6 +64,10 @@ function isRpgRuntimeRoute(pathname: string) {
|
||||
export default function App() {
|
||||
const authUi = useAuthUi();
|
||||
const runtimeIntentTokenRef = useRef(0);
|
||||
const hasHostNavigationAnchorRef = useRef(
|
||||
isAppHistoryState(window.history.state),
|
||||
);
|
||||
const hostNavigation = useHostNavigationCanGoBack();
|
||||
const [runtimeIntent, setRuntimeIntent] =
|
||||
useState<RpgRuntimeAppIntent | null>(null);
|
||||
const [, setHostRuntimeRevision] = useState(0);
|
||||
@@ -93,6 +100,10 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
hasHostNavigationAnchorRef.current = isAppHistoryState(
|
||||
window.history.state,
|
||||
);
|
||||
|
||||
if (isRpgRuntimeRoute(window.location.pathname)) {
|
||||
setIsRuntimeActive(true);
|
||||
return;
|
||||
@@ -108,6 +119,31 @@ export default function App() {
|
||||
return () => window.removeEventListener('popstate', syncStageFromHistory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hostNavigation.isSupported ||
|
||||
hostNavigation.canGoBack ||
|
||||
isRuntimeActive ||
|
||||
selectionStage === 'platform' ||
|
||||
isAppHistoryState(window.history.state) ||
|
||||
hasHostNavigationAnchorRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = normalizeAppPath(window.location.pathname);
|
||||
const currentSearch = window.location.search;
|
||||
|
||||
hasHostNavigationAnchorRef.current = true;
|
||||
replaceAppHistoryPath('/');
|
||||
pushAppHistoryPath(`${currentPath}${currentSearch}`);
|
||||
}, [
|
||||
hostNavigation.canGoBack,
|
||||
hostNavigation.isSupported,
|
||||
isRuntimeActive,
|
||||
selectionStage,
|
||||
]);
|
||||
|
||||
const createRuntimeIntent = useCallback(
|
||||
(intent: Omit<RpgRuntimeAppIntent, 'token'>) => {
|
||||
runtimeIntentTokenRef.current += 1;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
subscribeHostNavigationCanGoBack,
|
||||
subscribeHostRuntimeChange,
|
||||
} from '../services/host-bridge/hostBridge';
|
||||
import { useHostNavigationCanGoBack } from './useHostNavigationCanGoBack';
|
||||
|
||||
vi.mock('../services/host-bridge/hostBridge', () => ({
|
||||
canUseNativeHostCapability: vi.fn(() => false),
|
||||
subscribeHostNavigationCanGoBack: vi.fn(() => () => undefined),
|
||||
subscribeHostRuntimeChange: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
|
||||
const subscribeHostNavigationCanGoBackMock = vi.mocked(
|
||||
subscribeHostNavigationCanGoBack,
|
||||
);
|
||||
const subscribeHostRuntimeChangeMock = vi.mocked(subscribeHostRuntimeChange);
|
||||
|
||||
function setNavigationCapability(enabled: boolean) {
|
||||
canUseNativeHostCapabilityMock.mockImplementation((capability) =>
|
||||
enabled
|
||||
? capability === 'host.events' || capability === 'navigation.canGoBack'
|
||||
: false,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('host navigation can-go-back stays unsupported without both event capabilities', () => {
|
||||
setNavigationCapability(false);
|
||||
|
||||
const { result } = renderHook(() => useHostNavigationCanGoBack());
|
||||
|
||||
expect(result.current).toEqual({
|
||||
canGoBack: false,
|
||||
isSupported: false,
|
||||
});
|
||||
expect(subscribeHostNavigationCanGoBackMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('host navigation can-go-back follows native app events', () => {
|
||||
setNavigationCapability(true);
|
||||
const unsubscribe = vi.fn();
|
||||
let navigationListener:
|
||||
| Parameters<typeof subscribeHostNavigationCanGoBack>[0]
|
||||
| null = null;
|
||||
subscribeHostNavigationCanGoBackMock.mockImplementation((listener) => {
|
||||
navigationListener = listener;
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useHostNavigationCanGoBack(),
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
canGoBack: false,
|
||||
isSupported: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
navigationListener?.({ canGoBack: true });
|
||||
});
|
||||
expect(result.current).toEqual({
|
||||
canGoBack: true,
|
||||
isSupported: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
navigationListener?.({ canGoBack: false });
|
||||
});
|
||||
expect(result.current).toEqual({
|
||||
canGoBack: false,
|
||||
isSupported: true,
|
||||
});
|
||||
|
||||
unmount();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('host navigation can-go-back subscribes after runtime refresh exposes capabilities', () => {
|
||||
setNavigationCapability(false);
|
||||
let runtimeListener: Parameters<typeof subscribeHostRuntimeChange>[0] | null =
|
||||
null;
|
||||
let navigationListener:
|
||||
| Parameters<typeof subscribeHostNavigationCanGoBack>[0]
|
||||
| null = null;
|
||||
subscribeHostRuntimeChangeMock.mockImplementation((listener) => {
|
||||
runtimeListener = listener;
|
||||
return () => undefined;
|
||||
});
|
||||
subscribeHostNavigationCanGoBackMock.mockImplementation((listener) => {
|
||||
navigationListener = listener;
|
||||
return () => undefined;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useHostNavigationCanGoBack());
|
||||
|
||||
expect(result.current.isSupported).toBe(false);
|
||||
|
||||
setNavigationCapability(true);
|
||||
act(() => {
|
||||
runtimeListener?.();
|
||||
});
|
||||
|
||||
expect(result.current.isSupported).toBe(true);
|
||||
expect(subscribeHostNavigationCanGoBackMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
navigationListener?.({ canGoBack: true });
|
||||
});
|
||||
expect(result.current.canGoBack).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
subscribeHostNavigationCanGoBack,
|
||||
subscribeHostRuntimeChange,
|
||||
} from '../services/host-bridge/hostBridge';
|
||||
|
||||
function canUseHostNavigationCanGoBack() {
|
||||
return (
|
||||
canUseNativeHostCapability('host.events') &&
|
||||
canUseNativeHostCapability('navigation.canGoBack')
|
||||
);
|
||||
}
|
||||
|
||||
export function useHostNavigationCanGoBack() {
|
||||
const [isSupported, setIsSupported] = useState(() =>
|
||||
canUseHostNavigationCanGoBack(),
|
||||
);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isDisposed = false;
|
||||
let unsubscribeNavigation: () => void = () => {};
|
||||
|
||||
const syncSubscription = () => {
|
||||
unsubscribeNavigation();
|
||||
unsubscribeNavigation = () => {};
|
||||
|
||||
const nextIsSupported = canUseHostNavigationCanGoBack();
|
||||
if (!isDisposed) {
|
||||
setIsSupported(nextIsSupported);
|
||||
}
|
||||
|
||||
if (!nextIsSupported) {
|
||||
if (!isDisposed) {
|
||||
setCanGoBack(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribeNavigation = subscribeHostNavigationCanGoBack((payload) => {
|
||||
if (!isDisposed) {
|
||||
setCanGoBack(payload.canGoBack);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
syncSubscription();
|
||||
const unsubscribeRuntime = subscribeHostRuntimeChange(syncSubscription);
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
unsubscribeRuntime();
|
||||
unsubscribeNavigation();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
canGoBack,
|
||||
isSupported,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isAppHistoryState,
|
||||
pushAppHistoryPath,
|
||||
replaceAppHistoryPath,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
} from './appPageRoutes';
|
||||
@@ -193,6 +195,41 @@ describe('appPageRoutes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps native app runtime context and marks app history entries', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostShell=tauri_desktop&hostPlatform=linux&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime%2Chost.events%2Cnavigation.canGoBack',
|
||||
);
|
||||
|
||||
pushAppHistoryPath('/creation/puzzle');
|
||||
|
||||
expect(window.location.pathname).toBe('/creation/puzzle');
|
||||
expect(window.location.search).toBe(
|
||||
'?clientRuntime=native_app&hostShell=tauri_desktop&hostPlatform=linux&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime%2Chost.events%2Cnavigation.canGoBack',
|
||||
);
|
||||
expect(isAppHistoryState(window.history.state)).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces app history paths through the same runtime context rules', () => {
|
||||
window.history.replaceState(
|
||||
{ existing: true },
|
||||
'',
|
||||
'/creation/puzzle?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=host.events%2Cnavigation.canGoBack',
|
||||
);
|
||||
|
||||
replaceAppHistoryPath('/');
|
||||
|
||||
expect(window.location.pathname).toBe('/');
|
||||
expect(window.location.search).toBe(
|
||||
'?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=host.events%2Cnavigation.canGoBack',
|
||||
);
|
||||
expect(window.history.state).toMatchObject({
|
||||
existing: true,
|
||||
});
|
||||
expect(isAppHistoryState(window.history.state)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears creation restore query params when leaving the flow or switching flows', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
|
||||
@@ -79,6 +79,7 @@ const ROUTE_STAGE_BY_PATH = new Map(
|
||||
|
||||
const APP_RUNTIME_CONTEXT_QUERY_KEYS =
|
||||
HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS;
|
||||
export const APP_HISTORY_STATE_KEY = '__genarrativeAppHistoryEntry';
|
||||
|
||||
export function normalizeAppPath(pathname: string) {
|
||||
const trimmedPathname = pathname.trim().toLowerCase();
|
||||
@@ -141,6 +142,14 @@ export function isKnownMainAppPagePath(pathname: string) {
|
||||
}
|
||||
|
||||
export function pushAppHistoryPath(path: string) {
|
||||
writeAppHistoryPath(path, 'push');
|
||||
}
|
||||
|
||||
export function replaceAppHistoryPath(path: string) {
|
||||
writeAppHistoryPath(path, 'replace');
|
||||
}
|
||||
|
||||
function writeAppHistoryPath(path: string, mode: 'push' | 'replace') {
|
||||
const nextUrl = new URL(path, window.location.origin);
|
||||
const normalizedPath = normalizeAppPath(nextUrl.pathname);
|
||||
const nextSearch = buildPreservedAppSearch(
|
||||
@@ -156,7 +165,31 @@ export function pushAppHistoryPath(path: string) {
|
||||
}
|
||||
|
||||
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
|
||||
window.history.pushState(null, '', nextRelativeUrl);
|
||||
if (mode === 'replace') {
|
||||
window.history.replaceState(buildAppHistoryState(), '', nextRelativeUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.pushState(buildAppHistoryState(), '', nextRelativeUrl);
|
||||
}
|
||||
|
||||
export function isAppHistoryState(state: unknown) {
|
||||
return Boolean(
|
||||
state &&
|
||||
typeof state === 'object' &&
|
||||
(state as Record<string, unknown>)[APP_HISTORY_STATE_KEY] === true,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAppHistoryState() {
|
||||
const currentState =
|
||||
window.history.state && typeof window.history.state === 'object'
|
||||
? window.history.state
|
||||
: {};
|
||||
return {
|
||||
...currentState,
|
||||
[APP_HISTORY_STATE_KEY]: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreservedAppSearch(
|
||||
|
||||
Reference in New Issue
Block a user