统一 H5 宿主壳能力协议
新增 HostBridge 通用宿主能力服务与测试 迁移登录支付分享订阅入口到通用 HostBridge API 保留微信小程序旧接口兼容包装 补充宿主壳协议文档与项目记忆
This commit is contained in:
230
src/services/host-bridge/hostBridge.test.ts
Normal file
230
src/services/host-bridge/hostBridge.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
canUseHostShareGrid,
|
||||
getHostRuntime,
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
navigateHostNativePage,
|
||||
openHostShareGrid,
|
||||
openWechatMiniProgramShareGridPage,
|
||||
postWechatMiniProgramMessage,
|
||||
requestHostLogin,
|
||||
requestHostPayment,
|
||||
requestWechatMiniProgramPayment,
|
||||
requestWechatMiniProgramPhoneLogin,
|
||||
resolveHostRuntime,
|
||||
setHostShareTarget,
|
||||
} from './hostBridge';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.wx = undefined;
|
||||
});
|
||||
|
||||
describe('hostBridge', () => {
|
||||
test('识别微信小程序、原生 App 和普通浏览器宿主', () => {
|
||||
expect(
|
||||
getHostRuntime({
|
||||
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||
}).kind,
|
||||
).toBe('wechat_mini_program');
|
||||
|
||||
expect(
|
||||
resolveHostRuntime({
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 iPhone MicroMessenger/8.0 miniProgram',
|
||||
},
|
||||
}).kind,
|
||||
).toBe('wechat_mini_program');
|
||||
|
||||
expect(
|
||||
resolveHostRuntime({
|
||||
location: { search: '?clientRuntime=native_app' },
|
||||
}).kind,
|
||||
).toBe('native_app');
|
||||
|
||||
expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe(
|
||||
'browser',
|
||||
);
|
||||
});
|
||||
|
||||
test('通过微信小程序原生页请求登录', async () => {
|
||||
const navigateTo = vi.fn((options) => {
|
||||
options.success?.();
|
||||
});
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
const requested = await requestHostLogin();
|
||||
|
||||
expect(requested).toBe(true);
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
|
||||
await expect(requestWechatMiniProgramPhoneLogin()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('通过微信小程序原生页请求支付', async () => {
|
||||
const navigateTo = vi.fn((options) => {
|
||||
options.success?.();
|
||||
});
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123456);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
const handled = await requestHostPayment({
|
||||
payload: {
|
||||
timeStamp: '1',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=1',
|
||||
signType: 'RSA',
|
||||
paySign: 'sign',
|
||||
},
|
||||
orderId: 'order-1',
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
const url = navigateTo.mock.calls[0]?.[0].url;
|
||||
expect(url).toContain('/pages/wechat-pay/index?');
|
||||
const parsed = new URL(url, 'https://mini.test');
|
||||
expect(parsed.searchParams.get('requestId')).toBe(
|
||||
'wechat_pay_order-1_123456',
|
||||
);
|
||||
expect(parsed.searchParams.get('orderId')).toBe('order-1');
|
||||
expect(parsed.searchParams.get('payParams')).toContain('prepay_id=1');
|
||||
|
||||
await expect(
|
||||
requestWechatMiniProgramPayment(
|
||||
{
|
||||
timeStamp: '1',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=1',
|
||||
signType: 'RSA',
|
||||
paySign: 'sign',
|
||||
},
|
||||
'order-1',
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('普通浏览器不处理宿主登录、支付和原生页跳转', async () => {
|
||||
await expect(requestHostLogin()).resolves.toBe(false);
|
||||
await expect(
|
||||
requestHostPayment({
|
||||
payload: {
|
||||
timeStamp: '1',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=1',
|
||||
signType: 'RSA',
|
||||
paySign: 'sign',
|
||||
},
|
||||
orderId: 'order-1',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(navigateHostNativePage('/pages/test/index')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(
|
||||
requestHostPayment({
|
||||
payload: null,
|
||||
orderId: 'order-1',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('打开微信小程序九宫切图页并补齐绝对图片地址', async () => {
|
||||
const navigateTo = vi.fn((options) => {
|
||||
options.success?.();
|
||||
});
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
|
||||
expect(canUseHostShareGrid()).toBe(true);
|
||||
const opened = await openHostShareGrid({
|
||||
imageUrl: '/cover.png',
|
||||
title: '暖灯猫街',
|
||||
publicWorkCode: 'PZ-00000001',
|
||||
});
|
||||
|
||||
expect(opened).toBe(true);
|
||||
const url = navigateTo.mock.calls[0]?.[0].url;
|
||||
const parsed = new URL(url, 'https://mini.test');
|
||||
expect(parsed.pathname).toBe('/pages/share-grid/index');
|
||||
expect(parsed.searchParams.get('imageUrl')).toBe(
|
||||
`${window.location.origin}/cover.png`,
|
||||
);
|
||||
expect(parsed.searchParams.get('title')).toBe('暖灯猫街');
|
||||
|
||||
await expect(
|
||||
openWechatMiniProgramShareGridPage({
|
||||
imageUrl: '/cover.png',
|
||||
title: '暖灯猫街',
|
||||
publicWorkCode: 'PZ-00000001',
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('向微信小程序宿主同步消息', () => {
|
||||
const postMessage = vi.fn();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=wechat_mini_program',
|
||||
);
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
postMessage,
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
setHostShareTarget({
|
||||
type: 'test',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(postMessage).toHaveBeenCalledWith({
|
||||
data: {
|
||||
type: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(postWechatMiniProgramMessage({ type: 'compat' })).toBe(true);
|
||||
});
|
||||
|
||||
test('普通浏览器不开放微信小程序能力', () => {
|
||||
expect(isWechatMiniProgramWebViewRuntime()).toBe(false);
|
||||
expect(canUseHostShareGrid()).toBe(false);
|
||||
expect(setHostShareTarget({ type: 'test' })).toBe(false);
|
||||
});
|
||||
});
|
||||
340
src/services/host-bridge/hostBridge.ts
Normal file
340
src/services/host-bridge/hostBridge.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import type {
|
||||
WechatMiniProgramPayParams,
|
||||
WechatMiniProgramVirtualPayParams,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
|
||||
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;
|
||||
miniProgramEnv: string | null;
|
||||
};
|
||||
|
||||
export type HostRuntimeContext = {
|
||||
location?: Pick<Location, 'search'> | null;
|
||||
navigator?: Partial<Pick<Navigator, 'userAgent'>> | null;
|
||||
wx?: Window['wx'] | 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;
|
||||
};
|
||||
|
||||
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 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 miniProgramEnv = params.get('miniProgramEnv');
|
||||
const navigatorLike = resolveNavigator(context);
|
||||
const wxBridge = resolveWxBridge(context);
|
||||
|
||||
if (
|
||||
clientRuntime === 'wechat_mini_program' ||
|
||||
clientType === 'mini_program' ||
|
||||
isWechatMiniProgramUserAgent(navigatorLike?.userAgent ?? '') ||
|
||||
hasWechatMiniProgramBridge(wxBridge)
|
||||
) {
|
||||
return {
|
||||
kind: 'wechat_mini_program',
|
||||
clientType,
|
||||
clientRuntime,
|
||||
miniProgramEnv,
|
||||
};
|
||||
}
|
||||
|
||||
if (clientRuntime === 'native_app' || clientType === 'native_app') {
|
||||
return {
|
||||
kind: 'native_app',
|
||||
clientType,
|
||||
clientRuntime,
|
||||
miniProgramEnv,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'browser',
|
||||
clientType,
|
||||
clientRuntime,
|
||||
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 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 !== 'wechat_mini_program') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await navigateWechatMiniProgramPage(url, options.errorMessage, {
|
||||
beforeNavigate: options.beforeNavigate,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function requestHostLogin() {
|
||||
return await navigateHostNativePage(MINI_PROGRAM_AUTH_PAGE_URL, {
|
||||
errorMessage: '请在微信小程序内完成登录',
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestWechatMiniProgramPhoneLogin() {
|
||||
return await requestHostLogin();
|
||||
}
|
||||
|
||||
export async function requestHostPayment({
|
||||
payload,
|
||||
orderId,
|
||||
}: HostPaymentRequest) {
|
||||
if (getHostRuntime().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;
|
||||
}
|
||||
|
||||
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 (
|
||||
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 function postWechatMiniProgramMessage(message: unknown) {
|
||||
return setHostShareTarget(message);
|
||||
}
|
||||
Reference in New Issue
Block a user