fba6fb75fb
H5 HostBridge 微信小程序跳转失败不再透出原生 errMsg HostBridge 测试覆盖微信跳转失败稳定提示 native shell 总门禁反查微信跳转失败边界
1343 lines
33 KiB
TypeScript
1343 lines
33 KiB
TypeScript
import type {
|
|
AppearanceColorSchemeResult,
|
|
AppLifecycleEventPayload,
|
|
ClipboardReadTextResult,
|
|
FileExportAudioPayload,
|
|
FileExportAudioResult,
|
|
FileExportImagePayload,
|
|
FileExportImageResult,
|
|
FileExportTextPayload,
|
|
FileExportTextResult,
|
|
FileImportAudioResult,
|
|
FileImportDocumentResult,
|
|
FileImportImageResult,
|
|
FileImportTextResult,
|
|
HapticsImpactPayload,
|
|
HostBridgeCapability,
|
|
HostBridgeMethod,
|
|
HostBridgeRuntimeResult,
|
|
LocalNotificationPayload,
|
|
LocalNotificationResult,
|
|
NavigationCanGoBackEventPayload,
|
|
NetworkStatusResult,
|
|
OpenExternalUrlPayload,
|
|
ScannerScanQrCodeResult,
|
|
SetBadgeCountPayload,
|
|
ShareOpenPayload,
|
|
} from '../../../packages/shared/src/contracts/hostBridge';
|
|
import {
|
|
HOST_BRIDGE_AUDIO_MIME_TYPES,
|
|
HOST_BRIDGE_APP_TITLE_MAX_LENGTH,
|
|
HOST_BRIDGE_DOCUMENT_MIME_TYPES,
|
|
HOST_BRIDGE_IMAGE_MIME_TYPES,
|
|
HOST_BRIDGE_NATIVE_APP_QUERY,
|
|
HOST_BRIDGE_PUBLIC_WEB_ORIGIN,
|
|
HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,
|
|
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY,
|
|
HOST_BRIDGE_SCANNER_TIMEOUT_MS,
|
|
HOST_BRIDGE_TEXT_MIME_TYPES,
|
|
HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
|
|
HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY,
|
|
isHostBridgeCapability,
|
|
normalizeHostBridgeBadgeCount,
|
|
normalizeHostBridgeAppTitle,
|
|
normalizeHostBridgeClipboardText,
|
|
normalizeHostBridgeColorScheme,
|
|
normalizeHostBridgeConnectionType,
|
|
normalizeHostBridgeExportAudioPayload,
|
|
normalizeHostBridgeExportImagePayload,
|
|
normalizeHostBridgeExportTextPayload,
|
|
normalizeHostBridgeExternalUrlPayload,
|
|
normalizeHostBridgeHapticsImpactStyle,
|
|
normalizeHostBridgeImportAudioResult,
|
|
normalizeHostBridgeImportDocumentResult,
|
|
normalizeHostBridgeImportImageResult,
|
|
normalizeHostBridgeImportTextResult,
|
|
normalizeHostBridgeLifecycleState,
|
|
normalizeHostBridgeLocalNotification,
|
|
normalizeHostBridgeQrCodeValue,
|
|
normalizeHostBridgeShareOpenPayload,
|
|
} 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 HostFileExportAudioRequest = FileExportAudioPayload;
|
|
|
|
export type HostFileImportImageResult = FileImportImageResult;
|
|
|
|
export type HostFileImportTextResult = FileImportTextResult;
|
|
|
|
export type HostFileImportDocumentResult = FileImportDocumentResult;
|
|
|
|
export type HostFileImportAudioResult = FileImportAudioResult;
|
|
|
|
export type HostScannerScanQrCodeResult = ScannerScanQrCodeResult;
|
|
|
|
export type HostClipboardWriteTextRequest = {
|
|
text: string;
|
|
};
|
|
|
|
export type HostClipboardReadTextResult = ClipboardReadTextResult;
|
|
|
|
export type HostHapticsImpactRequest = HapticsImpactPayload;
|
|
|
|
export type HostLocalNotificationRequest = LocalNotificationPayload;
|
|
|
|
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;
|
|
|
|
export type HostNetworkStatusSnapshot = NetworkStatusResult;
|
|
|
|
export type HostNavigationCanGoBackSnapshot = NavigationCanGoBackEventPayload;
|
|
|
|
export type HostImageDropSnapshot = FileImportImageResult;
|
|
|
|
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 isCancelledHostBridgeError(error: unknown) {
|
|
return error instanceof Error && error.name === 'cancelled';
|
|
}
|
|
|
|
function normalizeHostCapabilities(values: unknown): HostBridgeCapability[] {
|
|
if (!Array.isArray(values)) {
|
|
return [];
|
|
}
|
|
|
|
return values.filter(isHostBridgeCapability);
|
|
}
|
|
|
|
function mergeHostCapabilities(
|
|
...capabilityLists: Array<readonly 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 isLocalNotificationDeliveredToSystemResult(
|
|
result: unknown,
|
|
): result is LocalNotificationResult {
|
|
return (
|
|
!!result &&
|
|
typeof result === 'object' &&
|
|
(result as Partial<LocalNotificationResult>).action ===
|
|
'delivered_to_system'
|
|
);
|
|
}
|
|
|
|
async function requestNativeHostLocalNotification(
|
|
payload: LocalNotificationPayload,
|
|
) {
|
|
try {
|
|
const result = await requestNativeAppHostBridge(
|
|
'notification.showLocal',
|
|
payload,
|
|
);
|
|
return result === true || isLocalNotificationDeliveredToSystemResult(result);
|
|
} 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(
|
|
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.clientType,
|
|
);
|
|
const clientRuntime = params.get(
|
|
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.clientRuntime,
|
|
);
|
|
const hostShell = params.get(HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.hostShell);
|
|
const hostPlatform = params.get(
|
|
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.hostPlatform,
|
|
);
|
|
const hostVersion = params.get(
|
|
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.hostVersion,
|
|
);
|
|
const queryHostCapabilities = (
|
|
params.get(HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.hostCapabilities) ?? ''
|
|
)
|
|
.split(',')
|
|
.map((capability) => capability.trim())
|
|
.filter(isHostBridgeCapability);
|
|
const miniProgramEnv = params.get(
|
|
HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.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 ===
|
|
HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY.clientRuntime ||
|
|
clientType === HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY.clientType ||
|
|
isWechatMiniProgramUserAgent(navigatorLike?.userAgent ?? '') ||
|
|
hasWechatMiniProgramBridge(wxBridge)
|
|
) {
|
|
return {
|
|
kind: 'wechat_mini_program',
|
|
clientType,
|
|
clientRuntime,
|
|
hostShell,
|
|
hostPlatform,
|
|
hostVersion,
|
|
hostCapabilities: queryHostCapabilities,
|
|
miniProgramEnv,
|
|
};
|
|
}
|
|
|
|
if (
|
|
clientRuntime === HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime ||
|
|
clientType === HOST_BRIDGE_NATIVE_APP_QUERY.clientType ||
|
|
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) {
|
|
console.error(
|
|
'[host-bridge] wechat mini program navigation failed',
|
|
error,
|
|
);
|
|
reject(new Error(errorMessage));
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function navigateHostNativePage(
|
|
url: string,
|
|
options: HostNativePageNavigationOptions = {},
|
|
) {
|
|
const runtime = getHostRuntime();
|
|
if (runtime.kind === 'native_app') {
|
|
if (!canUseNativeHostCapability('navigation.openNativePage')) {
|
|
return false;
|
|
}
|
|
const normalizedUrl = normalizeNativeAppPageUrl(url);
|
|
if (!normalizedUrl) {
|
|
return false;
|
|
}
|
|
const result = await requestNativeHostBoolean(
|
|
'navigation.openNativePage',
|
|
{ url: normalizedUrl },
|
|
);
|
|
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 (!canUseNativeHostCapability('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 normalizeNativeAppPageUrl(url: string) {
|
|
const trimmedUrl = url.trim();
|
|
if (!trimmedUrl || /[\u0000-\u001f\u007f]/u.test(trimmedUrl)) {
|
|
return null;
|
|
}
|
|
|
|
if (trimmedUrl.startsWith('//')) {
|
|
return null;
|
|
}
|
|
|
|
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedUrl)) {
|
|
try {
|
|
const nativePageUrl = new URL(trimmedUrl);
|
|
if (
|
|
nativePageUrl.protocol !== 'http:' &&
|
|
nativePageUrl.protocol !== 'https:'
|
|
) {
|
|
return null;
|
|
}
|
|
if (nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN) {
|
|
return null;
|
|
}
|
|
return nativePageUrl.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return trimmedUrl;
|
|
}
|
|
|
|
function normalizeHostExternalUrlPayload(url: string) {
|
|
const trimmedUrl = url.trim();
|
|
if (!trimmedUrl) {
|
|
return null;
|
|
}
|
|
|
|
if (typeof window === 'undefined') {
|
|
return normalizeHostBridgeExternalUrlPayload(trimmedUrl);
|
|
}
|
|
|
|
try {
|
|
return normalizeHostBridgeExternalUrlPayload(
|
|
new URL(trimmedUrl, window.location.origin).toString(),
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function absolutizeHostSharePayloadUrls(params: HostShareOpenRequest) {
|
|
if (typeof window === 'undefined') {
|
|
return params;
|
|
}
|
|
|
|
const nextPayload: HostShareOpenRequest = { ...params };
|
|
for (const field of ['url', 'href', 'path', 'targetPath'] as const) {
|
|
const value = nextPayload[field];
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(value.trim(), window.location.origin);
|
|
if (
|
|
url.origin !== window.location.origin &&
|
|
url.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN
|
|
) {
|
|
nextPayload[field] = value;
|
|
continue;
|
|
}
|
|
nextPayload[field] = new URL(
|
|
`${url.pathname}${url.search}${url.hash}`,
|
|
HOST_BRIDGE_PUBLIC_WEB_ORIGIN,
|
|
).toString();
|
|
} catch {
|
|
nextPayload[field] = value;
|
|
}
|
|
}
|
|
|
|
return nextPayload;
|
|
}
|
|
|
|
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;
|
|
}
|
|
const normalizedPayload = normalizeHostBridgeShareOpenPayload(message);
|
|
if (normalizedPayload.status !== 'valid') {
|
|
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;
|
|
}
|
|
|
|
const normalizedPayload = normalizeHostBridgeShareOpenPayload(
|
|
absolutizeHostSharePayloadUrls(params),
|
|
);
|
|
if (normalizedPayload.status !== 'valid') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeHostBoolean(
|
|
'share.open',
|
|
normalizedPayload.payload,
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function openHostExternalUrl({ url }: HostExternalUrlRequest) {
|
|
if (!canUseNativeHostCapability('app.openExternalUrl')) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedPayload = normalizeHostExternalUrlPayload(url);
|
|
if (!normalizedPayload) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeHostBoolean(
|
|
'app.openExternalUrl',
|
|
normalizedPayload,
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function reloadHostWebView() {
|
|
if (!canUseNativeHostCapability('app.reloadWebView')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeHostBoolean('app.reloadWebView');
|
|
} 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_BRIDGE_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;
|
|
}
|
|
|
|
const clipboardText = normalizeHostBridgeClipboardText(text);
|
|
if (!clipboardText) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeHostBoolean('clipboard.writeText', clipboardText);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function readHostClipboardText() {
|
|
if (!canUseNativeHostCapability('clipboard.readText')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const clipboardText = normalizeHostBridgeClipboardText(
|
|
(
|
|
await requestNativeAppHostBridge<ClipboardReadTextResult>(
|
|
'clipboard.readText',
|
|
)
|
|
)?.text,
|
|
);
|
|
return clipboardText ?? false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function exportHostTextFile(
|
|
params: HostFileExportTextRequest,
|
|
) {
|
|
if (!canUseNativeHostCapability('file.exportText')) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedPayload = normalizeHostBridgeExportTextPayload(params);
|
|
if (!normalizedPayload) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeAppHostBridge<FileExportTextResult>(
|
|
'file.exportText',
|
|
normalizedPayload,
|
|
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
|
);
|
|
} catch (error) {
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function exportHostImageFile(
|
|
params: HostFileExportImageRequest,
|
|
) {
|
|
if (!canUseNativeHostCapability('file.exportImage')) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedPayload = normalizeHostBridgeExportImagePayload(params);
|
|
if (!normalizedPayload) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeAppHostBridge<FileExportImageResult>(
|
|
'file.exportImage',
|
|
normalizedPayload,
|
|
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
|
);
|
|
} catch (error) {
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function exportHostAudioFile(
|
|
params: HostFileExportAudioRequest,
|
|
) {
|
|
if (!canUseNativeHostCapability('file.exportAudio')) {
|
|
return false;
|
|
}
|
|
|
|
const normalizedPayload = normalizeHostBridgeExportAudioPayload(params);
|
|
if (!normalizedPayload) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeAppHostBridge<FileExportAudioResult>(
|
|
'file.exportAudio',
|
|
normalizedPayload,
|
|
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
|
);
|
|
} catch (error) {
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function importHostImageFile() {
|
|
if (!canUseNativeHostCapability('file.importImage')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return normalizeHostBridgeImportImageResult(
|
|
await requestNativeAppHostBridge<FileImportImageResult>(
|
|
'file.importImage',
|
|
undefined,
|
|
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
|
),
|
|
) ?? false;
|
|
} catch (error) {
|
|
if (isCancelledHostBridgeError(error)) {
|
|
return null;
|
|
}
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function captureHostImageFile() {
|
|
if (!canUseNativeHostCapability('file.captureImage')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return normalizeHostBridgeImportImageResult(
|
|
await requestNativeAppHostBridge<FileImportImageResult>(
|
|
'file.captureImage',
|
|
undefined,
|
|
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
|
),
|
|
) ?? false;
|
|
} catch (error) {
|
|
if (isCancelledHostBridgeError(error)) {
|
|
return null;
|
|
}
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function scanHostQrCode() {
|
|
if (!canUseNativeHostCapability('scanner.scanQrCode')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const result =
|
|
await requestNativeAppHostBridge<ScannerScanQrCodeResult>(
|
|
'scanner.scanQrCode',
|
|
undefined,
|
|
{ timeoutMs: HOST_BRIDGE_SCANNER_TIMEOUT_MS },
|
|
);
|
|
return normalizeHostBridgeQrCodeValue(result?.value) ?? false;
|
|
} catch (error) {
|
|
if (isCancelledHostBridgeError(error)) {
|
|
return null;
|
|
}
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function importHostTextFile() {
|
|
if (!canUseNativeHostCapability('file.importText')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return normalizeHostBridgeImportTextResult(
|
|
await requestNativeAppHostBridge<FileImportTextResult>(
|
|
'file.importText',
|
|
undefined,
|
|
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
|
),
|
|
) ?? false;
|
|
} catch (error) {
|
|
if (isCancelledHostBridgeError(error)) {
|
|
return null;
|
|
}
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function importHostDocumentFile() {
|
|
if (!canUseNativeHostCapability('file.importDocument')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return normalizeHostBridgeImportDocumentResult(
|
|
await requestNativeAppHostBridge<FileImportDocumentResult>(
|
|
'file.importDocument',
|
|
undefined,
|
|
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
|
),
|
|
) ?? false;
|
|
} catch (error) {
|
|
if (isCancelledHostBridgeError(error)) {
|
|
return null;
|
|
}
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function importHostAudioFile() {
|
|
if (!canUseNativeHostCapability('file.importAudio')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return normalizeHostBridgeImportAudioResult(
|
|
await requestNativeAppHostBridge<FileImportAudioResult>(
|
|
'file.importAudio',
|
|
undefined,
|
|
{ timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS },
|
|
),
|
|
) ?? false;
|
|
} catch (error) {
|
|
if (isCancelledHostBridgeError(error)) {
|
|
return null;
|
|
}
|
|
if (isUnsupportedHostBridgeError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function requestHostHapticsImpact(
|
|
params: HostHapticsImpactRequest = {},
|
|
) {
|
|
if (!canUseNativeHostCapability('haptics.impact')) {
|
|
return false;
|
|
}
|
|
|
|
const style = normalizeHostBridgeHapticsImpactStyle(params.style);
|
|
if (!style) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeHostBoolean('haptics.impact', { style });
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function showHostLocalNotification(
|
|
params: HostLocalNotificationRequest,
|
|
) {
|
|
if (!canUseNativeHostCapability('notification.showLocal')) {
|
|
return false;
|
|
}
|
|
|
|
const notification = normalizeHostBridgeLocalNotification(params);
|
|
if (!notification) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeHostLocalNotification(notification);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function setHostAppTitle({ title }: HostAppTitleRequest) {
|
|
const normalizedTitle = normalizeHostBridgeAppTitle(title);
|
|
if (!normalizedTitle || !canUseNativeHostCapability('app.setTitle')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requestNativeHostBoolean('app.setTitle', 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 (!canUseNativeHostEventCapability('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 }
|
|
: {}),
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function normalizeHostNetworkStatus(
|
|
payload: NetworkStatusResult | null | undefined,
|
|
): HostNetworkStatusSnapshot {
|
|
const connectionType = normalizeHostBridgeConnectionType(
|
|
payload?.connectionType ?? payload?.nativeType,
|
|
);
|
|
const isConnected =
|
|
typeof payload?.isConnected === 'boolean'
|
|
? payload.isConnected
|
|
: connectionType !== 'none' && connectionType !== 'unknown';
|
|
|
|
return {
|
|
isConnected,
|
|
isInternetReachable:
|
|
typeof payload?.isInternetReachable === 'boolean'
|
|
? payload.isInternetReachable
|
|
: null,
|
|
connectionType,
|
|
...(typeof payload?.nativeType === 'string'
|
|
? { nativeType: payload.nativeType }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export async function getHostNetworkStatus() {
|
|
if (!canUseNativeHostCapability('network.status')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return normalizeHostNetworkStatus(
|
|
await requestNativeAppHostBridge<NetworkStatusResult>('network.status'),
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function subscribeHostNetworkStatusChange(
|
|
listener: (payload: HostNetworkStatusSnapshot) => void,
|
|
) {
|
|
if (!canUseNativeHostEventCapability('network.statusChanged')) {
|
|
return () => undefined;
|
|
}
|
|
|
|
return subscribeNativeAppHostBridgeEvent<NetworkStatusResult>(
|
|
'network.statusChanged',
|
|
(payload) => {
|
|
listener(normalizeHostNetworkStatus(payload));
|
|
},
|
|
);
|
|
}
|
|
|
|
export function subscribeHostNavigationCanGoBack(
|
|
listener: (payload: HostNavigationCanGoBackSnapshot) => void,
|
|
) {
|
|
if (!canUseNativeHostEventCapability('navigation.canGoBack')) {
|
|
return () => undefined;
|
|
}
|
|
|
|
return subscribeNativeAppHostBridgeEvent<NavigationCanGoBackEventPayload>(
|
|
'navigation.canGoBack',
|
|
(payload) => {
|
|
listener({
|
|
canGoBack: payload?.canGoBack === true,
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
export function subscribeHostImageDrop(
|
|
listener: (payload: HostImageDropSnapshot) => void,
|
|
) {
|
|
if (!canUseNativeHostEventCapability('file.imageDropped')) {
|
|
return () => undefined;
|
|
}
|
|
|
|
return subscribeNativeAppHostBridgeEvent<FileImportImageResult>(
|
|
'file.imageDropped',
|
|
(payload) => {
|
|
const normalizedPayload = normalizeHostBridgeImportImageResult(payload);
|
|
if (normalizedPayload) {
|
|
listener(normalizedPayload);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
function canUseNativeHostEventCapability(
|
|
capability: Exclude<HostBridgeCapability, 'host.events'>,
|
|
) {
|
|
return (
|
|
canUseNativeHostCapability('host.events') &&
|
|
canUseNativeHostCapability(capability)
|
|
);
|
|
}
|