新增 Expo 与 Tauri 原生宿主壳
新增 HostBridge 原生宿主契约和 H5 native_app transport 新增 Expo React Native 移动壳并收紧 WebView 外链边界 新增 Tauri 桌面壳并用 capability 收口受控命令 更新宿主壳方案、文档索引和共享记忆
This commit is contained in:
217
src/services/host-bridge/nativeAppHostBridge.ts
Normal file
217
src/services/host-bridge/nativeAppHostBridge.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user