Files
Genarrative/src/services/host-bridge/hostBridge.ts
kdletters 346368f0e7 接入原生壳生命周期事件
新增 app.lifecycle HostBridge 能力与 H5 订阅入口

Expo 壳通过 React Native AppState 注入真实前后台状态

Tauri 壳通过主窗口 focus 和 blur 注入真实激活状态

更新壳能力漂移检查、测试和架构文档
2026-06-18 02:16:47 +08:00

818 lines
20 KiB
TypeScript

import type {
AppearanceColorSchemeResult,
AppLifecycleEventPayload,
FileExportImagePayload,
FileExportImageResult,
FileExportTextPayload,
FileExportTextResult,
HapticsImpactPayload,
HostBridgeCapability,
HostBridgeMethod,
HostBridgeRuntimeResult,
OpenExternalUrlPayload,
SetBadgeCountPayload,
ShareOpenPayload,
} from '../../../packages/shared/src/contracts/hostBridge';
import {
isHostBridgeCapability,
normalizeHostBridgeBadgeCount,
normalizeHostBridgeColorScheme,
normalizeHostBridgeExternalUrl,
normalizeHostBridgeLifecycleState,
} from '../../../packages/shared/src/contracts/hostBridge';
import type {
WechatMiniProgramPayParams,
WechatMiniProgramVirtualPayParams,
} from '../../../packages/shared/src/contracts/runtime';
import {
canUseNativeAppHostBridge,
requestNativeAppHostBridge,
subscribeNativeAppHostBridgeEvent,
} 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 =
'/pages/web-view/index?authAction=login&returnTo=previous';
const MINI_PROGRAM_PAY_PAGE_URL = '/pages/wechat-pay/index';
const MINI_PROGRAM_SHARE_GRID_PAGE_URL = '/pages/share-grid/index';
export type HostRuntimeKind =
| 'browser'
| 'wechat_mini_program'
| 'native_app';
export type HostRuntimeSnapshot = {
kind: HostRuntimeKind;
clientType: string | null;
clientRuntime: string | null;
hostShell: string | null;
hostPlatform: string | null;
hostVersion: string | null;
hostCapabilities: HostBridgeCapability[];
miniProgramEnv: string | null;
};
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 = {
errorMessage?: string;
beforeNavigate?: () => void;
};
export type HostPaymentRequest = {
payload:
| WechatMiniProgramPayParams
| WechatMiniProgramVirtualPayParams
| null
| undefined;
orderId: string;
};
export type HostShareGridRequest = {
imageUrl: string;
title: string;
publicWorkCode: string;
};
export type HostFileExportTextRequest = FileExportTextPayload;
export type HostFileExportImageRequest = FileExportImagePayload;
export type HostClipboardWriteTextRequest = {
text: string;
};
export type HostHapticsImpactRequest = HapticsImpactPayload;
export type HostAppTitleRequest = {
title: string;
};
export type HostAppBadgeCountRequest = SetBadgeCountPayload;
export type HostShareOpenRequest = ShareOpenPayload;
export type HostExternalUrlRequest = OpenExternalUrlPayload;
export type HostAppearanceColorSchemeSnapshot = AppearanceColorSchemeResult;
export type HostAppLifecycleSnapshot = AppLifecycleEventPayload;
const HOST_RUNTIME_REFRESH_TIMEOUT_MS = 3000;
let cachedNativeHostRuntime: HostBridgeRuntimeResult | null = null;
let nativeHostRuntimeRefreshPromise: Promise<HostBridgeRuntimeResult | null> | null =
null;
const hostRuntimeChangeListeners = new Set<() => void>();
function isUnsupportedHostBridgeError(error: unknown) {
return (
error instanceof Error &&
(error.name === 'unsupported_method' ||
error.name === 'unsupported_capability')
);
}
function normalizeHostCapabilities(values: unknown): HostBridgeCapability[] {
if (!Array.isArray(values)) {
return [];
}
return values.filter(isHostBridgeCapability);
}
function mergeHostCapabilities(
...capabilityLists: Array<HostBridgeCapability[] | null | undefined>
) {
return Array.from(
new Set(capabilityLists.flatMap((capabilities) => capabilities ?? [])),
);
}
function normalizeNativeHostRuntimeResult(
runtime: HostBridgeRuntimeResult | null | undefined,
) {
if (!runtime || typeof runtime !== 'object') {
return null;
}
return {
...runtime,
capabilities: normalizeHostCapabilities(runtime.capabilities),
} satisfies HostBridgeRuntimeResult;
}
function updateCachedNativeHostRuntime(
runtime: HostBridgeRuntimeResult | null,
) {
const normalizedRuntime = normalizeNativeHostRuntimeResult(runtime);
if (!normalizedRuntime) {
return null;
}
cachedNativeHostRuntime = normalizedRuntime;
hostRuntimeChangeListeners.forEach((listener) => listener());
return normalizedRuntime;
}
export function subscribeHostRuntimeChange(listener: () => void) {
hostRuntimeChangeListeners.add(listener);
return () => {
hostRuntimeChangeListeners.delete(listener);
};
}
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)
);
}
function resolveNavigator(context: HostRuntimeContext) {
return (
context.navigator ??
(typeof navigator !== 'undefined' ? navigator : null)
);
}
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,
);
}
function isWechatMiniProgramUserAgent(userAgent: string) {
const normalizedUserAgent = userAgent.toLowerCase();
return (
normalizedUserAgent.includes('micromessenger') &&
normalizedUserAgent.includes('miniprogram')
);
}
export function resolveHostRuntime(
context: HostRuntimeContext = {},
): HostRuntimeSnapshot {
const location = resolveLocation(context);
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 queryHostCapabilities = (params.get('hostCapabilities') ?? '')
.split(',')
.map((capability) => capability.trim())
.filter(isHostBridgeCapability);
const miniProgramEnv = params.get('miniProgramEnv');
const navigatorLike = resolveNavigator(context);
const wxBridge = resolveWxBridge(context);
const tauriBridge = resolveTauriBridge(context);
const reactNativeWebView = resolveReactNativeWebView(context);
const nativeHostCapabilities = mergeHostCapabilities(
queryHostCapabilities,
cachedNativeHostRuntime?.capabilities,
);
if (
clientRuntime === 'wechat_mini_program' ||
clientType === 'mini_program' ||
isWechatMiniProgramUserAgent(navigatorLike?.userAgent ?? '') ||
hasWechatMiniProgramBridge(wxBridge)
) {
return {
kind: 'wechat_mini_program',
clientType,
clientRuntime,
hostShell,
hostPlatform,
hostVersion,
hostCapabilities: queryHostCapabilities,
miniProgramEnv,
};
}
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,
hostCapabilities: nativeHostCapabilities,
miniProgramEnv,
};
}
return {
kind: 'browser',
clientType,
clientRuntime,
hostShell,
hostPlatform,
hostVersion,
hostCapabilities: queryHostCapabilities,
miniProgramEnv,
};
}
export function getHostRuntime(context: HostRuntimeContext = {}) {
return resolveHostRuntime(context);
}
export function isWechatMiniProgramWebViewRuntime(
context: HostRuntimeContext = {},
) {
return resolveHostRuntime(context).kind === 'wechat_mini_program';
}
export function isNativeAppRuntime(context: HostRuntimeContext = {}) {
return resolveHostRuntime(context).kind === 'native_app';
}
export function canUseNativeHostCapability(
capability: HostBridgeCapability,
context: HostRuntimeContext = {},
) {
const runtime = getHostRuntime(context);
return (
runtime.kind === 'native_app' &&
runtime.hostCapabilities.includes(capability)
);
}
export function loadWechatMiniProgramBridge(
errorMessage = '请在微信小程序内完成操作',
) {
if (
typeof window === 'undefined' ||
!isWechatMiniProgramWebViewRuntime()
) {
return Promise.reject(new Error(errorMessage));
}
if (window.wx?.miniProgram?.navigateTo) {
return Promise.resolve(window.wx);
}
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${WECHAT_JS_SDK_URL}"]`,
);
const complete = () => {
if (window.wx?.miniProgram?.navigateTo) {
resolve(window.wx);
} else {
reject(new Error(errorMessage));
}
};
if (existingScript) {
if (window.wx?.miniProgram?.navigateTo) {
complete();
return;
}
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error(errorMessage)),
{ once: true },
);
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error(errorMessage));
document.head.appendChild(script);
});
}
export async function navigateWechatMiniProgramPage(
url: string,
errorMessage = '请在微信小程序内完成操作',
options: Pick<HostNativePageNavigationOptions, 'beforeNavigate'> = {},
) {
const wxBridge = await loadWechatMiniProgramBridge(errorMessage);
const navigateTo = wxBridge.miniProgram?.navigateTo;
if (typeof navigateTo !== 'function') {
throw new Error(errorMessage);
}
await new Promise<void>((resolve, reject) => {
options.beforeNavigate?.();
navigateTo({
url,
success() {
resolve();
},
fail(error) {
reject(new Error(error?.errMsg || errorMessage));
},
});
});
}
export async function navigateHostNativePage(
url: string,
options: HostNativePageNavigationOptions = {},
) {
const runtime = getHostRuntime();
if (runtime.kind === 'native_app') {
if (!runtime.hostCapabilities.includes('navigation.openNativePage')) {
return false;
}
const result = await requestNativeHostBoolean(
'navigation.openNativePage',
{ url },
);
if (result) {
options.beforeNavigate?.();
}
return result;
}
if (runtime.kind !== 'wechat_mini_program') {
return false;
}
await navigateWechatMiniProgramPage(url, options.errorMessage, {
beforeNavigate: options.beforeNavigate,
});
return true;
}
export async function requestHostLogin() {
if (getHostRuntime().kind === 'native_app') {
if (!canUseNativeHostCapability('auth.requestLogin')) {
return false;
}
return await requestNativeHostBoolean('auth.requestLogin');
}
return await navigateHostNativePage(MINI_PROGRAM_AUTH_PAGE_URL, {
errorMessage: '请在微信小程序内完成登录',
});
}
export async function requestWechatMiniProgramPhoneLogin() {
return await requestHostLogin();
}
export async function requestHostPayment({
payload,
orderId,
}: HostPaymentRequest) {
const runtime = getHostRuntime();
if (runtime.kind === 'native_app') {
if (!runtime.hostCapabilities.includes('payment.request')) {
return false;
}
return await requestNativeHostBoolean('payment.request', {
payload,
orderId,
});
}
if (runtime.kind !== 'wechat_mini_program') {
return false;
}
if (!payload) {
throw new Error('请在微信小程序内完成支付');
}
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
const searchParams = new URLSearchParams({
requestId,
orderId,
payParams: JSON.stringify(payload),
});
await navigateWechatMiniProgramPage(
`${MINI_PROGRAM_PAY_PAGE_URL}?${searchParams.toString()}`,
'请在微信小程序内完成支付',
);
return true;
}
export async function requestWechatMiniProgramPayment(
payload:
| WechatMiniProgramPayParams
| WechatMiniProgramVirtualPayParams
| null
| undefined,
orderId: string,
) {
const handled = await requestHostPayment({ payload, orderId });
if (!handled) {
throw new Error('请在微信小程序内完成支付');
}
}
function buildAbsoluteUrl(value: string) {
if (typeof window === 'undefined') {
return value;
}
return new URL(value, window.location.origin).href;
}
function normalizeHostExternalUrl(url: string) {
const trimmedUrl = url.trim();
if (!trimmedUrl) {
return null;
}
if (typeof window === 'undefined') {
return normalizeHostBridgeExternalUrl(trimmedUrl);
}
try {
return normalizeHostBridgeExternalUrl(
new URL(trimmedUrl, window.location.origin).toString(),
);
} catch {
return null;
}
}
export function canUseHostShareGrid(context: HostRuntimeContext = {}) {
return getHostRuntime(context).kind === 'wechat_mini_program';
}
export function canUseWechatMiniProgramShareGrid() {
return canUseHostShareGrid();
}
export async function openHostShareGrid(params: HostShareGridRequest) {
const imageUrl = params.imageUrl.trim();
if (!imageUrl || !canUseHostShareGrid()) {
return false;
}
const searchParams = new URLSearchParams({
imageUrl: buildAbsoluteUrl(imageUrl),
title: params.title.trim() || '我的作品',
publicWorkCode: params.publicWorkCode.trim(),
});
try {
return await navigateHostNativePage(
`${MINI_PROGRAM_SHARE_GRID_PAGE_URL}?${searchParams.toString()}`,
{
errorMessage: 'wechat_js_sdk_unavailable',
},
);
} catch {
return false;
}
}
export async function openWechatMiniProgramShareGridPage(
params: HostShareGridRequest,
) {
return await openHostShareGrid(params);
}
export function setHostShareTarget(message: unknown) {
if (getHostRuntime().kind === 'native_app') {
if (!canUseNativeHostCapability('share.setTarget')) {
return false;
}
void requestNativeAppHostBridge('share.setTarget', {
target: message,
}).catch(() => {
// 分享目标同步是宿主提示能力,失败不应打断当前 H5 流程。
});
return true;
}
if (
typeof window === 'undefined' ||
getHostRuntime().kind !== 'wechat_mini_program' ||
typeof window.wx?.miniProgram?.postMessage !== 'function'
) {
return false;
}
window.wx.miniProgram.postMessage({
data: message,
});
return true;
}
export async function openHostShare(params: HostShareOpenRequest) {
if (!canUseNativeHostCapability('share.open')) {
return false;
}
try {
return await requestNativeHostBoolean('share.open', params);
} catch {
return false;
}
}
export async function openHostExternalUrl({ url }: HostExternalUrlRequest) {
if (!canUseNativeHostCapability('app.openExternalUrl')) {
return false;
}
const normalizedUrl = normalizeHostExternalUrl(url);
if (!normalizedUrl) {
return false;
}
try {
return await requestNativeHostBoolean('app.openExternalUrl', {
url: normalizedUrl,
});
} catch {
return false;
}
}
export function postWechatMiniProgramMessage(message: unknown) {
return setHostShareTarget(message);
}
export async function getNativeAppHostRuntime() {
const runtime = getHostRuntime();
if (
runtime.kind !== 'native_app' ||
(!runtime.hostCapabilities.includes('host.getRuntime') &&
!canUseNativeAppHostBridge())
) {
return null;
}
try {
return updateCachedNativeHostRuntime(
await requestNativeAppHostBridge<HostBridgeRuntimeResult>(
'host.getRuntime',
undefined,
{ timeoutMs: HOST_RUNTIME_REFRESH_TIMEOUT_MS },
),
);
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return null;
}
throw error;
}
}
export function refreshNativeAppHostRuntime() {
if (nativeHostRuntimeRefreshPromise) {
return nativeHostRuntimeRefreshPromise;
}
nativeHostRuntimeRefreshPromise = getNativeAppHostRuntime()
.catch(() => null)
.finally(() => {
nativeHostRuntimeRefreshPromise = null;
});
return nativeHostRuntimeRefreshPromise;
}
export function resetHostRuntimeCacheForTest() {
cachedNativeHostRuntime = null;
nativeHostRuntimeRefreshPromise = null;
hostRuntimeChangeListeners.clear();
}
export async function writeHostClipboardText({
text,
}: HostClipboardWriteTextRequest) {
if (!canUseNativeHostCapability('clipboard.writeText')) {
return false;
}
try {
return await requestNativeHostBoolean('clipboard.writeText', { text });
} catch {
return false;
}
}
export async function exportHostTextFile(
params: HostFileExportTextRequest,
) {
if (!canUseNativeHostCapability('file.exportText')) {
return false;
}
try {
return await requestNativeAppHostBridge<FileExportTextResult>(
'file.exportText',
params,
{ timeoutMs: 30000 },
);
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
export async function exportHostImageFile(
params: HostFileExportImageRequest,
) {
if (!canUseNativeHostCapability('file.exportImage')) {
return false;
}
try {
return await requestNativeAppHostBridge<FileExportImageResult>(
'file.exportImage',
params,
{ timeoutMs: 30000 },
);
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
export async function requestHostHapticsImpact(
params: HostHapticsImpactRequest = {},
) {
if (!canUseNativeHostCapability('haptics.impact')) {
return false;
}
try {
return await requestNativeHostBoolean('haptics.impact', params);
} catch {
return false;
}
}
export async function setHostAppTitle({ title }: HostAppTitleRequest) {
const normalizedTitle = title.trim();
if (!normalizedTitle || !canUseNativeHostCapability('app.setTitle')) {
return false;
}
try {
return await requestNativeHostBoolean('app.setTitle', {
title: normalizedTitle,
});
} catch {
return false;
}
}
export async function setHostAppBadgeCount({
count,
}: HostAppBadgeCountRequest) {
const normalizedCount = normalizeHostBridgeBadgeCount(count);
if (
normalizedCount === null ||
!canUseNativeHostCapability('app.setBadgeCount')
) {
return false;
}
try {
return await requestNativeHostBoolean('app.setBadgeCount', {
count: normalizedCount,
});
} catch {
return false;
}
}
export async function getHostAppearanceColorScheme() {
if (!canUseNativeHostCapability('appearance.getColorScheme')) {
return false;
}
try {
const result =
await requestNativeAppHostBridge<AppearanceColorSchemeResult>(
'appearance.getColorScheme',
);
return {
colorScheme: normalizeHostBridgeColorScheme(result?.colorScheme),
} satisfies HostAppearanceColorSchemeSnapshot;
} catch {
return false;
}
}
export function subscribeHostAppLifecycle(
listener: (payload: HostAppLifecycleSnapshot) => void,
) {
if (!canUseNativeHostCapability('app.lifecycle')) {
return () => undefined;
}
return subscribeNativeAppHostBridgeEvent<AppLifecycleEventPayload>(
'app.lifecycle',
(payload) => {
const state = normalizeHostBridgeLifecycleState(payload?.state);
listener({
state,
focused: typeof payload?.focused === 'boolean'
? payload.focused
: state === 'active',
...(typeof payload?.nativeState === 'string'
? { nativeState: payload.nativeState }
: {}),
});
},
);
}