回读宿主运行态能力
HostBridge 启动时通过真实 host.getRuntime 回读并缓存宿主能力 主 App 订阅宿主能力变化并在回读后刷新能力入口 补充宿主 runtime 回读测试和 App 能力刷新测试 更新 Expo/Tauri 壳方案、HostBridge 协议文档和共享决策记录
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
- 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade,`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `<a>` 行为,宿主不可用或拒绝时回退浏览器外链。
|
- 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade,`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `<a>` 行为,宿主不可用或拒绝时回退浏览器外链。
|
||||||
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
|
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
|
||||||
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
|
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
|
||||||
|
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。
|
||||||
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
||||||
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
||||||
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ GameBridge 禁止:
|
|||||||
- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape。
|
- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape。
|
||||||
- 壳层只接受来自允许 origin / packaged asset 的消息。
|
- 壳层只接受来自允许 origin / packaged asset 的消息。
|
||||||
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。
|
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。
|
||||||
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;不能只凭 `native_app` 宿主类型假设能力可用。
|
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。
|
||||||
- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。
|
- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。
|
||||||
- Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。
|
- Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。
|
||||||
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。
|
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。
|
||||||
@@ -241,7 +241,7 @@ GameBridge 禁止:
|
|||||||
- iOS / Android 深链打开作品详情、创作页和邀请码。
|
- iOS / Android 深链打开作品详情、创作页和邀请码。
|
||||||
- 登录和支付先 fallback 到 H5;只把能力边界跑通。
|
- 登录和支付先 fallback 到 H5;只把能力边界跑通。
|
||||||
|
|
||||||
当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`host.events`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.openExternalUrl`、`clipboard.writeText`、`file.exportText`、`haptics.impact` 和 Android 返回键回退;H5 会解析并过滤 `hostCapabilities`,只对声明能力展示入口或调用宿主能力;其中 `share.setTarget` / `share.open` 会解析统一分享目标里的 `title`、`message`、`url`、`work`、`path` 或 `targetPath` 并调用 React Native 系统分享面板;发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作,失败时保留复制链接回退路径;`navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`navigation.canGoBack` 由 WebView 导航状态变化实时注入 H5,WebView 自身拦截到外域导航时只会把 `http:`、`https:`、`mailto:`、`tel:` 交给系统,危险协议直接阻断;`app.openExternalUrl` 也只允许同一协议白名单,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开 WebView 并交给系统浏览器;`clipboard.writeText` 由 H5 复制服务优先调用并写入系统剪贴板;`file.exportText` 通过 Expo 文件系统写入缓存文本文件,再交给系统分享 / 保存面板,文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数;`haptics.impact` 通过 Expo Haptics 承接运行时轻触反馈,H5 在宿主不支持时回退到浏览器 vibration。登录和支付尚未接入渠道 SDK / 原生页面时明确返回 unsupported,让 H5 fallback 承接。
|
当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`host.events`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.openExternalUrl`、`clipboard.writeText`、`file.exportText`、`haptics.impact` 和 Android 返回键回退;H5 会解析并过滤 `hostCapabilities`,也会在主 App 启动时通过真实 `host.getRuntime` 回读并缓存能力,只对声明或回读到的能力展示入口或调用宿主能力;其中 `share.setTarget` / `share.open` 会解析统一分享目标里的 `title`、`message`、`url`、`work`、`path` 或 `targetPath` 并调用 React Native 系统分享面板;发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作,失败时保留复制链接回退路径;`navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`navigation.canGoBack` 由 WebView 导航状态变化实时注入 H5,WebView 自身拦截到外域导航时只会把 `http:`、`https:`、`mailto:`、`tel:` 交给系统,危险协议直接阻断;`app.openExternalUrl` 也只允许同一协议白名单,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开 WebView 并交给系统浏览器;`clipboard.writeText` 由 H5 复制服务优先调用并写入系统剪贴板;`file.exportText` 通过 Expo 文件系统写入缓存文本文件,再交给系统分享 / 保存面板,文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数;`haptics.impact` 通过 Expo Haptics 承接运行时轻触反馈,H5 在宿主不支持时回退到浏览器 vibration。登录和支付尚未接入渠道 SDK / 原生页面时明确返回 unsupported,让 H5 fallback 承接。
|
||||||
|
|
||||||
### Phase 3:Tauri 桌面壳 MVP
|
### Phase 3:Tauri 桌面壳 MVP
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ GameBridge 禁止:
|
|||||||
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
|
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
|
||||||
- 验证 macOS / Windows / Linux 至少一条本地 smoke。
|
- 验证 macOS / Windows / Linux 至少一条本地 smoke。
|
||||||
|
|
||||||
当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开主窗口并交给系统浏览器;`navigation.openNativePage` 只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内受控跳转,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板并由 H5 复制服务优先调用,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符;H5 主站会按当前平台阶段先更新 `document.title`,再通过 `app.setTitle` 把同一标题同步给 Tauri 窗口,Expo 移动壳不声明该能力时静默忽略;不把 opener、clipboard 或 dialog 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`share.setTarget`、`share.open`、`navigation.openNativePage`、`app.openExternalUrl`、`app.setTitle`、`clipboard.writeText` 和 `file.exportText`;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`,发布分享弹窗在桌面壳中也通过该能力提供“系统分享”动作;`file.exportText` 通过系统保存对话框让用户选择本地路径,清洗文件名、限制单次文本导出不超过 5 MiB,写入成功后只返回文件名和字节数,不把本机绝对路径暴露给 H5,用户取消返回 `cancelled`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。
|
当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开主窗口并交给系统浏览器;`navigation.openNativePage` 只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内受控跳转,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板并由 H5 复制服务优先调用,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符;H5 主站会按当前平台阶段先更新 `document.title`,再通过 `app.setTitle` 把同一标题同步给 Tauri 窗口,Expo 移动壳不声明该能力时静默忽略;不把 opener、clipboard 或 dialog 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`share.setTarget`、`share.open`、`navigation.openNativePage`、`app.openExternalUrl`、`app.setTitle`、`clipboard.writeText` 和 `file.exportText`;H5 会在主 App 启动时通过真实 `host.getRuntime` 回读并缓存这些能力,即使入口 URL 缺少 `hostCapabilities`,也只按宿主真实回包开启能力入口;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`,发布分享弹窗在桌面壳中也通过该能力提供“系统分享”动作;`file.exportText` 通过系统保存对话框让用户选择本地路径,清洗文件名、限制单次文本导出不超过 5 MiB,写入成功后只返回文件名和字节数,不把本机绝对路径暴露给 H5,用户取消返回 `cancelled`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。
|
||||||
|
|
||||||
### Phase 4:宿主能力扩展
|
### Phase 4:宿主能力扩展
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ AI H5 sandbox
|
|||||||
|
|
||||||
## 首批能力
|
## 首批能力
|
||||||
|
|
||||||
- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;未知能力会被丢弃,H5 业务只根据已声明能力展示入口或发起宿主请求。
|
- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。
|
||||||
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
|
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
|
||||||
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
|
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
|
||||||
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
|
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
|
||||||
@@ -57,7 +57,7 @@ AI H5 sandbox
|
|||||||
1. 新增 `src/services/host-bridge/`,沉淀宿主运行态识别和微信小程序 JS SDK 加载,并暴露通用 HostBridge 能力接口。
|
1. 新增 `src/services/host-bridge/`,沉淀宿主运行态识别和微信小程序 JS SDK 加载,并暴露通用 HostBridge 能力接口。
|
||||||
2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。
|
2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。
|
||||||
3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。
|
3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。
|
||||||
4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉。
|
4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import { AuthUiContext } from './components/auth/AuthUiContext';
|
import { AuthUiContext } from './components/auth/AuthUiContext';
|
||||||
import type { PlatformEntryFlowShellProps } from './components/platform-entry';
|
import type { PlatformEntryFlowShellProps } from './components/platform-entry';
|
||||||
|
import {
|
||||||
|
canUseNativeHostCapability,
|
||||||
|
resetHostRuntimeCacheForTest,
|
||||||
|
} from './services/host-bridge/hostBridge';
|
||||||
|
import { resetNativeAppHostBridgeForTest } from './services/host-bridge/nativeAppHostBridge';
|
||||||
|
|
||||||
const appTitleMock = vi.hoisted(() => ({
|
const appTitleMock = vi.hoisted(() => ({
|
||||||
syncAppTitle: vi.fn(),
|
syncAppTitle: vi.fn(),
|
||||||
@@ -25,6 +30,9 @@ vi.mock('./components/platform-entry/PlatformEntryFlowShell', () => ({
|
|||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
}: PlatformEntryFlowShellProps) => (
|
}: PlatformEntryFlowShellProps) => (
|
||||||
<div>
|
<div>
|
||||||
|
<div data-testid="share-capability">
|
||||||
|
{canUseNativeHostCapability('share.open') ? 'enabled' : 'disabled'}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectionStage('puzzle-agent-workspace')}
|
onClick={() => setSelectionStage('puzzle-agent-workspace')}
|
||||||
@@ -87,6 +95,10 @@ function renderApp() {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
appTitleMock.syncAppTitle.mockReset();
|
appTitleMock.syncAppTitle.mockReset();
|
||||||
window.history.replaceState(null, '', '/');
|
window.history.replaceState(null, '', '/');
|
||||||
|
delete window.__TAURI__;
|
||||||
|
delete window.ReactNativeWebView;
|
||||||
|
resetHostRuntimeCacheForTest();
|
||||||
|
resetNativeAppHostBridgeForTest();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,4 +134,42 @@ describe('App title sync', () => {
|
|||||||
|
|
||||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
|
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('启动时回读宿主 runtime 后刷新壳能力 UI', async () => {
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
||||||
|
);
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: {
|
||||||
|
invoke: vi.fn(async (_command, args) => {
|
||||||
|
const request = (args as { request: { id: string } }).request;
|
||||||
|
return {
|
||||||
|
bridge: 'GenarrativeHostBridge',
|
||||||
|
version: 1,
|
||||||
|
id: request.id,
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
shell: 'tauri_desktop',
|
||||||
|
platform: 'linux',
|
||||||
|
hostVersion: '0.1.0',
|
||||||
|
bridgeVersion: 1,
|
||||||
|
capabilities: ['host.getRuntime', 'share.open'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderApp();
|
||||||
|
|
||||||
|
expect(screen.getByTestId('share-capability').textContent).toBe(
|
||||||
|
'disabled',
|
||||||
|
);
|
||||||
|
await screen.findByText('enabled');
|
||||||
|
expect(screen.getByTestId('share-capability').textContent).toBe(
|
||||||
|
'enabled',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
15
src/App.tsx
15
src/App.tsx
@@ -27,6 +27,10 @@ import {
|
|||||||
resolveAppTitleForSelectionStage,
|
resolveAppTitleForSelectionStage,
|
||||||
syncAppTitle,
|
syncAppTitle,
|
||||||
} from './services/appTitle';
|
} from './services/appTitle';
|
||||||
|
import {
|
||||||
|
refreshNativeAppHostRuntime,
|
||||||
|
subscribeHostRuntimeChange,
|
||||||
|
} from './services/host-bridge/hostBridge';
|
||||||
import type { CustomWorldProfile } from './types';
|
import type { CustomWorldProfile } from './types';
|
||||||
|
|
||||||
const RpgRuntimeApp = lazy(async () => {
|
const RpgRuntimeApp = lazy(async () => {
|
||||||
@@ -59,6 +63,7 @@ export default function App() {
|
|||||||
const runtimeIntentTokenRef = useRef(0);
|
const runtimeIntentTokenRef = useRef(0);
|
||||||
const [runtimeIntent, setRuntimeIntent] =
|
const [runtimeIntent, setRuntimeIntent] =
|
||||||
useState<RpgRuntimeAppIntent | null>(null);
|
useState<RpgRuntimeAppIntent | null>(null);
|
||||||
|
const [, setHostRuntimeRevision] = useState(0);
|
||||||
const [isRuntimeActive, setIsRuntimeActive] = useState(() =>
|
const [isRuntimeActive, setIsRuntimeActive] = useState(() =>
|
||||||
isRpgRuntimeRoute(window.location.pathname),
|
isRpgRuntimeRoute(window.location.pathname),
|
||||||
);
|
);
|
||||||
@@ -76,6 +81,16 @@ export default function App() {
|
|||||||
pushAppHistoryPath(resolvePathForSelectionStage(stage));
|
pushAppHistoryPath(resolvePathForSelectionStage(stage));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = subscribeHostRuntimeChange(() => {
|
||||||
|
setHostRuntimeRevision((revision) => revision + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
void refreshNativeAppHostRuntime();
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncStageFromHistory = () => {
|
const syncStageFromHistory = () => {
|
||||||
if (isRpgRuntimeRoute(window.location.pathname)) {
|
if (isRpgRuntimeRoute(window.location.pathname)) {
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {lockMobileViewportZoom} from './mobileViewportZoomLock';
|
|||||||
import {resolveAppRoute} from './routing/appRoutes';
|
import {resolveAppRoute} from './routing/appRoutes';
|
||||||
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
|
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
|
||||||
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
|
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
|
||||||
import {getHostRuntime} from './services/host-bridge/hostBridge';
|
import {
|
||||||
|
getHostRuntime,
|
||||||
|
refreshNativeAppHostRuntime,
|
||||||
|
} from './services/host-bridge/hostBridge';
|
||||||
|
|
||||||
type AppRoot = ReturnType<typeof createRoot>;
|
type AppRoot = ReturnType<typeof createRoot>;
|
||||||
|
|
||||||
@@ -39,6 +42,7 @@ const RouteComponent = route.Component;
|
|||||||
lockMobileViewportZoom();
|
lockMobileViewportZoom();
|
||||||
stabilizeMobileViewportKeyboardFocus();
|
stabilizeMobileViewportKeyboardFocus();
|
||||||
markWechatMiniProgramRuntime();
|
markWechatMiniProgramRuntime();
|
||||||
|
void refreshNativeAppHostRuntime();
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ import {
|
|||||||
openHostShareGrid,
|
openHostShareGrid,
|
||||||
openWechatMiniProgramShareGridPage,
|
openWechatMiniProgramShareGridPage,
|
||||||
postWechatMiniProgramMessage,
|
postWechatMiniProgramMessage,
|
||||||
|
refreshNativeAppHostRuntime,
|
||||||
requestHostHapticsImpact,
|
requestHostHapticsImpact,
|
||||||
requestHostLogin,
|
requestHostLogin,
|
||||||
requestHostPayment,
|
requestHostPayment,
|
||||||
requestWechatMiniProgramPayment,
|
requestWechatMiniProgramPayment,
|
||||||
requestWechatMiniProgramPhoneLogin,
|
requestWechatMiniProgramPhoneLogin,
|
||||||
|
resetHostRuntimeCacheForTest,
|
||||||
resolveHostRuntime,
|
resolveHostRuntime,
|
||||||
setHostAppTitle,
|
setHostAppTitle,
|
||||||
setHostShareTarget,
|
setHostShareTarget,
|
||||||
|
subscribeHostRuntimeChange,
|
||||||
writeHostClipboardText,
|
writeHostClipboardText,
|
||||||
} from './hostBridge';
|
} from './hostBridge';
|
||||||
import { resetNativeAppHostBridgeForTest } from './nativeAppHostBridge';
|
import { resetNativeAppHostBridgeForTest } from './nativeAppHostBridge';
|
||||||
@@ -57,6 +60,7 @@ afterEach(() => {
|
|||||||
delete window.ReactNativeWebView;
|
delete window.ReactNativeWebView;
|
||||||
delete window.__TAURI__;
|
delete window.__TAURI__;
|
||||||
resetNativeAppHostBridgeForTest();
|
resetNativeAppHostBridgeForTest();
|
||||||
|
resetHostRuntimeCacheForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hostBridge', () => {
|
describe('hostBridge', () => {
|
||||||
@@ -133,6 +137,103 @@ describe('hostBridge', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('从真实宿主 runtime 回读能力并通知订阅者', async () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = subscribeHostRuntimeChange(listener);
|
||||||
|
const invoke = vi.fn(
|
||||||
|
async (_command: string, args?: Record<string, unknown>) => {
|
||||||
|
const request = (args as { request: { id: string; method: string } })
|
||||||
|
.request;
|
||||||
|
return {
|
||||||
|
bridge: 'GenarrativeHostBridge',
|
||||||
|
version: 1,
|
||||||
|
id: request.id,
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
shell: 'tauri_desktop',
|
||||||
|
platform: 'linux',
|
||||||
|
hostVersion: '0.1.0',
|
||||||
|
bridgeVersion: 1,
|
||||||
|
capabilities: [
|
||||||
|
'host.getRuntime',
|
||||||
|
'share.open',
|
||||||
|
'clipboard.writeText',
|
||||||
|
'unknown.capability',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
||||||
|
);
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: {
|
||||||
|
invoke: asTauriInvoke(invoke),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canUseNativeHostCapability('share.open')).toBe(false);
|
||||||
|
await expect(refreshNativeAppHostRuntime()).resolves.toMatchObject({
|
||||||
|
shell: 'tauri_desktop',
|
||||||
|
capabilities: ['host.getRuntime', 'share.open', 'clipboard.writeText'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(canUseNativeHostCapability('share.open')).toBe(true);
|
||||||
|
expect(canUseNativeHostCapability('clipboard.writeText')).toBe(true);
|
||||||
|
expect(canUseNativeHostCapability('file.exportText')).toBe(false);
|
||||||
|
expect(getHostRuntime().hostCapabilities).toEqual([
|
||||||
|
'host.getRuntime',
|
||||||
|
'share.open',
|
||||||
|
'clipboard.writeText',
|
||||||
|
]);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invoke).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('普通浏览器不混入缓存的原生宿主能力', async () => {
|
||||||
|
const invoke = vi.fn(
|
||||||
|
async (_command: string, args?: Record<string, unknown>) => {
|
||||||
|
const request = (args as { request: { id: string } }).request;
|
||||||
|
return {
|
||||||
|
bridge: 'GenarrativeHostBridge',
|
||||||
|
version: 1,
|
||||||
|
id: request.id,
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
shell: 'tauri_desktop',
|
||||||
|
platform: 'linux',
|
||||||
|
hostVersion: '0.1.0',
|
||||||
|
bridgeVersion: 1,
|
||||||
|
capabilities: ['share.open'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
||||||
|
);
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: {
|
||||||
|
invoke: asTauriInvoke(invoke),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await refreshNativeAppHostRuntime();
|
||||||
|
delete window.__TAURI__;
|
||||||
|
window.history.replaceState(null, '', '/?clientRuntime=browser');
|
||||||
|
|
||||||
|
expect(getHostRuntime().kind).toBe('browser');
|
||||||
|
expect(getHostRuntime().hostCapabilities).toEqual([]);
|
||||||
|
expect(canUseNativeHostCapability('share.open')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('通过微信小程序原生页请求登录', async () => {
|
test('通过微信小程序原生页请求登录', async () => {
|
||||||
const navigateTo = vi.fn((options) => {
|
const navigateTo = vi.fn((options) => {
|
||||||
options.success?.();
|
options.success?.();
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import type {
|
|||||||
WechatMiniProgramPayParams,
|
WechatMiniProgramPayParams,
|
||||||
WechatMiniProgramVirtualPayParams,
|
WechatMiniProgramVirtualPayParams,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { requestNativeAppHostBridge } from './nativeAppHostBridge';
|
import {
|
||||||
|
canUseNativeAppHostBridge,
|
||||||
|
requestNativeAppHostBridge,
|
||||||
|
} from './nativeAppHostBridge';
|
||||||
|
|
||||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||||
const MINI_PROGRAM_AUTH_PAGE_URL =
|
const MINI_PROGRAM_AUTH_PAGE_URL =
|
||||||
@@ -84,6 +87,13 @@ export type HostShareOpenRequest = ShareOpenPayload;
|
|||||||
|
|
||||||
export type HostExternalUrlRequest = OpenExternalUrlPayload;
|
export type HostExternalUrlRequest = OpenExternalUrlPayload;
|
||||||
|
|
||||||
|
const HOST_RUNTIME_REFRESH_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
let cachedNativeHostRuntime: HostBridgeRuntimeResult | null = null;
|
||||||
|
let nativeHostRuntimeRefreshPromise: Promise<HostBridgeRuntimeResult | null> | null =
|
||||||
|
null;
|
||||||
|
const hostRuntimeChangeListeners = new Set<() => void>();
|
||||||
|
|
||||||
function isUnsupportedHostBridgeError(error: unknown) {
|
function isUnsupportedHostBridgeError(error: unknown) {
|
||||||
return (
|
return (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
@@ -92,6 +102,56 @@ function isUnsupportedHostBridgeError(error: unknown) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHostCapabilities(values: unknown): HostBridgeCapability[] {
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.filter(isHostBridgeCapability);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeHostCapabilities(
|
||||||
|
...capabilityLists: Array<HostBridgeCapability[] | null | undefined>
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(capabilityLists.flatMap((capabilities) => capabilities ?? [])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNativeHostRuntimeResult(
|
||||||
|
runtime: HostBridgeRuntimeResult | null | undefined,
|
||||||
|
) {
|
||||||
|
if (!runtime || typeof runtime !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...runtime,
|
||||||
|
capabilities: normalizeHostCapabilities(runtime.capabilities),
|
||||||
|
} satisfies HostBridgeRuntimeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCachedNativeHostRuntime(
|
||||||
|
runtime: HostBridgeRuntimeResult | null,
|
||||||
|
) {
|
||||||
|
const normalizedRuntime = normalizeNativeHostRuntimeResult(runtime);
|
||||||
|
if (!normalizedRuntime) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedNativeHostRuntime = normalizedRuntime;
|
||||||
|
hostRuntimeChangeListeners.forEach((listener) => listener());
|
||||||
|
return normalizedRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeHostRuntimeChange(listener: () => void) {
|
||||||
|
hostRuntimeChangeListeners.add(listener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
hostRuntimeChangeListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function requestNativeHostBoolean(
|
async function requestNativeHostBoolean(
|
||||||
method: HostBridgeMethod,
|
method: HostBridgeMethod,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
@@ -106,17 +166,6 @@ async function requestNativeHostBoolean(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeHasNativeCapability(
|
|
||||||
capability: HostBridgeCapability,
|
|
||||||
context: HostRuntimeContext = {},
|
|
||||||
) {
|
|
||||||
const runtime = getHostRuntime(context);
|
|
||||||
return (
|
|
||||||
runtime.kind === 'native_app' &&
|
|
||||||
runtime.hostCapabilities.includes(capability)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocation(context: HostRuntimeContext) {
|
function resolveLocation(context: HostRuntimeContext) {
|
||||||
return (
|
return (
|
||||||
context.location ?? (typeof window !== 'undefined' ? window.location : null)
|
context.location ?? (typeof window !== 'undefined' ? window.location : null)
|
||||||
@@ -171,7 +220,7 @@ export function resolveHostRuntime(
|
|||||||
const hostShell = params.get('hostShell');
|
const hostShell = params.get('hostShell');
|
||||||
const hostPlatform = params.get('hostPlatform');
|
const hostPlatform = params.get('hostPlatform');
|
||||||
const hostVersion = params.get('hostVersion');
|
const hostVersion = params.get('hostVersion');
|
||||||
const hostCapabilities = (params.get('hostCapabilities') ?? '')
|
const queryHostCapabilities = (params.get('hostCapabilities') ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((capability) => capability.trim())
|
.map((capability) => capability.trim())
|
||||||
.filter(isHostBridgeCapability);
|
.filter(isHostBridgeCapability);
|
||||||
@@ -180,6 +229,10 @@ export function resolveHostRuntime(
|
|||||||
const wxBridge = resolveWxBridge(context);
|
const wxBridge = resolveWxBridge(context);
|
||||||
const tauriBridge = resolveTauriBridge(context);
|
const tauriBridge = resolveTauriBridge(context);
|
||||||
const reactNativeWebView = resolveReactNativeWebView(context);
|
const reactNativeWebView = resolveReactNativeWebView(context);
|
||||||
|
const nativeHostCapabilities = mergeHostCapabilities(
|
||||||
|
queryHostCapabilities,
|
||||||
|
cachedNativeHostRuntime?.capabilities,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
clientRuntime === 'wechat_mini_program' ||
|
clientRuntime === 'wechat_mini_program' ||
|
||||||
@@ -194,7 +247,7 @@ export function resolveHostRuntime(
|
|||||||
hostShell,
|
hostShell,
|
||||||
hostPlatform,
|
hostPlatform,
|
||||||
hostVersion,
|
hostVersion,
|
||||||
hostCapabilities,
|
hostCapabilities: queryHostCapabilities,
|
||||||
miniProgramEnv,
|
miniProgramEnv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -212,7 +265,7 @@ export function resolveHostRuntime(
|
|||||||
hostShell,
|
hostShell,
|
||||||
hostPlatform,
|
hostPlatform,
|
||||||
hostVersion,
|
hostVersion,
|
||||||
hostCapabilities,
|
hostCapabilities: nativeHostCapabilities,
|
||||||
miniProgramEnv,
|
miniProgramEnv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -224,7 +277,7 @@ export function resolveHostRuntime(
|
|||||||
hostShell,
|
hostShell,
|
||||||
hostPlatform,
|
hostPlatform,
|
||||||
hostVersion,
|
hostVersion,
|
||||||
hostCapabilities,
|
hostCapabilities: queryHostCapabilities,
|
||||||
miniProgramEnv,
|
miniProgramEnv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -247,7 +300,11 @@ export function canUseNativeHostCapability(
|
|||||||
capability: HostBridgeCapability,
|
capability: HostBridgeCapability,
|
||||||
context: HostRuntimeContext = {},
|
context: HostRuntimeContext = {},
|
||||||
) {
|
) {
|
||||||
return runtimeHasNativeCapability(capability, context);
|
const runtime = getHostRuntime(context);
|
||||||
|
return (
|
||||||
|
runtime.kind === 'native_app' &&
|
||||||
|
runtime.hostCapabilities.includes(capability)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadWechatMiniProgramBridge(
|
export function loadWechatMiniProgramBridge(
|
||||||
@@ -550,13 +607,49 @@ export function postWechatMiniProgramMessage(message: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getNativeAppHostRuntime() {
|
export async function getNativeAppHostRuntime() {
|
||||||
if (!canUseNativeHostCapability('host.getRuntime')) {
|
const runtime = getHostRuntime();
|
||||||
|
if (
|
||||||
|
runtime.kind !== 'native_app' ||
|
||||||
|
(!runtime.hostCapabilities.includes('host.getRuntime') &&
|
||||||
|
!canUseNativeAppHostBridge())
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await requestNativeAppHostBridge<HostBridgeRuntimeResult>(
|
try {
|
||||||
'host.getRuntime',
|
return updateCachedNativeHostRuntime(
|
||||||
);
|
await requestNativeAppHostBridge<HostBridgeRuntimeResult>(
|
||||||
|
'host.getRuntime',
|
||||||
|
undefined,
|
||||||
|
{ timeoutMs: HOST_RUNTIME_REFRESH_TIMEOUT_MS },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnsupportedHostBridgeError(error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshNativeAppHostRuntime() {
|
||||||
|
if (nativeHostRuntimeRefreshPromise) {
|
||||||
|
return nativeHostRuntimeRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
nativeHostRuntimeRefreshPromise = getNativeAppHostRuntime()
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(() => {
|
||||||
|
nativeHostRuntimeRefreshPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return nativeHostRuntimeRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetHostRuntimeCacheForTest() {
|
||||||
|
cachedNativeHostRuntime = null;
|
||||||
|
nativeHostRuntimeRefreshPromise = null;
|
||||||
|
hostRuntimeChangeListeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeHostClipboardText({
|
export async function writeHostClipboardText({
|
||||||
|
|||||||
Reference in New Issue
Block a user