收口原生能力可信边界

H5 原生能力只信任真实 host.getRuntime 回包

移除桌面壳未声明网络事件白名单

补齐原生壳能力门控测试和共享决策记录
This commit is contained in:
2026-06-21 18:08:02 +08:00
parent 0ecafa128c
commit ba000111d7
16 changed files with 336 additions and 69 deletions
+18 -1
View File
@@ -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}`);
@@ -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",
];
@@ -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` 已缓存的 capabilityquery 中的 `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`
+11 -11
View File
@@ -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,
+19 -3
View File
@@ -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();
+3
View File
@@ -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({
@@ -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<typeof setHostRuntimeCacheForTest>[0]['capabilities']) {
setHostRuntimeCacheForTest({
shell: 'tauri_desktop',
platform: 'linux',
hostVersion: '0.1.0',
bridgeVersion: 1,
capabilities,
});
}
function renderPanel(
overrides: Partial<
ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>
@@ -206,6 +219,7 @@ test('原生 App 宿主可用时上传按钮走 HostBridge 音频导入', async
'',
'/?clientRuntime=native_app&hostCapabilities=file.importAudio',
);
trustNativeHostRuntime(['file.importAudio']);
window.__TAURI__ = {
core: {
invoke: async <Result,>(
@@ -256,6 +270,7 @@ test('原生 App 宿主可用且当前音频为本地资产时可以导出音频
'',
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
);
trustNativeHostRuntime(['file.exportAudio']);
window.__TAURI__ = {
core: {
invoke: async <Result,>(
@@ -298,6 +313,7 @@ test('非本地音频资产或未声明能力时不显示导出入口', () => {
'',
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
);
trustNativeHostRuntime(['file.exportAudio']);
rerender(
<CreativeAudioInputPanel<TestAudioAsset>
title="敲击音效"
@@ -333,6 +349,7 @@ test('导出音频失败时提示错误', async () => {
'',
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
);
trustNativeHostRuntime(['file.exportAudio']);
window.__TAURI__ = {
core: {
invoke: async <Result,>(
@@ -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<typeof setHostRuntimeCacheForTest>[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),
@@ -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);
@@ -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<string, unknown>) => {
@@ -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);
@@ -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);
@@ -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<string, unknown>) => {
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,
'',
+144 -29
View File
@@ -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<string, unknown>) => {
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);
});
+16 -5
View File
@@ -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) {
+14 -1
View File
@@ -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),
+1 -11
View File
@@ -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) {