新增 Expo 与 Tauri 原生宿主壳

新增 HostBridge 原生宿主契约和 H5 native_app transport

新增 Expo React Native 移动壳并收紧 WebView 外链边界

新增 Tauri 桌面壳并用 capability 收口受控命令

更新宿主壳方案、文档索引和共享记忆
This commit is contained in:
2026-06-17 21:39:34 +08:00
parent f92e791464
commit 9b7da18879
35 changed files with 16229 additions and 308 deletions

View File

@@ -5,6 +5,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import {
canUseHostShareGrid,
getHostRuntime,
getNativeAppHostRuntime,
isWechatMiniProgramWebViewRuntime,
navigateHostNativePage,
openHostShareGrid,
@@ -17,11 +18,26 @@ import {
resolveHostRuntime,
setHostShareTarget,
} from './hostBridge';
import { resetNativeAppHostBridgeForTest } from './nativeAppHostBridge';
function asTauriInvoke(
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
) {
return async function tauriInvoke<Result = unknown>(
command: string,
args?: Record<string, unknown>,
) {
return (await invoke(command, args)) as Result;
};
}
afterEach(() => {
vi.restoreAllMocks();
window.history.replaceState(null, '', '/');
window.wx = undefined;
delete window.ReactNativeWebView;
delete window.__TAURI__;
resetNativeAppHostBridgeForTest();
});
describe('hostBridge', () => {
@@ -43,7 +59,26 @@ describe('hostBridge', () => {
expect(
resolveHostRuntime({
location: { search: '?clientRuntime=native_app' },
location: {
search:
'?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0',
},
}),
).toMatchObject({
kind: 'native_app',
hostShell: 'expo_mobile',
hostPlatform: 'ios',
hostVersion: '0.1.0',
});
expect(
resolveHostRuntime({
tauri: {
core: {
invoke: asTauriInvoke(vi.fn(async () => null)),
},
},
location: { search: '' },
}).kind,
).toBe('native_app');
@@ -227,4 +262,100 @@ describe('hostBridge', () => {
expect(canUseHostShareGrid()).toBe(false);
expect(setHostShareTarget({ type: 'test' })).toBe(false);
});
test('原生 App 宿主通过 HostBridge 处理导航、登录和支付', async () => {
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string; method: string } })
.request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: request.method === 'host.getRuntime'
? {
shell: 'tauri_desktop',
platform: 'linux',
hostVersion: '0.1.0',
bridgeVersion: 1,
capabilities: ['host.getRuntime'],
}
: true,
};
});
window.history.replaceState(
null,
'',
'/?clientRuntime=native_app&hostShell=tauri_desktop',
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(navigateHostNativePage('/settings')).resolves.toBe(true);
await expect(requestHostLogin()).resolves.toBe(true);
await expect(
requestHostPayment({
payload: null,
orderId: 'order-1',
}),
).resolves.toBe(true);
await expect(getNativeAppHostRuntime()).resolves.toMatchObject({
shell: 'tauri_desktop',
platform: 'linux',
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'navigation.openNativePage',
payload: { url: '/settings' },
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'auth.requestLogin',
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'payment.request',
payload: {
payload: null,
orderId: 'order-1',
},
}),
});
});
test('原生 App 宿主不支持能力时回退到 H5 路径', async () => {
window.history.replaceState(null, '', '/?clientRuntime=native_app');
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(vi.fn(async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: false,
error: {
code: 'unsupported_method',
message: 'unsupported_method',
},
};
})),
},
};
await expect(navigateHostNativePage('/settings')).resolves.toBe(false);
await expect(requestHostLogin()).resolves.toBe(false);
await expect(
requestHostPayment({
payload: null,
orderId: 'order-1',
}),
).resolves.toBe(false);
});
});

View File

