统一 H5 宿主壳能力协议

新增 HostBridge 通用宿主能力服务与测试

迁移登录支付分享订阅入口到通用 HostBridge API

保留微信小程序旧接口兼容包装

补充宿主壳协议文档与项目记忆
This commit is contained in:
2026-06-17 20:05:55 +08:00
parent 6984af782c
commit f92e791464
19 changed files with 795 additions and 416 deletions

View File

@@ -21,6 +21,8 @@
微信小程序虚拟支付接入、`wechat_mp_virtual` 渠道、`wx.requestVirtualPayment` 承接页和后端签名配置见 [【技术方案】微信虚拟支付接入-2026-05-26.md](./%E3%80%90%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88%E3%80%91%E5%BE%AE%E4%BF%A1%E8%99%9A%E6%8B%9F%E6%94%AF%E4%BB%98%E6%8E%A5%E5%85%A5-2026-05-26.md)。
微信小程序壳、未来原生 App 壳、固定内置玩法与 AI H5 沙箱之间的宿主能力边界见 [【前端架构】宿主壳能力统一协议-2026-06-17.md](./【前端架构】宿主壳能力统一协议-2026-06-17.md)。
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。

View File

@@ -16,6 +16,14 @@
---
## 2026-06-17 H5 宿主壳能力统一走 HostBridge
- 背景:主站同时运行在普通浏览器、微信小程序 `web-view` 和未来可能出现的原生 App WebView 中;登录、支付、分享、订阅授权和运行态分享目标同步曾散落在业务组件与服务文件里,后续新增宿主壳会导致同一业务重复分叉。
- 决策:前端宿主运行态识别、微信小程序 JS SDK 加载、原生页跳转、支付跳转、登录跳转、九宫切图和 `postMessage` 统一收口到 `src/services/host-bridge/hostBridge.ts`,业务层优先调用 `getHostRuntime``requestHostLogin``requestHostPayment``navigateHostNativePage``setHostShareTarget``openHostShareGrid``authService`、分享服务、订阅授权和个人中心充值可保留兼容导出或业务编排,但不再自行加载微信 JS SDK 或直接判断 `wx.miniProgram`。固定内置玩法不走代码包下载流程AI 生成 H5 沙箱后续单独定义受限 `GameBridge`,不得直接暴露完整 `HostBridge`
- 影响范围:`src/services/host-bridge/``src/services/authService.ts``src/services/payment/paymentPlatform.ts``src/services/wechatMiniProgramShareGrid.ts``src/services/wechatMiniProgramShareTarget.ts``src/services/wechatMiniProgramSubscribe.ts``src/components/platform-entry/usePlatformProfileCenterController.ts`、微信小程序壳和未来原生 App 壳接入。
- 验证方式:微信小程序首点登录仍打开原生登录页;小程序支付仍跳转 `/pages/wechat-pay/index` 并保留 hash 回灌确认;订阅授权仍跳转 `/pages/subscribe-message/index` 且返回不阻断生成普通浏览器分享、H5 支付和 Native 二维码支付不受影响。前端验证运行 HostBridge、auth、payment、分享、订阅和个人中心充值相关定向测试并执行 `npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`
## 2026-06-15 SpacetimeDB 本地 skills 只保留 CLI / Concepts / Rust
- 背景:本仓库的 SpacetimeDB 接入已固定为 `server-rs + Axum + SpacetimeDB`,本地 skill 需要从上游 SpacetimeDB `skills/` 更新到 2.5 口径,同时避免继续维护当前项目不使用的 TypeScript server/client、C# 和 Unity 专用 skill。

View File

