收口原生能力可信边界
H5 原生能力只信任真实 host.getRuntime 回包 移除桌面壳未声明网络事件白名单 补齐原生壳能力门控测试和共享决策记录
This commit is contained in:
@@ -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` 已缓存的 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`。
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
'',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
Reference in New Issue
Block a user