@@ -1,7 +1,12 @@
import type {
HostBridgeMethod,
HostBridgeRuntimeResult,
} from '../../../packages/shared/src/contracts/hostBridge';
import type {
WechatMiniProgramPayParams,
WechatMiniProgramVirtualPayParams,
} from '../../../packages/shared/src/contracts/runtime';
import { requestNativeAppHostBridge } from './nativeAppHostBridge';
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const MINI_PROGRAM_AUTH_PAGE_URL =
@@ -18,6 +23,9 @@ export type HostRuntimeSnapshot = {
kind: HostRuntimeKind;
clientType: string | null;
clientRuntime: string | null;
hostShell: string | null;
hostPlatform: string | null;
hostVersion: string | null;
miniProgramEnv: string | null;
};
@@ -25,6 +33,8 @@ export type HostRuntimeContext = {
location?: Pick<Location, 'search'> | null;
navigator?: Partial<Pick<Navigator, 'userAgent'>> | null;
wx?: Window['wx'] | null;
tauri?: Window['__TAURI__'] | null;
reactNativeWebView?: Window['ReactNativeWebView'] | null;
};
export type HostNativePageNavigationOptions = {
@@ -47,6 +57,28 @@ export type HostShareGridRequest = {
publicWorkCode: string;
};
function isUnsupportedHostBridgeError(error: unknown) {
return (
error instanceof Error &&
(error.name === 'unsupported_method' ||
error.name === 'unsupported_capability')
);
}
async function requestNativeHostBoolean(
method: HostBridgeMethod,
payload?: unknown,
) {
try {
return Boolean(await requestNativeAppHostBridge(method, payload));
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
function resolveLocation(context: HostRuntimeContext) {
return (
context.location ?? (typeof window !== 'undefined' ? window.location : null)
@@ -64,6 +96,19 @@ function resolveWxBridge(context: HostRuntimeContext) {
return context.wx ?? (typeof window !== 'undefined' ? window.wx : null);
}
function resolveTauriBridge(context: HostRuntimeContext) {
return (
context.tauri ?? (typeof window !== 'undefined' ? window.__TAURI__ : null)
);
}
function resolveReactNativeWebView(context: HostRuntimeContext) {
return (
context.reactNativeWebView ??
(typeof window !== 'undefined' ? window.ReactNativeWebView : null)
);
}
function hasWechatMiniProgramBridge(wxBridge: Window['wx'] | null | undefined) {
return Boolean(
wxBridge?.miniProgram?.postMessage || wxBridge?.miniProgram?.navigateTo,
@@ -85,9 +130,14 @@ export function resolveHostRuntime(
const params = new URLSearchParams(location?.search ?? '');
const clientType = params.get('clientType');
const clientRuntime = params.get('clientRuntime');
const hostShell = params.get('hostShell');
const hostPlatform = params.get('hostPlatform');
const hostVersion = params.get('hostVersion');
const miniProgramEnv = params.get('miniProgramEnv');
const navigatorLike = resolveNavigator(context);
const wxBridge = resolveWxBridge(context);
const tauriBridge = resolveTauriBridge(context);
const reactNativeWebView = resolveReactNativeWebView(context);
if (
clientRuntime === 'wechat_mini_program' ||
@@ -99,15 +149,26 @@ export function resolveHostRuntime(
kind: 'wechat_mini_program',
clientType,
clientRuntime,
hostShell,
hostPlatform,
hostVersion,
miniProgramEnv,
};
}
if (clientRuntime === 'native_app' || clientType === 'native_app') {
if (
clientRuntime === 'native_app' ||
clientType === 'native_app' ||
typeof tauriBridge?.core?.invoke === 'function' ||
typeof reactNativeWebView?.postMessage === 'function'
) {
return {
kind: 'native_app',
clientType,
clientRuntime,
hostShell,
hostPlatform,
hostVersion,
miniProgramEnv,
};
}
@@ -116,6 +177,9 @@ export function resolveHostRuntime(
kind: 'browser',
clientType,
clientRuntime,
hostShell,
hostPlatform,
hostVersion,
miniProgramEnv,
};
}
@@ -214,6 +278,17 @@ export async function navigateHostNativePage(
options: HostNativePageNavigationOptions = {},
) {
const runtime = getHostRuntime();
if (runtime.kind === 'native_app') {
const result = await requestNativeHostBoolean(
'navigation.openNativePage',
{ url },
);
if (result) {
options.beforeNavigate?.();
}
return result;
}
if (runtime.kind !== 'wechat_mini_program') {
return false;
}
@@ -225,6 +300,10 @@ export async function navigateHostNativePage(
}
export async function requestHostLogin() {
if (getHostRuntime().kind === 'native_app') {
return await requestNativeHostBoolean('auth.requestLogin');
}
return await navigateHostNativePage(MINI_PROGRAM_AUTH_PAGE_URL, {
errorMessage: '请在微信小程序内完成登录',
});
@@ -238,7 +317,15 @@ export async function requestHostPayment({
payload,
orderId,
}: HostPaymentRequest) {
if (getHostRuntime().kind !== 'wechat_mini_program') {
const runtime = getHostRuntime();
if (runtime.kind === 'native_app') {
return await requestNativeHostBoolean('payment.request', {
payload,
orderId,
});
}
if (runtime.kind !== 'wechat_mini_program') {
return false;
}
@@ -321,6 +408,15 @@ export async function openWechatMiniProgramShareGridPage(
}
export function setHostShareTarget(message: unknown) {
if (getHostRuntime().kind === 'native_app') {
void requestNativeAppHostBridge('share.setTarget', {
target: message,
}).catch(() => {
// 分享目标同步是宿主提示能力,失败不应打断当前 H5 流程。
});
return true;
}
if (
typeof window === 'undefined' ||
getHostRuntime().kind !== 'wechat_mini_program' ||
@@ -338,3 +434,13 @@ export function setHostShareTarget(message: unknown) {
export function postWechatMiniProgramMessage(message: unknown) {
return setHostShareTarget(message);
}
export async function getNativeAppHostRuntime() {
if (getHostRuntime().kind !== 'native_app') {
return null;
}
return await requestNativeAppHostBridge<HostBridgeRuntimeResult>(
'host.getRuntime',
);
}

View File

@@ -0,0 +1,142 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
type HostBridgeResponse,
} from '../../../packages/shared/src/contracts/hostBridge';
import {
canUseNativeAppHostBridge,
canUseReactNativeHostBridge,
canUseTauriHostBridge,
requestNativeAppHostBridge,
resetNativeAppHostBridgeForTest,
} from './nativeAppHostBridge';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
resetNativeAppHostBridgeForTest();
delete window.ReactNativeWebView;
delete window.__TAURI__;
});
function dispatchHostBridgeResponse(response: HostBridgeResponse) {
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify(response),
}),
);
}
function asTauriInvoke(
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
) {
return async function tauriInvoke<Result = unknown>(
command: string,
args?: Record<string, unknown>,
) {
return (await invoke(command, args)) as Result;
};
}
describe('nativeAppHostBridge', () => {
test('通过 React Native WebView postMessage 发送请求并接收回包', async () => {
const postMessage = vi.fn((message: string) => {
const request = JSON.parse(message) as HostBridgeRequest;
dispatchHostBridgeResponse({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: true,
result: {
handled: true,
},
});
});
window.ReactNativeWebView = { postMessage };
await expect(
requestNativeAppHostBridge('share.open', { title: '测试作品' }),
).resolves.toEqual({
handled: true,
});
expect(canUseReactNativeHostBridge()).toBe(true);
expect(canUseNativeAppHostBridge()).toBe(true);
expect(postMessage).toHaveBeenCalledTimes(1);
const request = JSON.parse(postMessage.mock.calls[0]?.[0] ?? '{}');
expect(request).toMatchObject({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
method: 'share.open',
payload: {
title: '测试作品',
},
});
});
test('通过 Tauri invoke 发送请求并处理错误回包', async () => {
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: HostBridgeRequest }).request;
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: false,
error: {
code: 'unsupported_method',
message: 'unsupported_method',
},
} satisfies HostBridgeResponse;
});
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(
requestNativeAppHostBridge('auth.requestLogin'),
).rejects.toMatchObject({
name: 'unsupported_method',
message: 'unsupported_method',
});
expect(canUseTauriHostBridge()).toBe(true);
expect(canUseNativeAppHostBridge()).toBe(true);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'auth.requestLogin',
}),
});
});
test('没有原生宿主 bridge 时返回 null', async () => {
await expect(
requestNativeAppHostBridge('host.getRuntime'),
).resolves.toBeNull();
expect(canUseNativeAppHostBridge()).toBe(false);
});
test('React Native WebView 回包超时时拒绝请求', async () => {
vi.useFakeTimers();
window.ReactNativeWebView = {
postMessage: vi.fn(),
};
const pending = requestNativeAppHostBridge('share.open', undefined, {
timeoutMs: 10,
});
const assertion = expect(pending).rejects.toMatchObject({
name: 'timeout',
message: 'host_bridge_timeout',
});
await vi.advanceTimersByTimeAsync(11);
await assertion;
});
});