@@ -0,0 +1,68 @@
# 宿主壳能力统一协议
更新时间:`2026-06-17`
## 背景
当前主站已经同时运行在普通浏览器、微信小程序 `web-view` 和后续可能出现的原生 App WebView 中。登录、支付、分享、订阅授权、运行态分享目标同步等能力散落在业务组件和服务文件里,后续如果新增原生 App 壳,容易出现同一业务按宿主重复分叉。
本方案先建立 `HostBridge` 宿主壳协议,把浏览器、微信小程序壳和未来原生 App 壳统一成能力 adapter。固定玩法运行态仍作为平台内置 runtimeAI 生成 H5 仍走独立 sandbox 和受限 GameBridge二者都不能直接拿完整宿主壳能力。
## 目标
1. H5 业务层只判断宿主能力,不直接散落判断 `wx.miniProgram``MicroMessenger``clientRuntime`
2. 微信小程序壳先作为 `wechat_mini_program` adapter 接入,保留现有登录、支付、分享、订阅授权行为。
3. 未来原生 App 壳只新增 `native_app` adapter不重写 H5 业务。
4. 固定玩法继续读取作品数据、素材、运行态 snapshot 和后端裁决结果,不走代码包下载流程。
5. AI H5 sandbox 只能通过受限 GameBridge 请求资产、上报事件和提交候选结果不暴露登录、支付、token、完整用户资料。
## 非目标
- 不重写 React 主站和现有玩法 runtime。
- 不把固定玩法迁成远程代码包。
- 不在本阶段实现 React Native / Expo 壳。
- 不改变支付到账、任务、排行榜、发布、统计等后端裁决口径。
## 分层
```text
H5 业务层
-> HostBridge 能力接口
-> browserHostBridge
-> wechatMiniProgramHostBridge
-> nativeAppHostBridge预留
AI H5 sandbox
-> GameBridge 受限协议
-> parent HostBridge adapter
```
## 首批能力
- `getHostRuntime()`:识别 `browser``wechat_mini_program``native_app`
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
- `openHostShareGrid()`:微信小程序九宫格切图页。
- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。
## 迁移顺序
1. 新增 `src/services/host-bridge/`,沉淀宿主运行态识别和微信小程序 JS SDK 加载,并暴露通用 HostBridge 能力接口。
2. `authService` 保留原导出,但内部委托 HostBridge避免一次性改动 AuthGate。
3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。
4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉。
## 验收
- 微信小程序首点登录仍能打开原生登录页。
- 小程序分享链接仍生成 `/pages/web-view/index?targetPath=/works/detail&work=...`
- 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。
- 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。
- 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。
## 后续
- 设计 `native_app``postMessage` 消息格式和回包超时策略。
- 为 AI H5 sandbox 单独定义 GameBridge禁止直接依赖 HostBridge。
- 将宿主能力、支付渠道和分享策略补充进移动端发布检查清单。

View File

