接入原生返回栈状态消费

H5 新增 navigation.canGoBack hook 并在直达二级页时补齐返回锚点

路由导航保留完整原生宿主上下文并标记应用历史状态

补齐 HostBridge 返回栈消费测试、门禁和文档
This commit is contained in:
2026-06-19 10:59:47 +08:00
parent 4a16263729
commit ce0765b298
13 changed files with 364 additions and 5 deletions
@@ -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 = {
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+63
View File
@@ -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,
};
}
+37
View File
@@ -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,
+34 -1
View File
@@ -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(