View File

@@ -0,0 +1,217 @@
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
} from '../../../packages/shared/src/contracts/hostBridge';
const DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS = 8000;
const MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS = 30000;
type NativeAppBridgeWindow = Window & {
ReactNativeWebView?: {
postMessage?: (message: string) => void;
};
__TAURI__?: {
core?: {
invoke?: <Result = unknown>(
command: string,
args?: Record<string, unknown>,
) => Promise<Result>;
};
};
};
type PendingNativeRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
};
const pendingNativeRequests = new Map<string, PendingNativeRequest>();
let nativeBridgeListenerInstalled = false;
let nextNativeRequestSequence = 0;
function resolveNativeWindow() {
return typeof window === 'undefined'
? null
: (window as NativeAppBridgeWindow);
}
function buildNativeRequestId(method: HostBridgeMethod) {
nextNativeRequestSequence += 1;
return `host_${method.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}_${nextNativeRequestSequence}`;
}
function resolveTimeoutMs(timeoutMs: number | undefined) {
if (!Number.isFinite(timeoutMs ?? Number.NaN) || !timeoutMs) {
return DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS;
}
return Math.min(
Math.max(1, Math.trunc(timeoutMs)),
MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS,
);
}
function createHostBridgeError(error: HostBridgeError) {
const result = new Error(error.message);
result.name = error.code;
return result;
}
function isHostBridgeResponse(value: unknown): value is HostBridgeResponse {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<HostBridgeResponse>;
return (
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
candidate.version === HOST_BRIDGE_VERSION &&
typeof candidate.id === 'string' &&
typeof candidate.ok === 'boolean'
);
}
function parseNativeMessage(data: unknown) {
if (typeof data === 'string') {
try {
return JSON.parse(data) as unknown;
} catch {
return null;
}
}
return data;
}
function settleNativeResponse(response: HostBridgeResponse) {
const pending = pendingNativeRequests.get(response.id);
if (!pending) {
return;
}
pendingNativeRequests.delete(response.id);
clearTimeout(pending.timeoutId);
if (response.ok) {
pending.resolve(response.result);
return;
}
pending.reject(createHostBridgeError(response.error));
}
function ensureNativeBridgeListener() {
const nativeWindow = resolveNativeWindow();
if (!nativeWindow || nativeBridgeListenerInstalled) {
return;
}
nativeWindow.addEventListener('message', (event) => {
const value = parseNativeMessage(event.data);
if (isHostBridgeResponse(value)) {
settleNativeResponse(value);
}
});
nativeBridgeListenerInstalled = true;
}
export function canUseReactNativeHostBridge() {
return (
typeof resolveNativeWindow()?.ReactNativeWebView?.postMessage ===
'function'
);
}
export function canUseTauriHostBridge() {
return (
typeof resolveNativeWindow()?.__TAURI__?.core?.invoke === 'function'
);
}
export function canUseNativeAppHostBridge() {
return canUseReactNativeHostBridge() || canUseTauriHostBridge();
}
export async function requestNativeAppHostBridge<Result = unknown>(
method: HostBridgeMethod,
payload?: unknown,
options: {
timeoutMs?: number;
} = {},
) {
const nativeWindow = resolveNativeWindow();
if (!nativeWindow) {
return null;
}
const timeoutMs = resolveTimeoutMs(options.timeoutMs);
const request: HostBridgeRequest = {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: buildNativeRequestId(method),
method,
timeoutMs,
...(payload === undefined ? {} : { payload }),
};
const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke;
if (typeof tauriInvoke === 'function') {
const response = await tauriInvoke<HostBridgeResponse<Result>>(
'host_bridge_request',
{ request },
);
if (!isHostBridgeResponse(response)) {
throw new Error('host_bridge_invalid_response');
}
if (response.ok) {
return response.result as Result;
}
throw createHostBridgeError(response.error);
}
const postMessage = nativeWindow.ReactNativeWebView?.postMessage;
if (typeof postMessage !== 'function') {
return null;
}
ensureNativeBridgeListener();
return await new Promise<Result>((resolve, reject) => {
const timeoutId = setTimeout(() => {
pendingNativeRequests.delete(request.id);
reject(createHostBridgeError({
code: 'timeout',
message: 'host_bridge_timeout',
}));
}, timeoutMs);
pendingNativeRequests.set(request.id, {
resolve: (value) => resolve(value as Result),
reject,
timeoutId,
});
try {
postMessage(JSON.stringify(request));
} catch (error) {
pendingNativeRequests.delete(request.id);
clearTimeout(timeoutId);
reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
export function resetNativeAppHostBridgeForTest() {
for (const pending of pendingNativeRequests.values()) {
clearTimeout(pending.timeoutId);
}
pendingNativeRequests.clear();
nextNativeRequestSequence = 0;
}