Files
Genarrative/src/services/host-bridge/nativeAppHostBridge.ts
kdletters 9b7da18879 新增 Expo 与 Tauri 原生宿主壳
新增 HostBridge 原生宿主契约和 H5 native_app transport

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

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

更新宿主壳方案、文档索引和共享记忆
2026-06-17 21:39:34 +08:00

218 lines
5.3 KiB
TypeScript

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;
}