@@ -26,8 +26,6 @@ const authMocks = vi.hoisted(() => ({
getAuthAuditLogs: vi.fn(),
getAuthRiskBlocks: vi.fn(),
getAuthSessions: vi.fn(),
isWechatMiniProgramWebViewRuntime: vi.fn(() => false),
requestWechatMiniProgramPhoneLogin: vi.fn(),
revokeAuthSessions: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
@@ -62,14 +60,10 @@ vi.mock('../../services/authService', () => ({
getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: authMocks.getAuthSessions,
getCaptchaChallengeFromError: vi.fn(() => null),
isWechatMiniProgramWebViewRuntime:
authMocks.isWechatMiniProgramWebViewRuntime,
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
requestWechatMiniProgramPhoneLogin:
authMocks.requestWechatMiniProgramPhoneLogin,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSessions: authMocks.revokeAuthSessions,
@@ -78,6 +72,21 @@ vi.mock('../../services/authService', () => ({
startWechatLogin: authMocks.startWechatLogin,
}));
const hostBridgeMocks = vi.hoisted(() => ({
getHostRuntime: vi.fn(() => ({
kind: 'browser',
clientType: null as string | null,
clientRuntime: null as string | null,
miniProgramEnv: null as string | null,
})),
requestHostLogin: vi.fn(),
}));
vi.mock('../../services/host-bridge/hostBridge', () => ({
getHostRuntime: hostBridgeMocks.getHostRuntime,
requestHostLogin: hostBridgeMocks.requestHostLogin,
}));
vi.mock('../../hooks/useGameSettings', () => ({
useGameSettings: () => ({
musicVolume: 0.42,
@@ -165,8 +174,13 @@ beforeEach(() => {
expiresInSeconds: 300,
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false);
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
hostBridgeMocks.getHostRuntime.mockReturnValue({
kind: 'browser',
clientType: null,
clientRuntime: null,
miniProgramEnv: null,
});
hostBridgeMocks.requestHostLogin.mockResolvedValue(true);
});
afterEach(() => {
@@ -445,7 +459,12 @@ test('auth gate opens a login modal for protected actions and resumes after logi
test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => {
const user = userEvent.setup();
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true);
hostBridgeMocks.getHostRuntime.mockReturnValue({
kind: 'wechat_mini_program',
clientType: null,
clientRuntime: 'wechat_mini_program',
miniProgramEnv: null,
});
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],
});
@@ -459,13 +478,11 @@ test('auth gate uses mini program auth bridge instead of opening login modal in
await user.click(await screen.findByRole('button', { name: '进入作品' }));
await waitFor(() => {
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(
1,
);
expect(hostBridgeMocks.requestHostLogin).toHaveBeenCalledTimes(1);
});
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled();
expect(hostBridgeMocks.getHostRuntime).toHaveBeenCalled();
});
test('login modal requires first-time legal consent before sms login', async () => {

View File

@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import {
type ReactNode,
useCallback,
@@ -32,19 +34,21 @@ import {
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
isWechatMiniProgramWebViewRuntime,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
redeemRegistrationInviteCode,
requestWechatMiniProgramPhoneLogin,
resetPassword,
revokeAuthSessions,
sendPhoneLoginCode,
setStoredLastLoginPhone,
startWechatLogin,
} from '../../services/authService';
import {
getHostRuntime,
requestHostLogin,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { AccountModal } from './AccountModal';
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
@@ -328,7 +332,7 @@ export function AuthGate({ children }: AuthGateProps) {
const requestMiniProgramLogin = useCallback(() => {
setWechatLoading(true);
setError('');
void requestWechatMiniProgramPhoneLogin()
void requestHostLogin()
.catch((miniProgramError) => {
setError(
miniProgramError instanceof Error
@@ -349,7 +353,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
pendingProtectedActionRef.current = postLoginAction ?? null;
if (isWechatMiniProgramWebViewRuntime()) {
if (getHostRuntime().kind === 'wechat_mini_program') {
setShowLoginModal(false);
requestMiniProgramLogin();
return;

View File

@@ -7,7 +7,7 @@ import type {
AuthLoginMethod,
} from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
import { getHostRuntime } from '../../services/host-bridge/hostBridge';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
@@ -96,7 +96,7 @@ export function LoginScreen({
const passwordLoginEnabled = true;
const phoneLoginEnabled = true;
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const miniProgramRuntime = isWechatMiniProgramWebViewRuntime();
const miniProgramRuntime = getHostRuntime().kind === 'wechat_mini_program';
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
useEffect(() => {

View File

@@ -10,7 +10,6 @@ import {
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import * as shareGridService from '../../services/wechatMiniProgramShareGrid';
import { PublishShareModal } from './PublishShareModal';
import {
buildMiniProgramPublishSharePath,
@@ -24,10 +23,6 @@ import {
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
vi.mock('../../services/wechatMiniProgramShareGrid', () => ({
canUseWechatMiniProgramShareGrid: vi.fn(() => false),
openWechatMiniProgramShareGridPage: vi.fn(),
}));
const payload: PublishShareModalPayload = {
title: '暖灯猫街',
@@ -39,10 +34,8 @@ const payload: PublishShareModalPayload = {
afterEach(() => {
vi.clearAllMocks();
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
false,
);
window.history.replaceState(null, '', '/');
window.wx = undefined;
});
describe('PublishShareModal', () => {
@@ -141,8 +134,10 @@ describe('PublishShareModal', () => {
});
test('shows the mini program grid action only inside mini program runtime', () => {
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
true,
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
render(

View File

@@ -2,20 +2,20 @@ import { Check, Copy, Download, Grid3X3, Link2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard';
import {
canUseWechatMiniProgramShareGrid,
openWechatMiniProgramShareGridPage,
} from '../../services/wechatMiniProgramShareGrid';
canUseHostShareGrid,
getHostRuntime,
openHostShareGrid,
} from '../../services/host-bridge/hostBridge';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
import { downloadPublishShareCardImage } from './publishShareCardImage';
import {
buildPublishShareCopyUrl,
type PublishShareModalPayload,
} from './publishShareModalModel';
import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal';
type PublishShareModalProps = {
open: boolean;
@@ -59,7 +59,8 @@ export function PublishShareModal({
() =>
payload
? buildPublishShareCopyUrl(payload, {
miniProgramRuntime: isWechatMiniProgramWebViewRuntime(),
miniProgramRuntime:
getHostRuntime().kind === 'wechat_mini_program',
})
: '',
[payload],
@@ -68,7 +69,7 @@ export function PublishShareModal({
const coverImageSrc = resolvePayloadCoverImageSrc(payload);
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
const showMiniProgramGridButton =
canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc);
canUseHostShareGrid() && Boolean(coverImageSrc);
useEffect(
() => () => {
@@ -151,7 +152,7 @@ export function PublishShareModal({
setGridState('idle');
void resolveMiniProgramGridCover()
.then((resolvedCoverImageSrc) =>
openWechatMiniProgramShareGridPage({
openHostShareGrid({
imageUrl: resolvedCoverImageSrc,
title,
publicWorkCode: payload.publicWorkCode,

View File

@@ -1,7 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AuthUser } from '../../services/authService';
import {
type ConfirmWechatProfileRechargeOrderResponse,
type ProfileRechargeCenterResponse,
type ProfileRechargeProduct,
type ProfileReferralInviteCenterResponse,
type ProfileTaskCenterResponse,
type ProfileWalletLedgerResponse,
type RedeemProfileRewardCodeResponse,
type WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import { refreshStoredAccessToken } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import { requestHostPayment } from '../../services/host-bridge/hostBridge';
import {
resolveProfileRechargeProductPaymentChannel,
WECHAT_H5_PAYMENT_CHANNEL,
@@ -21,18 +32,6 @@ import {
redeemRpgProfileRewardCode,
watchWechatRpgProfileRechargeOrder,
} from '../../services/rpg-entry/rpgProfileClient';
import {
type ConfirmWechatProfileRechargeOrderResponse,
type ProfileRechargeCenterResponse,
type ProfileRechargeProduct,
type ProfileReferralInviteCenterResponse,
type ProfileTaskCenterResponse,
type ProfileWalletLedgerResponse,
type RedeemProfileRewardCodeResponse,
type WechatMiniProgramPayParams,
type WechatMiniProgramVirtualPayParams,
type WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import {
type CopyFeedbackState,
useCopyFeedback,
@@ -42,7 +41,6 @@ const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000;
const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
@@ -167,83 +165,6 @@ function readWechatPayResultFromHash(): WechatPayResult | null {
};
}
function loadWechatJsSdk() {
if (typeof window === 'undefined') {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
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('请在微信小程序内完成支付'));
}
};
if (existingScript) {
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('请在微信小程序内完成支付')),
{ once: true },
);
complete();
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error('请在微信小程序内完成支付'));
document.head.appendChild(script);
});
}
async function requestWechatMiniProgramPayment(
payload:
| WechatMiniProgramPayParams
| WechatMiniProgramVirtualPayParams
| null
| undefined,
orderId: string,
): Promise<void> {
if (!payload) {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
const wxBridge = await loadWechatJsSdk();
const miniProgram = wxBridge.miniProgram;
if (!miniProgram || typeof miniProgram.navigateTo !== 'function') {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
const navigateTo = miniProgram.navigateTo;
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
return new Promise<void>((resolve, reject) => {
navigateTo({
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
success() {
resolve();
},
fail(error) {
console.error('[wechat-pay] navigateTo failed', error);
reject(
error instanceof Error
? error
: new Error('请在微信小程序内完成支付'),
);
},
});
});
}
function waitWechatPayConfirmDelay(delayMs: number) {
return new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs);
@@ -601,10 +522,13 @@ export function usePlatformProfileCenterController({
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams,
response.order.orderId,
);
const paymentHandled = await requestHostPayment({
payload: response.wechatMiniProgramPayParams,
orderId: response.order.orderId,
});
if (!paymentHandled) {
throw new Error('请在微信小程序内完成支付');
}
return;
}
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {

View File

@@ -10,7 +10,7 @@ import {lockMobileViewportZoom} from './mobileViewportZoomLock';
import {resolveAppRoute} from './routing/appRoutes';
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
import {RouteLoadingScreen} from './routing/RouteLoadingScreen';
import {isWechatMiniProgramWebViewRuntime} from './services/authService';
import {getHostRuntime} from './services/host-bridge/hostBridge';
type AppRoot = ReturnType<typeof createRoot>;
@@ -28,7 +28,7 @@ if (!rootElement) {
}
function markWechatMiniProgramRuntime() {
if (isWechatMiniProgramWebViewRuntime()) {
if (getHostRuntime().kind === 'wechat_mini_program') {
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
}
}

View File

@@ -39,6 +39,10 @@ import {
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
export {
isWechatMiniProgramWebViewRuntime,
requestHostLogin as requestWechatMiniProgramPhoneLogin,
} from './host-bridge/hostBridge';
export type AuthSessionSnapshot = {
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
@@ -55,10 +59,6 @@ export type ConsumedAuthCallback = {
error: string | null;
};
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';
// 登录前公开认证入口不能误带旧 token也不能先触发 refresh 探测,
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
const PUBLIC_AUTH_REQUEST_OPTIONS = {
@@ -84,98 +84,6 @@ export function clearRuntimeGuestTokenCache() {
runtimeGuestTokenCache.value = null;
}
export function isWechatMiniProgramWebViewRuntime() {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search || '');
const userAgent =
typeof navigator === 'undefined' ? '' : navigator.userAgent || '';
const normalizedUserAgent = userAgent.toLowerCase();
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program' ||
(normalizedUserAgent.includes('micromessenger') &&
normalizedUserAgent.includes('miniprogram')) ||
Boolean(window.wx?.miniProgram?.postMessage)
);
}
function loadWechatMiniProgramBridge() {
if (typeof window === 'undefined') {
return Promise.reject(new Error('请在微信小程序内完成登录'));
}
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('请在微信小程序内完成登录'));
}
};
if (existingScript) {
if (window.wx?.miniProgram?.navigateTo) {
complete();
return;
}
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('请在微信小程序内完成登录')),
{ 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('请在微信小程序内完成登录'));
document.head.appendChild(script);
});
}
export async function requestWechatMiniProgramPhoneLogin() {
if (!isWechatMiniProgramWebViewRuntime()) {
return false;
}
const wxBridge = await loadWechatMiniProgramBridge();
const miniProgram = wxBridge.miniProgram;
const navigateTo = miniProgram?.navigateTo;
if (typeof navigateTo !== 'function') {
return false;
}
await new Promise<void>((resolve, reject) => {
navigateTo({
url: MINI_PROGRAM_AUTH_PAGE_URL,
success() {
resolve();
},
fail(error) {
reject(
new Error(error?.errMsg || '请在微信小程序内完成登录'),
);
},
});
});
return true;
}
export async function ensureRuntimeGuestToken() {
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
return runtimeGuestTokenCache.value!;

View 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);
});
});

View 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);
}

View File

@@ -43,6 +43,18 @@ describe('resolveProfileRechargePaymentChannel', () => {
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
});
test('小程序桥未就绪但 UA 已带 miniProgram 时选择 wechat_mp_virtual', () => {
expect(
resolveProfileRechargePaymentChannel({
location: { search: '' },
navigator: {
userAgent:
'Mozilla/5.0 iPhone MicroMessenger/8.0.49 miniProgram',
},
}),
).toBe(WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL);
});
test('移动网页选择 wechat_h5', () => {
expect(
resolveProfileRechargePaymentChannel({

View File

@@ -1,3 +1,5 @@
import { getHostRuntime } from '../host-bridge/hostBridge';
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
export const WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL = 'wechat_mp_virtual';
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
@@ -31,7 +33,7 @@ export function shouldShowRechargeEntry(
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
return (
isWechatMiniProgramRuntime(location) ||
isWechatMiniProgramRuntime(location, navigatorLike) ||
isWechatBrowserRuntime(navigatorLike)
);
}
@@ -50,7 +52,7 @@ export function resolveProfileRechargePaymentChannel(
? window.matchMedia.bind(window)
: null);
if (isWechatMiniProgramRuntime(location)) {
if (isWechatMiniProgramRuntime(location, navigatorLike)) {
return WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL;
}
@@ -75,12 +77,13 @@ export function isManualMockPaymentChannel(paymentChannel: string) {
export function isWechatMiniProgramRuntime(
location: Pick<Location, 'search'> | null | undefined =
typeof window !== 'undefined' ? window.location : null,
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined =
typeof navigator !== 'undefined' ? navigator : null,
) {
const params = new URLSearchParams(location?.search ?? '');
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program'
);
return getHostRuntime({
location,
navigator: navigatorLike,
}).kind === 'wechat_mini_program';
}
function isWechatBrowserRuntime(

View File

@@ -1,96 +1,4 @@
import { isWechatMiniProgramWebViewRuntime } from './authService';
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const SHARE_GRID_PAGE_URL = '/pages/share-grid/index';
function loadWechatMiniProgramBridge() {
if (
typeof window === 'undefined' ||
!isWechatMiniProgramWebViewRuntime()
) {
return Promise.reject(new Error('not_mini_program'));
}
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('wechat_js_sdk_unavailable'));
}
};
if (existingScript) {
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('wechat_js_sdk_load_failed')),
{ once: true },
);
complete();
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error('wechat_js_sdk_load_failed'));
document.head.appendChild(script);
});
}
function buildAbsoluteUrl(value: string) {
if (typeof window === 'undefined') {
return value;
}
return new URL(value, window.location.origin).href;
}
export function canUseWechatMiniProgramShareGrid() {
return isWechatMiniProgramWebViewRuntime();
}
export async function openWechatMiniProgramShareGridPage(params: {
imageUrl: string;
title: string;
publicWorkCode: string;
}) {
const imageUrl = params.imageUrl.trim();
if (!imageUrl) {
return false;
}
const wxBridge = await loadWechatMiniProgramBridge();
const miniProgram = wxBridge.miniProgram;
if (!miniProgram?.navigateTo) {
return false;
}
const searchParams = new URLSearchParams({
imageUrl: buildAbsoluteUrl(imageUrl),
title: params.title.trim() || '我的作品',
publicWorkCode: params.publicWorkCode.trim(),
});
const url = `${SHARE_GRID_PAGE_URL}?${searchParams.toString()}`;
return await new Promise<boolean>((resolve) => {
miniProgram.navigateTo?.({
url,
success() {
resolve(true);
},
fail() {
resolve(false);
},
});
});
}
export {
canUseHostShareGrid as canUseWechatMiniProgramShareGrid,
openHostShareGrid as openWechatMiniProgramShareGridPage,
} from './host-bridge/hostBridge';

View File

@@ -1,4 +1,4 @@
import { isWechatMiniProgramWebViewRuntime } from './authService';
import { setHostShareTarget } from './host-bridge/hostBridge';
const MESSAGE_TYPE = 'genarrative:share-target';
@@ -38,14 +38,6 @@ export function buildWechatMiniProgramShareTargetMessage(
export function postWechatMiniProgramShareTarget(
target: WechatMiniProgramShareTarget | null | undefined,
) {
if (
typeof window === 'undefined' ||
!isWechatMiniProgramWebViewRuntime() ||
typeof window.wx?.miniProgram?.postMessage !== 'function'
) {
return false;
}
const message = buildWechatMiniProgramShareTargetMessage(target);
if (!message) {
return false;
@@ -53,8 +45,5 @@ export function postWechatMiniProgramShareTarget(
// 中文注释:微信 web-view 会在分享等时机把 postMessage 数据交给原生页,
// 小程序页据此把右上角系统分享指向当前推荐作品。
window.wx.miniProgram.postMessage({
data: message,
});
return true;
return setHostShareTarget(message);
}

View File

@@ -89,7 +89,18 @@ describe('wechatMiniProgramSubscribe', () => {
});
test('skips permission request outside mini program web-view', async () => {
const navigateTo = vi.fn();
const requested = await requestGenerationResultSubscribePermission();
expect(requested).toBe(false);
});
test('uses an already available mini program bridge even without query marker', async () => {
const navigateTo = vi.fn((options) => {
options.success?.();
window.setTimeout(() => {
window.dispatchEvent(new Event('focus'));
}, 0);
});
window.wx = {
miniProgram: {
navigateTo,
@@ -98,7 +109,11 @@ describe('wechatMiniProgramSubscribe', () => {
const requested = await requestGenerationResultSubscribePermission();
expect(requested).toBe(false);
expect(navigateTo).not.toHaveBeenCalled();
expect(requested).toBe(true);
expect(navigateTo).toHaveBeenCalledWith({
url: expect.stringMatching(/^\/pages\/subscribe-message\/index\?/u),
success: expect.any(Function),
fail: expect.any(Function),
});
});
});

View File

@@ -1,6 +1,8 @@
import { isWechatMiniProgramRuntime } from './payment/paymentPlatform';
import {
getHostRuntime,
navigateHostNativePage,
} from './host-bridge/hostBridge';
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result';
const SUBSCRIBE_RESULT_TIMEOUT_MS = 2_500;
const SUBSCRIBE_RESULT_RETURN_FALLBACK_MS = 800;
@@ -96,80 +98,33 @@ function waitSubscribeResultFromHash(timeoutMs = SUBSCRIBE_RESULT_TIMEOUT_MS) {
});
}
function loadWechatJsSdk() {
export async function requestGenerationResultSubscribePermission() {
if (
!isWechatMiniProgramRuntime() ||
getHostRuntime().kind !== 'wechat_mini_program' ||
typeof window === 'undefined'
) {
return Promise.reject(new Error('not_mini_program'));
}
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('wechat_js_sdk_unavailable'));
}
};
if (existingScript) {
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener('error', () => reject(new Error('wechat_js_sdk_load_failed')), {
once: true,
});
complete();
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error('wechat_js_sdk_load_failed'));
document.head.appendChild(script);
});
}
export async function requestGenerationResultSubscribePermission() {
if (!isWechatMiniProgramRuntime() || typeof window === 'undefined') {
return false;
}
let wxBridge: NonNullable<Window['wx']>;
try {
wxBridge = await loadWechatJsSdk();
} catch {
return false;
}
const miniProgram = wxBridge.miniProgram;
if (!miniProgram || typeof miniProgram.navigateTo !== 'function') {
return false;
}
const requestId = `subscribe_generation_result_${Date.now()}`;
const resultPromise = waitSubscribeResultFromHash();
const navigated = await new Promise<boolean>((resolve) => {
miniProgram.navigateTo?.({
url: `/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`,
success() {
resolve(true);
let resultPromise: Promise<string | null> | null = null;
try {
const navigated = await navigateHostNativePage(
`/pages/subscribe-message/index?requestId=${encodeURIComponent(requestId)}&scene=generation-result`,
{
errorMessage: 'wechat_js_sdk_unavailable',
beforeNavigate() {
resultPromise = waitSubscribeResultFromHash();
},
},
fail() {
resolve(false);
},
});
});
if (!navigated) {
);
if (!navigated) {
return false;
}
} catch {
return false;
}
const result = await resultPromise;
const result = await (resultPromise ?? Promise.resolve(null));
return Boolean(result);
}