From 38ed2227d31ddf1d2a0f90e07fdf9acc76658c73 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 00:48:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E5=AE=BF=E4=B8=BB=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=90=AF=E7=94=A8=E5=8E=9F=E7=94=9F=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HostBridge 契约沉淀 method 与 capability 白名单 H5 解析 hostCapabilities 并按能力调用原生桥 发布分享弹窗仅在声明 share.open 时显示系统分享 补充能力声明测试和宿主壳文档 --- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 4 +- .../shared/src/contracts/hostBridge.test.ts | 8 +++ packages/shared/src/contracts/hostBridge.ts | 47 ++++++++----- src/components/auth/AuthGate.test.tsx | 12 ++++ .../common/PublishShareModal.test.tsx | 16 ++++- src/components/common/PublishShareModal.tsx | 3 +- .../PlatformProfilePrimitives.test.tsx | 3 + .../RpgCreationAssetDebugPanel.test.tsx | 3 + src/services/host-bridge/hostBridge.test.ts | 68 +++++++++++++++++-- src/services/host-bridge/hostBridge.ts | 54 +++++++++++++-- src/services/runtimeAudioFeedback.test.ts | 6 +- 13 files changed, 194 insertions(+), 35 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5890df21..a701e503 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -23,6 +23,7 @@ - 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面,且通过 `host.events` 注入 `navigation.canGoBack` 返回栈状态事件,`share.setTarget` / `share.open` 解析统一分享目标并调用 React Native 系统分享面板,发布分享弹窗在 native_app 中通过 `share.open` 提供“系统分享”动作,失败时保留复制链接回退路径;`file.exportText` 写入 Expo 缓存文本文件后交给系统分享 / 保存面板,成功只返回文件名和字节数,`haptics.impact` 通过 Expo Haptics 承接 H5 运行时点击反馈;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;H5 复制服务在 native_app 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板,失败后再回退浏览器复制路径;H5 运行时反馈在 native_app 中优先通过 `haptics.impact` 请求真实移动端触觉,宿主不可用或 unsupported 时回退浏览器 `navigator.vibrate`;H5 主站按当前平台阶段同步 `document.title` 并通过 `app.setTitle` 请求宿主窗口标题,Tauri 壳通过主窗口 API 同步非空窗口标题,Expo 移动壳不声明该能力时静默忽略;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,将 `navigation.openNativePage` 实现为 `https://app.genarrative.world` 同源 H5 route 的主窗口受控跳转,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板;桌面 `file.exportText` 通过 Tauri dialog 插件打开系统保存对话框并由 Rust 写入文本文件,但不把 dialog / fs 插件 command 直接暴露给 H5,成功只返回文件名和字节数,用户取消返回 `cancelled`;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 - 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade,`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `` 行为,宿主不可用或拒绝时回退浏览器外链。 - 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` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 3de9b0b6..0960d398 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -214,7 +214,7 @@ GameBridge 禁止: - HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape。 - 壳层只接受来自允许 origin / packaged asset 的消息。 - 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。 -- 能力按 `capabilities` 下发,H5 根据能力决定是否展示入口或走 fallback。 +- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;不能只凭 `native_app` 宿主类型假设能力可用。 - 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。 - Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。 - RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。 @@ -241,7 +241,7 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 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 返回键回退;其中 `share.setTarget` / `share.open` 会解析统一分享目标里的 `title`、`message`、`url`、`work`、`path` 或 `targetPath` 并调用 React Native 系统分享面板;发布分享弹窗在 `native_app` 中通过 `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`,只对声明能力展示入口或调用宿主能力;其中 `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 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 6de7d497..34e98d80 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -39,11 +39,11 @@ AI H5 sandbox ## 首批能力 -- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`。 +- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;未知能力会被丢弃,H5 业务只根据已声明能力展示入口或发起宿主请求。 - `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。 - `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。 - `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。 -- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗在 `native_app` 中展示“系统分享”,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,Tauri 桌面壳把分享文本写入系统剪贴板,宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。 +- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示“系统分享”,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,Tauri 桌面壳把分享文本写入系统剪贴板,宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。 - `openHostShareGrid()`:微信小程序九宫格切图页。 - `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。 - `requestHostHapticsImpact()`:原生 App 宿主的受控触觉反馈入口。Expo 移动壳通过 `haptics.impact` 调用 Expo Haptics;H5 运行时点击反馈在 `native_app` 中优先请求宿主触觉,宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 `navigator.vibrate`。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 05e13c5a..61167ec9 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { + isHostBridgeCapability, normalizeHostBridgeExportFileName, normalizeHostBridgeExternalUrl, } from './hostBridge'; @@ -42,4 +43,11 @@ describe('HostBridge shared contract helpers', () => { 120, ); }); + + test('识别 HostBridge 能力白名单', () => { + expect(isHostBridgeCapability('share.open')).toBe(true); + expect(isHostBridgeCapability('navigation.canGoBack')).toBe(true); + expect(isHostBridgeCapability('unknown.capability')).toBe(false); + expect(isHostBridgeCapability(null)).toBe(false); + }); }); diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 75913ef2..0d549717 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -13,23 +13,38 @@ export type NativeHostPlatform = | 'linux' | 'unknown'; -export type HostBridgeMethod = - | 'host.getRuntime' - | 'auth.requestLogin' - | 'payment.request' - | 'share.setTarget' - | 'share.open' - | 'navigation.openNativePage' - | 'app.openExternalUrl' - | 'app.setTitle' - | 'clipboard.writeText' - | 'file.exportText' - | 'haptics.impact'; +export const HOST_BRIDGE_METHODS = [ + 'host.getRuntime', + 'auth.requestLogin', + 'payment.request', + 'share.setTarget', + 'share.open', + 'navigation.openNativePage', + 'app.openExternalUrl', + 'app.setTitle', + 'clipboard.writeText', + 'file.exportText', + 'haptics.impact', +] as const; -export type HostBridgeCapability = - | HostBridgeMethod - | 'host.events' - | 'navigation.canGoBack'; +export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number]; + +export const HOST_BRIDGE_CAPABILITIES = [ + ...HOST_BRIDGE_METHODS, + 'host.events', + 'navigation.canGoBack', +] as const; + +export type HostBridgeCapability = (typeof HOST_BRIDGE_CAPABILITIES)[number]; + +export function isHostBridgeCapability( + value: unknown, +): value is HostBridgeCapability { + return ( + typeof value === 'string' && + HOST_BRIDGE_CAPABILITIES.includes(value as HostBridgeCapability) + ); +} export type HostBridgeRuntimeResult = { shell: NativeHostShell; diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 70a275c1..5cbd66d5 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -77,6 +77,10 @@ const hostBridgeMocks = vi.hoisted(() => ({ kind: 'browser', clientType: null as string | null, clientRuntime: null as string | null, + hostShell: null as string | null, + hostPlatform: null as string | null, + hostVersion: null as string | null, + hostCapabilities: [], miniProgramEnv: null as string | null, })), requestHostLogin: vi.fn(), @@ -178,6 +182,10 @@ beforeEach(() => { kind: 'browser', clientType: null, clientRuntime: null, + hostShell: null, + hostPlatform: null, + hostVersion: null, + hostCapabilities: [], miniProgramEnv: null, }); hostBridgeMocks.requestHostLogin.mockResolvedValue(true); @@ -463,6 +471,10 @@ test('auth gate uses mini program auth bridge instead of opening login modal in kind: 'wechat_mini_program', clientType: null, clientRuntime: 'wechat_mini_program', + hostShell: null, + hostPlatform: null, + hostVersion: null, + hostCapabilities: [], miniProgramEnv: null, }); authMocks.getAuthLoginOptions.mockResolvedValue({ diff --git a/src/components/common/PublishShareModal.test.tsx b/src/components/common/PublishShareModal.test.tsx index dd457d35..326c8848 100644 --- a/src/components/common/PublishShareModal.test.tsx +++ b/src/components/common/PublishShareModal.test.tsx @@ -177,7 +177,7 @@ describe('PublishShareModal', () => { window.history.replaceState( null, '', - '/?clientRuntime=native_app&hostShell=tauri_desktop', + '/?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open', ); window.__TAURI__ = { core: { @@ -206,4 +206,18 @@ describe('PublishShareModal', () => { }), }); }); + + test('does not show native share action when native app shell does not declare share capability', () => { + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostShell=tauri_desktop', + ); + + render( {}} />); + + const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); + expect(within(dialog).queryByRole('button', { name: '系统分享' })).toBeNull(); + expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy(); + }); }); diff --git a/src/components/common/PublishShareModal.tsx b/src/components/common/PublishShareModal.tsx index 52d691a9..be8458a6 100644 --- a/src/components/common/PublishShareModal.tsx +++ b/src/components/common/PublishShareModal.tsx @@ -5,6 +5,7 @@ import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; import { copyTextToClipboard } from '../../services/clipboard'; import { canUseHostShareGrid, + canUseNativeHostCapability, getHostRuntime, openHostShare, openHostShareGrid, @@ -78,7 +79,7 @@ export function PublishShareModal({ const workTypeLabel = resolvePayloadWorkTypeLabel(payload); const showMiniProgramGridButton = canUseHostShareGrid() && Boolean(coverImageSrc); - const showNativeShareButton = hostRuntimeKind === 'native_app'; + const showNativeShareButton = canUseNativeHostCapability('share.open'); useEffect( () => () => { diff --git a/src/components/platform-entry/PlatformProfilePrimitives.test.tsx b/src/components/platform-entry/PlatformProfilePrimitives.test.tsx index fcb12f53..2e1ec16d 100644 --- a/src/components/platform-entry/PlatformProfilePrimitives.test.tsx +++ b/src/components/platform-entry/PlatformProfilePrimitives.test.tsx @@ -25,6 +25,7 @@ vi.mock('../../services/host-bridge/hostBridge', () => ({ hostShell: null, hostPlatform: null, hostVersion: null, + hostCapabilities: [], miniProgramEnv: null, })), openHostExternalUrl: vi.fn(async () => false), @@ -47,6 +48,7 @@ beforeEach(() => { hostShell: null, hostPlatform: null, hostVersion: null, + hostCapabilities: [], miniProgramEnv: null, }); vi.mocked(openHostExternalUrl).mockResolvedValue(false); @@ -129,6 +131,7 @@ describe('PlatformProfilePrimitives', () => { hostShell: 'expo_mobile', hostPlatform: 'ios', hostVersion: '0.1.0', + hostCapabilities: ['app.openExternalUrl'], miniProgramEnv: null, }); vi.mocked(openHostExternalUrl).mockResolvedValue(true); diff --git a/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx b/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx index 4c83dae2..00cc630e 100644 --- a/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx +++ b/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx @@ -19,6 +19,7 @@ vi.mock('../../services/host-bridge/hostBridge', () => ({ hostShell: null, hostPlatform: null, hostVersion: null, + hostCapabilities: [], miniProgramEnv: null, })), openHostExternalUrl: vi.fn(async () => false), @@ -37,6 +38,7 @@ beforeEach(() => { hostShell: null, hostPlatform: null, hostVersion: null, + hostCapabilities: [], miniProgramEnv: null, }); vi.mocked(openHostExternalUrl).mockResolvedValue(false); @@ -214,6 +216,7 @@ test('RPG asset debug panel 在原生 App 中通过宿主打开原图外链', as hostShell: 'tauri_desktop', hostPlatform: 'linux', hostVersion: '0.1.0', + hostCapabilities: ['app.openExternalUrl'], miniProgramEnv: null, }); vi.mocked(openHostExternalUrl).mockResolvedValue(true); diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 3daa1b45..ddeff3eb 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { HostBridgeCapability } from '../../../packages/shared/src/contracts/hostBridge'; import { canUseHostShareGrid, + canUseNativeHostCapability, exportHostTextFile, getHostRuntime, getNativeAppHostRuntime, @@ -37,6 +39,17 @@ function asTauriInvoke( }; } +function nativeAppPath(capabilities: HostBridgeCapability[] = []) { + const params = new URLSearchParams({ + clientRuntime: 'native_app', + hostShell: 'tauri_desktop', + }); + if (capabilities.length > 0) { + params.set('hostCapabilities', capabilities.join(',')); + } + return `/?${params.toString()}`; +} + afterEach(() => { vi.restoreAllMocks(); window.history.replaceState(null, '', '/'); @@ -67,7 +80,7 @@ describe('hostBridge', () => { resolveHostRuntime({ location: { search: - '?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0', + '?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0&hostCapabilities=share.open,unknown,clipboard.writeText', }, }), ).toMatchObject({ @@ -75,6 +88,7 @@ describe('hostBridge', () => { hostShell: 'expo_mobile', hostPlatform: 'ios', hostVersion: '0.1.0', + hostCapabilities: ['share.open', 'clipboard.writeText'], }); expect( @@ -93,6 +107,32 @@ describe('hostBridge', () => { ); }); + test('按宿主能力声明判断原生 App 能力是否可用', () => { + expect( + canUseNativeHostCapability('share.open', { + location: { + search: + '?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl', + }, + }), + ).toBe(true); + expect( + canUseNativeHostCapability('clipboard.writeText', { + location: { + search: + '?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl', + }, + }), + ).toBe(false); + expect( + canUseNativeHostCapability('share.open', { + location: { + search: '?clientRuntime=browser&hostCapabilities=share.open', + }, + }), + ).toBe(false); + }); + test('通过微信小程序原生页请求登录', async () => { const navigateTo = vi.fn((options) => { options.success?.(); @@ -292,7 +332,17 @@ describe('hostBridge', () => { window.history.replaceState( null, '', - '/?clientRuntime=native_app&hostShell=tauri_desktop', + nativeAppPath([ + 'host.getRuntime', + 'navigation.openNativePage', + 'auth.requestLogin', + 'payment.request', + 'clipboard.writeText', + 'haptics.impact', + 'app.openExternalUrl', + 'app.setTitle', + 'share.open', + ]), ); window.__TAURI__ = { core: { @@ -397,7 +447,7 @@ describe('hostBridge', () => { }); test('原生 App 宿主不支持能力时回退到 H5 路径', async () => { - window.history.replaceState(null, '', '/?clientRuntime=native_app'); + window.history.replaceState(null, '', nativeAppPath()); window.__TAURI__ = { core: { invoke: asTauriInvoke(vi.fn(async (_command: string, args?: Record) => { @@ -476,7 +526,11 @@ describe('hostBridge', () => { }; }, ); - window.history.replaceState(null, '', '/?clientRuntime=native_app'); + window.history.replaceState( + null, + '', + nativeAppPath(['file.exportText']), + ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -507,7 +561,11 @@ describe('hostBridge', () => { }); test('原生 App 宿主不支持文本导出时回退 H5', async () => { - window.history.replaceState(null, '', '/?clientRuntime=native_app'); + window.history.replaceState( + null, + '', + nativeAppPath(['file.exportText']), + ); window.__TAURI__ = { core: { invoke: asTauriInvoke( diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 04f5ed6e..f5e28ed1 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -2,12 +2,14 @@ import type { FileExportTextPayload, FileExportTextResult, HapticsImpactPayload, + HostBridgeCapability, HostBridgeMethod, HostBridgeRuntimeResult, OpenExternalUrlPayload, ShareOpenPayload, } from '../../../packages/shared/src/contracts/hostBridge'; import { + isHostBridgeCapability, normalizeHostBridgeExternalUrl, } from '../../../packages/shared/src/contracts/hostBridge'; import type { @@ -34,6 +36,7 @@ export type HostRuntimeSnapshot = { hostShell: string | null; hostPlatform: string | null; hostVersion: string | null; + hostCapabilities: HostBridgeCapability[]; miniProgramEnv: string | null; }; @@ -103,6 +106,17 @@ 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) { return ( context.location ?? (typeof window !== 'undefined' ? window.location : null) @@ -157,6 +171,10 @@ export function resolveHostRuntime( const hostShell = params.get('hostShell'); const hostPlatform = params.get('hostPlatform'); const hostVersion = params.get('hostVersion'); + const hostCapabilities = (params.get('hostCapabilities') ?? '') + .split(',') + .map((capability) => capability.trim()) + .filter(isHostBridgeCapability); const miniProgramEnv = params.get('miniProgramEnv'); const navigatorLike = resolveNavigator(context); const wxBridge = resolveWxBridge(context); @@ -176,6 +194,7 @@ export function resolveHostRuntime( hostShell, hostPlatform, hostVersion, + hostCapabilities, miniProgramEnv, }; } @@ -193,6 +212,7 @@ export function resolveHostRuntime( hostShell, hostPlatform, hostVersion, + hostCapabilities, miniProgramEnv, }; } @@ -204,6 +224,7 @@ export function resolveHostRuntime( hostShell, hostPlatform, hostVersion, + hostCapabilities, miniProgramEnv, }; } @@ -222,6 +243,13 @@ export function isNativeAppRuntime(context: HostRuntimeContext = {}) { return resolveHostRuntime(context).kind === 'native_app'; } +export function canUseNativeHostCapability( + capability: HostBridgeCapability, + context: HostRuntimeContext = {}, +) { + return runtimeHasNativeCapability(capability, context); +} + export function loadWechatMiniProgramBridge( errorMessage = '请在微信小程序内完成操作', ) { @@ -303,6 +331,9 @@ export async function navigateHostNativePage( ) { const runtime = getHostRuntime(); if (runtime.kind === 'native_app') { + if (!runtime.hostCapabilities.includes('navigation.openNativePage')) { + return false; + } const result = await requestNativeHostBoolean( 'navigation.openNativePage', { url }, @@ -325,6 +356,9 @@ export async function navigateHostNativePage( export async function requestHostLogin() { if (getHostRuntime().kind === 'native_app') { + if (!canUseNativeHostCapability('auth.requestLogin')) { + return false; + } return await requestNativeHostBoolean('auth.requestLogin'); } @@ -343,6 +377,9 @@ export async function requestHostPayment({ }: HostPaymentRequest) { const runtime = getHostRuntime(); if (runtime.kind === 'native_app') { + if (!runtime.hostCapabilities.includes('payment.request')) { + return false; + } return await requestNativeHostBoolean('payment.request', { payload, orderId, @@ -452,6 +489,9 @@ export async function openWechatMiniProgramShareGridPage( export function setHostShareTarget(message: unknown) { if (getHostRuntime().kind === 'native_app') { + if (!canUseNativeHostCapability('share.setTarget')) { + return false; + } void requestNativeAppHostBridge('share.setTarget', { target: message, }).catch(() => { @@ -475,7 +515,7 @@ export function setHostShareTarget(message: unknown) { } export async function openHostShare(params: HostShareOpenRequest) { - if (getHostRuntime().kind !== 'native_app') { + if (!canUseNativeHostCapability('share.open')) { return false; } @@ -487,7 +527,7 @@ export async function openHostShare(params: HostShareOpenRequest) { } export async function openHostExternalUrl({ url }: HostExternalUrlRequest) { - if (getHostRuntime().kind !== 'native_app') { + if (!canUseNativeHostCapability('app.openExternalUrl')) { return false; } @@ -510,7 +550,7 @@ export function postWechatMiniProgramMessage(message: unknown) { } export async function getNativeAppHostRuntime() { - if (getHostRuntime().kind !== 'native_app') { + if (!canUseNativeHostCapability('host.getRuntime')) { return null; } @@ -522,7 +562,7 @@ export async function getNativeAppHostRuntime() { export async function writeHostClipboardText({ text, }: HostClipboardWriteTextRequest) { - if (getHostRuntime().kind !== 'native_app') { + if (!canUseNativeHostCapability('clipboard.writeText')) { return false; } @@ -536,7 +576,7 @@ export async function writeHostClipboardText({ export async function exportHostTextFile( params: HostFileExportTextRequest, ) { - if (getHostRuntime().kind !== 'native_app') { + if (!canUseNativeHostCapability('file.exportText')) { return false; } @@ -557,7 +597,7 @@ export async function exportHostTextFile( export async function requestHostHapticsImpact( params: HostHapticsImpactRequest = {}, ) { - if (getHostRuntime().kind !== 'native_app') { + if (!canUseNativeHostCapability('haptics.impact')) { return false; } @@ -570,7 +610,7 @@ export async function requestHostHapticsImpact( export async function setHostAppTitle({ title }: HostAppTitleRequest) { const normalizedTitle = title.trim(); - if (!normalizedTitle || getHostRuntime().kind !== 'native_app') { + if (!normalizedTitle || !canUseNativeHostCapability('app.setTitle')) { return false; } diff --git a/src/services/runtimeAudioFeedback.test.ts b/src/services/runtimeAudioFeedback.test.ts index b516d08f..67454563 100644 --- a/src/services/runtimeAudioFeedback.test.ts +++ b/src/services/runtimeAudioFeedback.test.ts @@ -64,7 +64,11 @@ describe('runtimeAudioFeedback', () => { configurable: true, value: vibrate, }); - window.history.replaceState(null, '', '/?clientRuntime=native_app'); + window.history.replaceState( + null, + '', + '/?clientRuntime=native_app&hostCapabilities=haptics.impact', + ); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke),