diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 509f1b77a..dbaba5999 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1483,6 +1483,9 @@ const sharedDesktopCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES', ); +const sharedDesktopEvents = sharedDesktopCapabilities.filter((capability) => + sharedEvents.includes(capability), +); const sharedHostBridgeProtocol = extractTsStringConst( sharedContractSource, 'HOST_BRIDGE_PROTOCOL', @@ -1887,7 +1890,21 @@ assertSameList( ); assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); -assertSameList(desktopEvents, sharedEvents, 'desktop shell HostBridge event whitelist'); +assertSameList( + desktopEvents, + sharedDesktopEvents, + 'desktop shell HostBridge event whitelist', +); +if (sharedDesktopCapabilities.includes('network.statusChanged')) { + throw new Error( + 'desktop shell must not declare network.statusChanged until Rust owns a real event source', + ); +} +if (desktopEvents.includes('network.statusChanged')) { + throw new Error( + 'desktop shell event whitelist must not include network.statusChanged until Rust owns a real event source', + ); +} for (const eventName of sharedEvents) { if (!sharedCapabilities.includes(eventName)) { throw new Error(`shared HostBridge event must also be a capability: ${eventName}`); diff --git a/apps/desktop-shell/src-tauri/src/shell/events.rs b/apps/desktop-shell/src-tauri/src/shell/events.rs index 149b5b1e1..db93ec6d1 100644 --- a/apps/desktop-shell/src-tauri/src/shell/events.rs +++ b/apps/desktop-shell/src-tauri/src/shell/events.rs @@ -1,9 +1,8 @@ use crate::host_bridge::protocol::{HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION}; use serde_json::{json, Value}; -const HOST_BRIDGE_EVENTS: [&str; 4] = [ +const HOST_BRIDGE_EVENTS: [&str; 3] = [ "app.lifecycle", - "network.statusChanged", "navigation.canGoBack", "file.imageDropped", ]; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 919daca6a..9e0895670 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3216,3 +3216,10 @@ - 决策:H5 发布分享弹窗必须按 `hostShell` 展示分享动作文案:`expo_mobile` 继续显示“系统分享 / 已打开 / 分享失败”,`tauri_desktop` 显示“复制分享文案 / 已复制 / 复制失败”;根级原生壳门禁反查 `PublishShareModal` 源码和测试,防止桌面剪贴板动作再次被包装成系统分享面板。 - 影响范围:`src/components/common/PublishShareModal.tsx`、`src/components/common/PublishShareModal.test.tsx`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-21 H5 原生能力必须来自真实 runtime 回包 + +- 背景:Expo / Tauri 壳会把 `clientRuntime`、`hostShell` 和 `hostCapabilities` 写入 H5 URL,用于保留宿主上下文和路由状态。如果 H5 在 `host.getRuntime` 异步回包前把 URL query 中的 `hostCapabilities` 作为真实能力来源,深链旧参数或伪造 query 会让首屏短暂展示或触发原生动作。 +- 决策:H5 仍可用 URL query 判断宿主类型和保留上下文,但 `canUseNativeHostCapability` 只能信任真实 native bridge 存在且 `host.getRuntime` 已缓存的 capability;query 中的 `hostCapabilities` 不再参与能力门控。`host.getRuntime` 刷新只依赖真实 Expo WebView / Tauri invoke 注入,不依赖 query capability。桌面 Tauri 事件白名单必须等于桌面 capability 中已声明的事件子集,不得包含未声明的 `network.statusChanged`。 +- 影响范围:`src/services/host-bridge/hostBridge.ts`、H5 HostBridge 消费测试、`apps/desktop-shell/src-tauri/src/shell/events.rs`、`scripts/check-native-shells.mjs`。 +- 验证方式:`npm run test -- src/services/host-bridge/hostBridge.test.ts src/services/runtimeAudioFeedback.test.ts src/App.test.tsx src/components/common/CreativeAudioInputPanel.test.tsx src/components/common/PublishShareModal.test.tsx src/components/platform-entry/platformHostBridgeSync.test.ts`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml shell::events`、`npm run check:native-shells`。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 1d3dfbc7d..2c2634564 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2096,25 +2096,25 @@ function assertNativeShellCapabilityPlan() { 'wechat mini program runtime capability profile', ); + const desktopEventWhitelist = extractRustStringArray(desktopEventSource, 'HOST_BRIDGE_EVENTS'); + const sharedDesktopEvents = sharedDesktopCapabilities.filter((capability) => + sharedEvents.includes(capability), + ); assertSameList( - extractRustStringArray(desktopEventSource, 'HOST_BRIDGE_EVENTS'), - sharedEvents, + desktopEventWhitelist, + sharedDesktopEvents, 'desktop shell runtime event whitelist', ); - - const desktopEventWhitelist = extractRustStringArray(desktopEventSource, 'HOST_BRIDGE_EVENTS'); - for (const eventName of sharedDesktopCapabilities.filter((capability) => - sharedEvents.includes(capability), - )) { - if (!desktopEventWhitelist.includes(eventName)) { - throw new Error(`desktop HostBridge event must be in Rust event whitelist: ${eventName}`); - } - } if (sharedDesktopCapabilities.includes('network.statusChanged')) { throw new Error( 'desktop shell must not declare network.statusChanged until Rust owns a real event source', ); } + if (desktopEventWhitelist.includes('network.statusChanged')) { + throw new Error( + 'desktop shell event whitelist must not include network.statusChanged until Rust owns a real event source', + ); + } assertSameList( desktopCapabilities, diff --git a/src/App.test.tsx b/src/App.test.tsx index c56395a8e..723bad467 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -183,8 +183,21 @@ describe('App title sync', () => { ); window.__TAURI__ = { core: { - invoke: vi.fn(async () => { - throw new Error('unsupported'); + 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.events', 'navigation.canGoBack'], + }, + }; }), }, }; @@ -196,8 +209,11 @@ describe('App title sync', () => { ); expect(window.location.pathname).toBe('/creation/puzzle'); expect(window.location.search).toBe( - '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events%2Cnavigation.canGoBack', + '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack', ); + await waitFor(() => { + expect(canUseNativeHostCapability('navigation.canGoBack')).toBe(true); + }); await act(async () => { window.history.back(); diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index eafccf778..27e9eb5e4 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -87,6 +87,7 @@ const hostBridgeMocks = vi.hoisted(() => ({ hostPlatform: null as string | null, hostVersion: null as string | null, hostCapabilities: [], + nativeRuntimeTrusted: false, miniProgramEnv: null as string | null, })), requestHostLogin: vi.fn(), @@ -195,6 +196,7 @@ beforeEach(() => { hostPlatform: null, hostVersion: null, hostCapabilities: [], + nativeRuntimeTrusted: false, miniProgramEnv: null, }); hostBridgeMocks.requestHostLogin.mockResolvedValue(true); @@ -486,6 +488,7 @@ test('auth gate uses mini program auth bridge instead of opening login modal in hostPlatform: null, hostVersion: null, hostCapabilities: [], + nativeRuntimeTrusted: false, miniProgramEnv: null, }); authMocks.getAuthLoginOptions.mockResolvedValue({ diff --git a/src/components/common/CreativeAudioInputPanel.test.tsx b/src/components/common/CreativeAudioInputPanel.test.tsx index 9c5a1b639..f33c724c6 100644 --- a/src/components/common/CreativeAudioInputPanel.test.tsx +++ b/src/components/common/CreativeAudioInputPanel.test.tsx @@ -4,7 +4,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ComponentProps } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; -import { resetHostRuntimeCacheForTest } from '../../services/host-bridge/hostBridge'; +import { + resetHostRuntimeCacheForTest, + setHostRuntimeCacheForTest, +} from '../../services/host-bridge/hostBridge'; import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge'; import type { CreativeAudioAsset } from './creativeAudioFileAsset'; import { CreativeAudioInputPanel } from './CreativeAudioInputPanel'; @@ -57,6 +60,16 @@ function buildExportableAsset( }; } +function trustNativeHostRuntime(capabilities: Parameters[0]['capabilities']) { + setHostRuntimeCacheForTest({ + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities, + }); +} + function renderPanel( overrides: Partial< ComponentProps> @@ -206,6 +219,7 @@ test('原生 App 宿主可用时上传按钮走 HostBridge 音频导入', async '', '/?clientRuntime=native_app&hostCapabilities=file.importAudio', ); + trustNativeHostRuntime(['file.importAudio']); window.__TAURI__ = { core: { invoke: async ( @@ -256,6 +270,7 @@ test('原生 App 宿主可用且当前音频为本地资产时可以导出音频 '', '/?clientRuntime=native_app&hostCapabilities=file.exportAudio', ); + trustNativeHostRuntime(['file.exportAudio']); window.__TAURI__ = { core: { invoke: async ( @@ -298,6 +313,7 @@ test('非本地音频资产或未声明能力时不显示导出入口', () => { '', '/?clientRuntime=native_app&hostCapabilities=file.exportAudio', ); + trustNativeHostRuntime(['file.exportAudio']); rerender( title="敲击音效" @@ -333,6 +349,7 @@ test('导出音频失败时提示错误', async () => { '', '/?clientRuntime=native_app&hostCapabilities=file.exportAudio', ); + trustNativeHostRuntime(['file.exportAudio']); window.__TAURI__ = { core: { invoke: async ( diff --git a/src/components/common/PublishShareModal.test.tsx b/src/components/common/PublishShareModal.test.tsx index 6445dd578..1d47fff98 100644 --- a/src/components/common/PublishShareModal.test.tsx +++ b/src/components/common/PublishShareModal.test.tsx @@ -11,6 +11,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { HOST_BRIDGE_PUBLIC_WEB_ORIGIN } from '../../../packages/shared/src/contracts/hostBridge'; import * as clipboardService from '../../services/clipboard'; +import { + resetHostRuntimeCacheForTest, + setHostRuntimeCacheForTest, +} from '../../services/host-bridge/hostBridge'; import { PublishShareModal } from './PublishShareModal'; import { buildMiniProgramPublishSharePath, @@ -39,6 +43,7 @@ afterEach(() => { delete window.ReactNativeWebView; delete window.__TAURI__; window.wx = undefined; + resetHostRuntimeCacheForTest(); }); function asTauriInvoke( @@ -52,6 +57,19 @@ function asTauriInvoke( }; } +function trustNativeHostRuntime( + shell: 'expo_mobile' | 'tauri_desktop', + capabilities: Parameters[0]['capabilities'], +) { + setHostRuntimeCacheForTest({ + shell, + platform: shell === 'expo_mobile' ? 'ios' : 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities, + }); +} + describe('PublishShareModal', () => { test('builds the publish share text with title, code and public url', () => { const text = buildPublishShareText(payload); @@ -180,6 +198,7 @@ describe('PublishShareModal', () => { '', '/?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open', ); + trustNativeHostRuntime('tauri_desktop', ['share.open']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -222,6 +241,7 @@ describe('PublishShareModal', () => { '', '/?clientRuntime=native_app&hostShell=expo_mobile&hostCapabilities=share.open', ); + trustNativeHostRuntime('expo_mobile', ['share.open']); const postMessage = vi.fn((rawMessage: string) => { const request = JSON.parse(rawMessage) as { id: string }; window.dispatchEvent( @@ -328,6 +348,7 @@ describe('PublishShareModal', () => { '', '/?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=file.exportImage', ); + trustNativeHostRuntime('tauri_desktop', ['file.exportImage']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), diff --git a/src/components/platform-entry/PlatformProfilePrimitives.test.tsx b/src/components/platform-entry/PlatformProfilePrimitives.test.tsx index 2e1ec16d3..f8ad7d31a 100644 --- a/src/components/platform-entry/PlatformProfilePrimitives.test.tsx +++ b/src/components/platform-entry/PlatformProfilePrimitives.test.tsx @@ -26,6 +26,7 @@ vi.mock('../../services/host-bridge/hostBridge', () => ({ hostPlatform: null, hostVersion: null, hostCapabilities: [], + nativeRuntimeTrusted: false, miniProgramEnv: null, })), openHostExternalUrl: vi.fn(async () => false), @@ -49,6 +50,7 @@ beforeEach(() => { hostPlatform: null, hostVersion: null, hostCapabilities: [], + nativeRuntimeTrusted: false, miniProgramEnv: null, }); vi.mocked(openHostExternalUrl).mockResolvedValue(false); @@ -132,6 +134,7 @@ describe('PlatformProfilePrimitives', () => { hostPlatform: 'ios', hostVersion: '0.1.0', hostCapabilities: ['app.openExternalUrl'], + nativeRuntimeTrusted: true, miniProgramEnv: null, }); vi.mocked(openHostExternalUrl).mockResolvedValue(true); diff --git a/src/components/platform-entry/platformHostBridgeSync.test.ts b/src/components/platform-entry/platformHostBridgeSync.test.ts index 59ef4555d..be29642b4 100644 --- a/src/components/platform-entry/platformHostBridgeSync.test.ts +++ b/src/components/platform-entry/platformHostBridgeSync.test.ts @@ -6,7 +6,10 @@ import type { HostBridgeCapability, HostBridgeRequest, } from '../../../packages/shared/src/contracts/hostBridge'; -import { resetHostRuntimeCacheForTest } from '../../services/host-bridge/hostBridge'; +import { + resetHostRuntimeCacheForTest, + setHostRuntimeCacheForTest, +} from '../../services/host-bridge/hostBridge'; import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge'; import { sendPlatformHostDraftNotificationToHost, @@ -35,6 +38,16 @@ function nativeAppPath(capabilities: HostBridgeCapability[] = []) { return `/?${params.toString()}`; } +function trustNativeHostRuntime(capabilities: HostBridgeCapability[]) { + setHostRuntimeCacheForTest({ + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities, + }); +} + function installTauriHostBridgeRecorder() { const requests: HostBridgeRequest[] = []; const invoke = vi.fn(async (_command: string, args?: Record) => { @@ -73,6 +86,7 @@ describe('platformHostBridgeSync', () => { '', nativeAppPath(['notification.showLocal']), ); + trustNativeHostRuntime(['notification.showLocal']); const { invoke, requests } = installTauriHostBridgeRecorder(); await expect( @@ -100,6 +114,7 @@ describe('platformHostBridgeSync', () => { '', nativeAppPath(['app.setBadgeCount']), ); + trustNativeHostRuntime(['app.setBadgeCount']); const { invoke, requests } = installTauriHostBridgeRecorder(); await expect(syncPlatformHostDraftBadgeCountToHost(3)).resolves.toBe(true); diff --git a/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx b/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx index 00cc630ef..4a230f04f 100644 --- a/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx +++ b/src/components/rpg-creation-result/RpgCreationAssetDebugPanel.test.tsx @@ -20,6 +20,7 @@ vi.mock('../../services/host-bridge/hostBridge', () => ({ hostPlatform: null, hostVersion: null, hostCapabilities: [], + nativeRuntimeTrusted: false, miniProgramEnv: null, })), openHostExternalUrl: vi.fn(async () => false), @@ -39,6 +40,7 @@ beforeEach(() => { hostPlatform: null, hostVersion: null, hostCapabilities: [], + nativeRuntimeTrusted: false, miniProgramEnv: null, }); vi.mocked(openHostExternalUrl).mockResolvedValue(false); @@ -217,6 +219,7 @@ test('RPG asset debug panel 在原生 App 中通过宿主打开原图外链', as hostPlatform: 'linux', hostVersion: '0.1.0', hostCapabilities: ['app.openExternalUrl'], + nativeRuntimeTrusted: true, miniProgramEnv: null, }); vi.mocked(openHostExternalUrl).mockResolvedValue(true); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 2f5a1515d..6ae0089df 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -98,7 +98,10 @@ import { regenerateBabyObjectMatchDraftAssets, saveBabyObjectMatchDraft, } from '../../services/edutainment-baby-object'; -import { resetHostRuntimeCacheForTest } from '../../services/host-bridge/hostBridge'; +import { + resetHostRuntimeCacheForTest, + setHostRuntimeCacheForTest, +} from '../../services/host-bridge/hostBridge'; import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge'; import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; import { match3dCreationClient } from '../../services/match3d-creation'; @@ -315,11 +318,26 @@ function nativeAppPath(capabilities: HostBridgeCapability[] = []) { return `/?${params.toString()}`; } -function installTauriHostBridgeRecorder() { +function installTauriHostBridgeRecorder(capabilities: HostBridgeCapability[] = []) { const requests: HostBridgeRequest[] = []; const invoke = vi.fn(async (_command: string, args?: Record) => { const request = (args as { request: HostBridgeRequest }).request; requests.push(request); + if (request.method === 'host.getRuntime') { + 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', ...capabilities], + }, + }; + } return { bridge: 'GenarrativeHostBridge', version: 1, @@ -9199,7 +9217,17 @@ test('native app jump hop draft completion sends host notification and badge fro resolveCompile = resolve; }), ); - const { requests } = installTauriHostBridgeRecorder(); + const { requests } = installTauriHostBridgeRecorder([ + 'app.setBadgeCount', + 'notification.showLocal', + ]); + setHostRuntimeCacheForTest({ + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities: ['app.setBadgeCount', 'notification.showLocal'], + }); window.history.replaceState( null, '', @@ -9250,7 +9278,16 @@ test('native app jump hop draft completion sends host notification and badge fro test('native app opens child motion demo through host navigation bridge', async () => { const user = userEvent.setup(); - const { requests } = installTauriHostBridgeRecorder(); + const { requests } = installTauriHostBridgeRecorder([ + 'navigation.openNativePage', + ]); + setHostRuntimeCacheForTest({ + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities: ['navigation.openNativePage'], + }); window.history.replaceState( null, '', diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 3bd0db326..fff306421 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -44,6 +44,7 @@ import { scanHostQrCode, setHostAppBadgeCount, setHostAppTitle, + setHostRuntimeCacheForTest, setHostShareTarget, showHostLocalNotification, subscribeHostAppLifecycle, @@ -77,6 +78,16 @@ function nativeAppPath(capabilities: HostBridgeCapability[] = []) { return `/?${params.toString()}`; } +function trustNativeHostRuntime(capabilities: HostBridgeCapability[]) { + setHostRuntimeCacheForTest({ + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities, + }); +} + afterEach(() => { vi.restoreAllMocks(); window.history.replaceState(null, '', '/'); @@ -116,7 +127,8 @@ describe('hostBridge', () => { hostShell: 'expo_mobile', hostPlatform: 'ios', hostVersion: '0.1.0', - hostCapabilities: ['share.open', 'clipboard.writeText'], + hostCapabilities: [], + nativeRuntimeTrusted: false, }); expect( @@ -138,14 +150,24 @@ describe('hostBridge', () => { test('按宿主能力声明判断原生 App 能力是否可用', () => { expect( canUseNativeHostCapability('share.open', { + tauri: { + core: { + invoke: asTauriInvoke(vi.fn(async () => null)), + }, + }, location: { search: '?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl', }, }), - ).toBe(true); + ).toBe(false); expect( canUseNativeHostCapability('clipboard.writeText', { + tauri: { + core: { + invoke: asTauriInvoke(vi.fn(async () => null)), + }, + }, location: { search: '?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl', @@ -161,6 +183,64 @@ describe('hostBridge', () => { ).toBe(false); }); + test('原生 App URL query 不在真实宿主桥缺失时启用能力', () => { + expect( + resolveHostRuntime({ + location: { + search: + '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open,app.openExternalUrl', + }, + }), + ).toMatchObject({ + kind: 'native_app', + hostShell: 'tauri_desktop', + hostCapabilities: [], + nativeRuntimeTrusted: false, + }); + expect( + canUseNativeHostCapability('share.open', { + location: { + search: + '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open', + }, + }), + ).toBe(false); + }); + + test('原生 App URL query 不在 runtime 回包前启用真实宿主能力', () => { + expect( + resolveHostRuntime({ + tauri: { + core: { + invoke: asTauriInvoke(vi.fn(async () => null)), + }, + }, + location: { + search: + '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open,app.openExternalUrl', + }, + }), + ).toMatchObject({ + kind: 'native_app', + hostShell: 'tauri_desktop', + hostCapabilities: [], + nativeRuntimeTrusted: true, + }); + expect( + canUseNativeHostCapability('share.open', { + tauri: { + core: { + invoke: asTauriInvoke(vi.fn(async () => null)), + }, + }, + location: { + search: + '?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open', + }, + }), + ).toBe(false); + }); + test('订阅原生 App 生命周期事件并归一化 payload', () => { const listener = vi.fn(); window.history.replaceState( @@ -168,6 +248,7 @@ describe('hostBridge', () => { '', nativeAppPath(['host.events', 'app.lifecycle']), ); + trustNativeHostRuntime(['host.events', 'app.lifecycle']); window.ReactNativeWebView = { postMessage: vi.fn(), }; @@ -218,6 +299,7 @@ describe('hostBridge', () => { '', nativeAppPath(['host.events', 'navigation.canGoBack']), ); + trustNativeHostRuntime(['host.events', 'navigation.canGoBack']); const unsubscribe = subscribeHostNavigationCanGoBack(listener); @@ -299,6 +381,7 @@ describe('hostBridge', () => { '', nativeAppPath(['navigation.canGoBack']), ); + trustNativeHostRuntime(['navigation.canGoBack']); const unsubscribeWithoutEvents = subscribeHostNavigationCanGoBack(listener); window.dispatchEvent( new MessageEvent('message', { @@ -376,6 +459,7 @@ describe('hostBridge', () => { '', nativeAppPath(['network.statusChanged']), ); + trustNativeHostRuntime(['network.statusChanged']); const unsubscribeNetwork = subscribeHostNetworkStatusChange(networkListener); window.dispatchEvent( @@ -401,6 +485,7 @@ describe('hostBridge', () => { '', nativeAppPath(['navigation.canGoBack']), ); + trustNativeHostRuntime(['navigation.canGoBack']); const unsubscribeNavigation = subscribeHostNavigationCanGoBack(navigationListener); window.dispatchEvent( @@ -424,6 +509,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.imageDropped']), ); + trustNativeHostRuntime(['file.imageDropped']); const unsubscribeImageDrop = subscribeHostImageDrop(imageDropListener); window.dispatchEvent( @@ -552,6 +638,7 @@ describe('hostBridge', () => { '', nativeAppPath(['host.events', 'network.status', 'network.statusChanged']), ); + trustNativeHostRuntime(['host.events', 'network.status', 'network.statusChanged']); window.ReactNativeWebView = { postMessage: vi.fn(), }; @@ -620,6 +707,7 @@ describe('hostBridge', () => { '', nativeAppPath(['appearance.getColorScheme']), ); + trustNativeHostRuntime(['appearance.getColorScheme']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -957,6 +1045,7 @@ describe('hostBridge', () => { '', nativeAppPath(['share.setTarget']), ); + trustNativeHostRuntime(['share.setTarget']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -992,6 +1081,32 @@ describe('hostBridge', () => { }); test('原生 App 宿主通过 HostBridge 处理导航、登录和支付', async () => { + const capabilities: HostBridgeCapability[] = [ + 'host.getRuntime', + 'appearance.getColorScheme', + 'navigation.openNativePage', + 'auth.requestLogin', + 'payment.request', + 'clipboard.readText', + 'clipboard.writeText', + 'haptics.impact', + 'app.openExternalUrl', + 'app.reloadWebView', + 'app.setTitle', + 'app.setBadgeCount', + 'network.status', + 'share.open', + 'file.importText', + 'file.importDocument', + 'file.exportImage', + 'file.importImage', + 'file.captureImage', + 'scanner.scanQrCode', + 'file.importAudio', + 'file.exportAudio', + 'file.imageDropped', + 'notification.showLocal', + ]; const invoke = vi.fn(async (_command: string, args?: Record) => { const request = (args as { request: { id: string; method: string } }) .request; @@ -1007,7 +1122,7 @@ describe('hostBridge', () => { platform: 'linux', hostVersion: '0.1.0', bridgeVersion: 1, - capabilities: ['host.getRuntime'], + capabilities, } : request.method === 'appearance.getColorScheme' ? { colorScheme: 'dark' } @@ -1074,33 +1189,9 @@ describe('hostBridge', () => { window.history.replaceState( null, '', - nativeAppPath([ - 'host.getRuntime', - 'appearance.getColorScheme', - 'navigation.openNativePage', - 'auth.requestLogin', - 'payment.request', - 'clipboard.readText', - 'clipboard.writeText', - 'haptics.impact', - 'app.openExternalUrl', - 'app.reloadWebView', - 'app.setTitle', - 'app.setBadgeCount', - 'network.status', - 'share.open', - 'file.importText', - 'file.importDocument', - 'file.exportImage', - 'file.importImage', - 'file.captureImage', - 'scanner.scanQrCode', - 'file.importAudio', - 'file.exportAudio', - 'file.imageDropped', - 'notification.showLocal', - ]), + nativeAppPath(capabilities), ); + trustNativeHostRuntime(capabilities); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -1397,6 +1488,7 @@ describe('hostBridge', () => { '', nativeAppPath(['navigation.openNativePage']), ); + trustNativeHostRuntime(['navigation.openNativePage']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -1465,6 +1557,7 @@ describe('hostBridge', () => { '', nativeAppPath(['clipboard.writeText']), ); + trustNativeHostRuntime(['clipboard.writeText']); await expect( writeHostClipboardText({ text: 'a'.repeat(100010) }), @@ -1501,6 +1594,7 @@ describe('hostBridge', () => { '', nativeAppPath(['haptics.impact']), ); + trustNativeHostRuntime(['haptics.impact']); await expect(requestHostHapticsImpact()).resolves.toBe(true); await expect( @@ -1546,6 +1640,7 @@ describe('hostBridge', () => { '', nativeAppPath(['clipboard.readText']), ); + trustNativeHostRuntime(['clipboard.readText']); await expect(readHostClipboardText()).resolves.toBe(false); await expect(readHostClipboardText()).resolves.toEqual({ @@ -1693,6 +1788,7 @@ describe('hostBridge', () => { '', nativeAppPath(['notification.showLocal']), ); + trustNativeHostRuntime(['notification.showLocal']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -1722,6 +1818,7 @@ describe('hostBridge', () => { '', nativeAppPath(['notification.showLocal']), ); + trustNativeHostRuntime(['notification.showLocal']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -1760,6 +1857,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.exportText']), ); + trustNativeHostRuntime(['file.exportText']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -1815,6 +1913,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.exportText']), ); + trustNativeHostRuntime(['file.exportText']); window.__TAURI__ = { core: { invoke: asTauriInvoke( @@ -1865,6 +1964,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.exportImage']), ); + trustNativeHostRuntime(['file.exportImage']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -1921,6 +2021,7 @@ describe('hostBridge', () => { '', nativeAppPath(['host.events', 'file.importImage', 'file.imageDropped']), ); + trustNativeHostRuntime(['host.events', 'file.importImage', 'file.imageDropped']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2001,6 +2102,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importText']), ); + trustNativeHostRuntime(['file.importText']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2048,6 +2150,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importDocument']), ); + trustNativeHostRuntime(['file.importDocument']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2095,6 +2198,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importAudio']), ); + trustNativeHostRuntime(['file.importAudio']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2139,6 +2243,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.exportAudio']), ); + trustNativeHostRuntime(['file.exportAudio']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2201,6 +2306,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importAudio']), ); + trustNativeHostRuntime(['file.importAudio']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2231,6 +2337,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importText']), ); + trustNativeHostRuntime(['file.importText']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2261,6 +2368,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importDocument']), ); + trustNativeHostRuntime(['file.importDocument']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2294,6 +2402,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importDocument']), ); + trustNativeHostRuntime(['file.importDocument']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2327,6 +2436,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importAudio']), ); + trustNativeHostRuntime(['file.importAudio']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2357,6 +2467,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importImage']), ); + trustNativeHostRuntime(['file.importImage']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2387,6 +2498,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.captureImage']), ); + trustNativeHostRuntime(['file.captureImage']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2417,6 +2529,7 @@ describe('hostBridge', () => { '', nativeAppPath(['scanner.scanQrCode']), ); + trustNativeHostRuntime(['scanner.scanQrCode']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2447,6 +2560,7 @@ describe('hostBridge', () => { '', nativeAppPath(['scanner.scanQrCode']), ); + trustNativeHostRuntime(['scanner.scanQrCode']); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), @@ -2462,6 +2576,7 @@ describe('hostBridge', () => { '', nativeAppPath(['file.importImage']), ); + trustNativeHostRuntime(['file.importImage']); await expect(captureHostImageFile()).resolves.toBe(false); }); diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 6daa92ac8..1640c6a1b 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -87,6 +87,7 @@ export type HostRuntimeSnapshot = { hostPlatform: string | null; hostVersion: string | null; hostCapabilities: HostBridgeCapability[]; + nativeRuntimeTrusted: boolean; miniProgramEnv: string | null; }; @@ -348,8 +349,12 @@ export function resolveHostRuntime( const wxBridge = resolveWxBridge(context); const tauriBridge = resolveTauriBridge(context); const reactNativeWebView = resolveReactNativeWebView(context); + const nativeBridgeAvailable = + typeof tauriBridge?.core?.invoke === 'function' || + typeof reactNativeWebView?.postMessage === 'function'; + const nativeRuntimeTrusted = + nativeBridgeAvailable || cachedNativeHostRuntime !== null; const nativeHostCapabilities = mergeHostCapabilities( - queryHostCapabilities, cachedNativeHostRuntime?.capabilities, ); @@ -368,6 +373,7 @@ export function resolveHostRuntime( hostPlatform, hostVersion, hostCapabilities: queryHostCapabilities, + nativeRuntimeTrusted: false, miniProgramEnv, }; } @@ -375,8 +381,7 @@ export function resolveHostRuntime( if ( clientRuntime === HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime || clientType === HOST_BRIDGE_NATIVE_APP_QUERY.clientType || - typeof tauriBridge?.core?.invoke === 'function' || - typeof reactNativeWebView?.postMessage === 'function' + nativeBridgeAvailable ) { return { kind: 'native_app', @@ -386,6 +391,7 @@ export function resolveHostRuntime( hostPlatform, hostVersion, hostCapabilities: nativeHostCapabilities, + nativeRuntimeTrusted, miniProgramEnv, }; } @@ -398,6 +404,7 @@ export function resolveHostRuntime( hostPlatform, hostVersion, hostCapabilities: queryHostCapabilities, + nativeRuntimeTrusted: false, miniProgramEnv, }; } @@ -423,6 +430,7 @@ export function canUseNativeHostCapability( const runtime = getHostRuntime(context); return ( runtime.kind === 'native_app' && + runtime.nativeRuntimeTrusted && runtime.hostCapabilities.includes(capability) ); } @@ -826,8 +834,7 @@ export async function getNativeAppHostRuntime() { const runtime = getHostRuntime(); if ( runtime.kind !== 'native_app' || - (!runtime.hostCapabilities.includes('host.getRuntime') && - !canUseNativeAppHostBridge()) + !canUseNativeAppHostBridge() ) { return null; } @@ -868,6 +875,10 @@ export function resetHostRuntimeCacheForTest() { hostRuntimeChangeListeners.clear(); } +export function setHostRuntimeCacheForTest(runtime: HostBridgeRuntimeResult) { + return updateCachedNativeHostRuntime(runtime); +} + export async function writeHostClipboardText({ text, }: HostClipboardWriteTextRequest) { diff --git a/src/services/runtimeAudioFeedback.test.ts b/src/services/runtimeAudioFeedback.test.ts index 674545633..6ad484b8b 100644 --- a/src/services/runtimeAudioFeedback.test.ts +++ b/src/services/runtimeAudioFeedback.test.ts @@ -2,6 +2,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + resetHostRuntimeCacheForTest, + setHostRuntimeCacheForTest, +} from './host-bridge/hostBridge'; import { resetNativeAppHostBridgeForTest } from './host-bridge/nativeAppHostBridge'; import { triggerRuntimeClickFeedback, @@ -31,10 +35,11 @@ afterEach(() => { delete window.ReactNativeWebView; delete window.__TAURI__; resetNativeAppHostBridgeForTest(); + resetHostRuntimeCacheForTest(); }); describe('runtimeAudioFeedback', () => { - test('普通浏览器点击反馈同步使用 vibration fallback', () => { + test('普通浏览器点击反馈使用 vibration fallback', async () => { const vibrate = vi.fn(); Object.defineProperty(navigator, 'vibrate', { configurable: true, @@ -42,6 +47,7 @@ describe('runtimeAudioFeedback', () => { }); triggerRuntimeImpactFeedback('light', 12); + await waitForNextTask(); expect(vibrate).toHaveBeenCalledWith([12]); }); @@ -69,6 +75,13 @@ describe('runtimeAudioFeedback', () => { '', '/?clientRuntime=native_app&hostCapabilities=haptics.impact', ); + setHostRuntimeCacheForTest({ + shell: 'tauri_desktop', + platform: 'linux', + hostVersion: '0.1.0', + bridgeVersion: 1, + capabilities: ['haptics.impact'], + }); window.__TAURI__ = { core: { invoke: asTauriInvoke(invoke), diff --git a/src/services/runtimeAudioFeedback.ts b/src/services/runtimeAudioFeedback.ts index 4445ccc22..638b3b2e8 100644 --- a/src/services/runtimeAudioFeedback.ts +++ b/src/services/runtimeAudioFeedback.ts @@ -1,7 +1,4 @@ -import { - isNativeAppRuntime, - requestHostHapticsImpact, -} from './host-bridge/hostBridge'; +import { requestHostHapticsImpact } from './host-bridge/hostBridge'; export const DEFAULT_RUNTIME_CLICK_SOUND_SRC = '/audio/ui-click-soft.wav'; export const DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC = @@ -85,13 +82,6 @@ export function triggerRuntimeImpactFeedback( style: 'light' | 'medium' | 'heavy' = 'light', fallbackVibrationPatternMs?: number, ) { - if (!isNativeAppRuntime()) { - if (fallbackVibrationPatternMs !== undefined) { - triggerBrowserRuntimeVibration(fallbackVibrationPatternMs); - } - return; - } - void requestHostHapticsImpact({ style }) .then((handled) => { if (!handled && fallbackVibrationPatternMs !== undefined) {