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 | null; navigator?: Partial> | 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 | 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 ) { 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>((resolve, reject) => { const existingScript = document.querySelector( `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 = {}, ) { const wxBridge = await loadWechatMiniProgramBridge(errorMessage); const navigateTo = wxBridge.miniProgram?.navigateTo; if (typeof navigateTo !== 'function') { throw new Error(errorMessage); } await new Promise((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( '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( '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( '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( '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( '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 } : {}), }); }, ); }