新增 Expo 与 Tauri 原生宿主壳
新增 HostBridge 原生宿主契约和 H5 native_app transport 新增 Expo React Native 移动壳并收紧 WebView 外链边界 新增 Tauri 桌面壳并用 capability 收口受控命令 更新宿主壳方案、文档索引和共享记忆
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
142
src/services/host-bridge/nativeAppHostBridge.test.ts
Normal file
142
src/services/host-bridge/nativeAppHostBridge.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
11
src/vite-env.d.ts
vendored
11
src/vite-env.d.ts
vendored
@@ -5,6 +5,17 @@ interface ImportMetaEnv {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
ReactNativeWebView?: {
|
||||
postMessage?: (message: string) => void;
|
||||
};
|
||||
__TAURI__?: {
|
||||
core?: {
|
||||
invoke?: <Result = unknown>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<Result>;
|
||||
};
|
||||
};
|
||||
wx?: {
|
||||
miniProgram?: {
|
||||
navigateTo?: (options: {
|
||||
|
||||
Reference in New Issue
Block a user