新增 HostBridge 原生宿主契约和 H5 native_app transport 新增 Expo React Native 移动壳并收紧 WebView 外链边界 新增 Tauri 桌面壳并用 capability 收口受控命令 更新宿主壳方案、文档索引和共享记忆
218 lines
5.3 KiB
TypeScript
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;
|
|
}
|