统一微信壳桥接目录

新增 miniprogram/shell 承接微信 Page 生命周期与 wx 容器调用

收窄微信页面入口为 shell 页面工厂装配

更新三端 host-bridge/shell 目录门禁与认证 smoke

补充微信 shell 测试与 CommonJS 测试加载工具

同步宿主壳架构文档和项目共享记忆

补齐 api-server 外部生成测试 fixture 更新时间字段
This commit is contained in:
2026-06-18 16:32:25 +08:00
parent 00b41b6a29
commit 7609c37d5f
24 changed files with 1449 additions and 1022 deletions
@@ -85,6 +85,7 @@
- 2026-06-18 移动壳 WebView 安全开关:Expo 移动壳 WebView 必须显式禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;同源主站页面才能留在带 HostBridge 的 WebView 内,外链只通过受控协议离开容器交给系统。配置检查和移动壳导航测试会拒绝这些边界被放宽。
- 2026-06-18 移动壳 WebView 默认下载边界:Expo WebView 内网页自动下载和 `<a download>` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText``file.exportImage``file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。
- 2026-06-18 移动壳 HostBridge 消息来源校验:Expo 移动壳 `onMessage` 必须根据 `event.nativeEvent.url` 校验消息来源,只有同源主站页面能进入 `handleMobileHostBridgeMessage``about:blank`、外域、协议降级和危险协议页面消息直接丢弃,不返回宿主能力错误细节。该规则与 WebView 导航留壳规则共用同源判断,配置检查和移动壳导航测试会拒绝移除。
- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridgeTauri release 不允许任意远端页面调用桌面命令。
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md``docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`
@@ -50,7 +50,8 @@ apps/
mobile-shell/ # Expo + React Native App 壳
desktop-shell/ # Tauri 桌面 App 壳
miniprogram/
host-bridge/ # 微信小程序宿主桥接逻辑,页面只做装配
host-bridge/ # 微信小程序协议归一、支付 / 订阅 / 分享结果编解码
shell/ # 微信小程序 Page 生命周期、wx.* 容器调用和页面工厂
packages/
shared/
src/contracts/
@@ -63,7 +64,7 @@ src/
已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。
三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`可测试桥接逻辑统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。
三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/wechatShell*.js`,页面入口只做 `Page(createWechat...Page())` 装配Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。
## HostBridge 消息协议
@@ -37,7 +37,7 @@ AI H5 sandbox
-> parent HostBridge adapter
```
桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的可测试桥接逻辑统一放在 `miniprogram/host-bridge/wechatHostBridge*.js`页面目录只保留页面装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。
桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”三类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/wechatHostBridge*.js``miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配;Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 承接协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 承接 URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 承接协议、分发、文件和分享,`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接 WebView、托盘和容器行为,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。
## 首批能力
@@ -176,44 +176,13 @@ function notifyPreviousWebView(requestId, orderId, payResult) {
}
}
function createWechatPayPage(pageContext) {
return {
data: {
title: '正在拉起支付',
errorMessage: '',
},
async onLoad(query) {
const requestId = String(query.requestId || '');
const orderId = String(query.orderId || '');
const payParams = parsePayParams(query.payParams);
if (!requestId || !payParams) {
const page = pageContext ?? this;
page.setData({
title: '支付失败',
errorMessage: '缺少支付参数。',
});
return;
}
const payResult = await requestWechatPayment(payParams);
notifyPreviousWebView(requestId, orderId, payResult);
wx.navigateBack();
},
handleBack() {
wx.navigateBack();
},
};
}
module.exports = {
canUseVirtualPayment,
PAY_RESULT_STORAGE_KEY,
appendPayResult,
buildPayResultValue,
createWechatPayPage,
normalizePayError,
notifyPreviousWebView,
parsePayParams,
safeCompareVersion,
requestWechatPayment,
@@ -1,15 +1,17 @@
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import wechatPayBridge from './wechatHostBridgePayment.js';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const {
appendPayResult,
createWechatPayPage,
parsePayParams,
requestWechatPayment,
} = wechatPayBridge;
const payBridgePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/wechatHostBridgePayment.js',
);
describe('wechat-pay mini program payment bridge', () => {
let wechatPayBridge;
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
globalThis.wx = {
@@ -20,9 +22,11 @@ describe('wechat-pay mini program payment bridge', () => {
navigateBack: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => []);
wechatPayBridge = loadCommonJsModule(payBridgePath);
});
test('routes virtual payloads to wx.requestVirtualPayment', async () => {
const { requestWechatPayment } = wechatPayBridge;
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
});
@@ -49,6 +53,7 @@ describe('wechat-pay mini program payment bridge', () => {
});
test('routes goods virtual payloads to wx.requestVirtualPayment', async () => {
const { requestWechatPayment } = wechatPayBridge;
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
});
@@ -75,6 +80,7 @@ describe('wechat-pay mini program payment bridge', () => {
});
test('keeps ordinary requestPayment payloads on wx.requestPayment', async () => {
const { requestWechatPayment } = wechatPayBridge;
globalThis.wx.requestPayment.mockImplementationOnce((options) => {
options.success?.();
});
@@ -101,6 +107,7 @@ describe('wechat-pay mini program payment bridge', () => {
});
test('maps virtual payment cancel errCode to cancel result', async () => {
const { requestWechatPayment } = wechatPayBridge;
const payError = {
errCode: -2,
errMsg: 'requestVirtualPayment:fail cancel',
@@ -129,30 +136,17 @@ describe('wechat-pay mini program payment bridge', () => {
);
});
test('page notifies previous web-view after virtual payment', async () => {
test('notifies previous web-view after virtual payment', () => {
const { notifyPreviousWebView } = wechatPayBridge;
const previousPage = {
data: { webViewUrl: 'https://web.test/#tab=profile' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
});
const page = createWechatPayPage({
setData: vi.fn(),
});
await page.onLoad({
requestId: 'request-1',
orderId: 'order-1',
payParams: encodeURIComponent(
JSON.stringify({
mode: 'short_series_coin',
signData: '{}',
paySig: 'pay-sig',
signature: 'user-sig',
}),
),
notifyPreviousWebView('request-1', 'order-1', {
status: 'success',
errorMessage: '',
});
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
@@ -162,10 +156,10 @@ describe('wechat-pay mini program payment bridge', () => {
expect(previousPage.setData).toHaveBeenCalledWith({
webViewUrl: 'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1',
});
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
test('parsePayParams and appendPayResult keep existing behavior', () => {
const { appendPayResult, parsePayParams } = wechatPayBridge;
expect(parsePayParams(encodeURIComponent('{"paySign":"sig"}'))).toEqual({
paySign: 'sig',
});
@@ -34,7 +34,7 @@ function resolveSubscribeStatus(result, templateId) {
: 'skip';
}
function createSubscribeMessagePage(pageContext, options = {}) {
function createSubscribeMessagePageController(pageContext, options = {}) {
const templateId = String(options.templateId || '').trim();
const notifyPageResult = (methodThis, status, reason) => {
const page = pageContext ?? methodThis;
@@ -123,6 +123,6 @@ module.exports = {
SUBSCRIBE_RESULT_STORAGE_KEY,
appendSubscribeResult,
buildSubscribeResultValue,
createSubscribeMessagePage,
createSubscribeMessagePageController,
resolveSubscribeStatus,
};
@@ -1,17 +1,19 @@
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import subscribeMessageBridge from './wechatHostBridgeSubscribeMessage.js';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
const {
SUBSCRIBE_RESULT_STORAGE_KEY,
appendSubscribeResult,
buildSubscribeResultValue,
createSubscribeMessagePage,
} = subscribeMessageBridge;
const subscribeBridgePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js',
);
describe('subscribe-message mini program bridge', () => {
let subscribeMessageBridge;
beforeEach(() => {
globalThis.wx = {
requestSubscribeMessage: vi.fn(),
@@ -19,9 +21,14 @@ describe('subscribe-message mini program bridge', () => {
navigateBack: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => []);
subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath);
});
test('requests subscribe message and stores result before returning', () => {
const {
SUBSCRIBE_RESULT_STORAGE_KEY,
createSubscribeMessagePageController,
} = subscribeMessageBridge;
const previousPage = {
data: { webViewUrl: 'https://web.test/#tab=create' },
setData: vi.fn(),
@@ -32,7 +39,7 @@ describe('subscribe-message mini program bridge', () => {
m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept',
});
});
const page = createSubscribeMessagePage(
const page = createSubscribeMessagePageController(
{
setData: vi.fn(),
},
@@ -56,12 +63,16 @@ describe('subscribe-message mini program bridge', () => {
});
test('skip action notifies previous web-view', () => {
const {
SUBSCRIBE_RESULT_STORAGE_KEY,
createSubscribeMessagePageController,
} = subscribeMessageBridge;
const previousPage = {
data: { webViewUrl: 'https://web.test/' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
const page = createSubscribeMessagePage(
const page = createSubscribeMessagePageController(
{
setData: vi.fn(),
},
@@ -80,6 +91,8 @@ describe('subscribe-message mini program bridge', () => {
});
test('appendSubscribeResult replaces stale subscribe hash', () => {
const { appendSubscribeResult, buildSubscribeResultValue } =
subscribeMessageBridge;
expect(
appendSubscribeResult(
'https://web.test/#old=1&wx_subscribe_result=old',
+3 -204
View File
@@ -1,206 +1,5 @@
/* global Page, wx */
/* eslint-disable no-console */
/* global Page */
const {
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
} = require('../../host-bridge/wechatHostBridgeShareGrid');
const { createWechatShareGridPage } = require('../../shell/wechatShellShareGrid');
function downloadImage(imageUrl) {
return new Promise((resolve, reject) => {
wx.downloadFile({
url: imageUrl,
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.tempFilePath);
return;
}
reject(new Error(`封面下载失败:${response.statusCode}`));
},
fail(error) {
reject(new Error(error.errMsg || '封面下载失败'));
},
});
});
}
function getImageInfo(src) {
return new Promise((resolve, reject) => {
wx.getImageInfo({
src,
success: resolve,
fail(error) {
reject(new Error(error.errMsg || '读取封面失败'));
},
});
});
}
function getCanvasNode(page) {
return new Promise((resolve, reject) => {
wx.createSelectorQuery()
.in(page)
.select('#share-grid-canvas')
.fields({ node: true, size: true })
.exec((results) => {
const canvas = results && results[0] && results[0].node;
if (canvas) {
resolve(canvas);
return;
}
reject(new Error('切图画布初始化失败'));
});
});
}
function canvasToTempFilePath(canvas, width, height) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
width,
height,
destWidth: width,
destHeight: height,
fileType: 'png',
success(response) {
resolve(response.tempFilePath);
},
fail(error) {
reject(new Error(error.errMsg || '导出切图失败'));
},
});
});
}
function saveImageToAlbum(filePath) {
return new Promise((resolve, reject) => {
wx.saveImageToPhotosAlbum({
filePath,
success() {
resolve();
},
fail(error) {
reject(new Error(error.errMsg || '保存到相册失败'));
},
});
});
}
function copyTempFileWithName(tempFilePath, fileName) {
const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager();
const userDataPath = wx.env && wx.env.USER_DATA_PATH;
if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') {
return Promise.resolve(tempFilePath);
}
const targetPath = `${userDataPath}/${fileName}`;
return new Promise((resolve) => {
fileSystem.copyFile({
srcPath: tempFilePath,
destPath: targetPath,
success() {
resolve(targetPath);
},
fail() {
resolve(tempFilePath);
},
});
});
}
async function saveGridTiles(page, params, localImagePath, imageInfo) {
const canvas = await getCanvasNode(page);
const context = canvas.getContext('2d');
const image = canvas.createImage();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error('封面绘制失败'));
image.src = localImagePath;
});
const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height);
for (const tile of plan) {
canvas.width = tile.sourceWidth;
canvas.height = tile.sourceHeight;
context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight);
context.drawImage(
image,
tile.sourceX,
tile.sourceY,
tile.sourceWidth,
tile.sourceHeight,
0,
0,
tile.sourceWidth,
tile.sourceHeight,
);
const tempFilePath = await canvasToTempFilePath(
canvas,
tile.sourceWidth,
tile.sourceHeight,
);
const namedFilePath = await copyTempFileWithName(
tempFilePath,
buildShareGridTileFileName(params, tile.index),
);
await saveImageToAlbum(namedFilePath);
page.setData({
savedCount: tile.index + 1,
});
}
}
Page({
data: {
errorMessage: '',
loading: true,
savedCount: 0,
title: '九宫切图',
},
async onLoad(query = {}) {
const params = normalizeShareGridQuery(query);
this._shareGridParams = params;
this.setData({
errorMessage: '',
loading: true,
savedCount: 0,
title: params.title,
});
if (!params.imageUrl) {
this.setData({
errorMessage: '缺少封面图。',
loading: false,
});
return;
}
try {
const localImagePath = await downloadImage(params.imageUrl);
const imageInfo = await getImageInfo(localImagePath);
await saveGridTiles(this, params, localImagePath, imageInfo);
this.setData({
loading: false,
savedCount: 9,
});
wx.showToast({
title: '已保存',
icon: 'success',
});
} catch (error) {
console.error('[share-grid] save failed', error);
this.setData({
errorMessage:
error && error.message ? error.message : '九宫切图保存失败。',
loading: false,
});
}
},
handleBack() {
wx.navigateBack();
},
});
Page(createWechatShareGridPage());
+1 -1
View File
@@ -3,7 +3,7 @@
const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config');
const {
createSubscribeMessagePage,
} = require('../../host-bridge/wechatHostBridgeSubscribeMessage');
} = require('../../shell/wechatShellSubscribeMessage');
Page(
createSubscribeMessagePage(null, {
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,3 +1,5 @@
const { createWechatPayPage } = require('../../host-bridge/wechatHostBridgePayment');
/* global Page */
const { createWechatPayPage } = require('../../shell/wechatShellPayment');
Page(createWechatPayPage());
+42
View File
@@ -0,0 +1,42 @@
/* global wx */
const {
notifyPreviousWebView,
parsePayParams,
requestWechatPayment,
} = require('../host-bridge/wechatHostBridgePayment');
function createWechatPayPage(pageContext) {
return {
data: {
title: '正在拉起支付',
errorMessage: '',
},
async onLoad(query) {
const requestId = String(query.requestId || '');
const orderId = String(query.orderId || '');
const payParams = parsePayParams(query.payParams);
if (!requestId || !payParams) {
const page = pageContext ?? this;
page.setData({
title: '支付失败',
errorMessage: '缺少支付参数。',
});
return;
}
const payResult = await requestWechatPayment(payParams);
notifyPreviousWebView(requestId, orderId, payResult);
wx.navigateBack();
},
handleBack() {
wx.navigateBack();
},
};
}
module.exports = {
createWechatPayPage,
};
@@ -0,0 +1,69 @@
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const payBridgePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/wechatHostBridgePayment.js',
);
const paymentShellPath = path.resolve(
process.cwd(),
'miniprogram/shell/wechatShellPayment.js',
);
describe('wechat payment shell page', () => {
let createWechatPayPage;
beforeEach(() => {
globalThis.wx = {
getSystemInfoSync: vi.fn(() => ({ SDKVersion: '2.32.0' })),
navigateBack: vi.fn(),
requestPayment: vi.fn(),
requestVirtualPayment: vi.fn(),
setStorageSync: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => []);
const wechatPayBridge = loadCommonJsModule(payBridgePath);
const wechatPaymentShell = loadCommonJsModule(paymentShellPath, {
'../host-bridge/wechatHostBridgePayment': wechatPayBridge,
});
createWechatPayPage = wechatPaymentShell.createWechatPayPage;
});
test('stores payment result and returns to previous web-view', async () => {
const previousPage = {
data: { webViewUrl: 'https://web.test/#tab=profile' },
setData: vi.fn(),
};
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
globalThis.wx.requestVirtualPayment.mockImplementationOnce((options) => {
options.success?.({ errMsg: 'requestVirtualPayment:ok' });
});
const page = createWechatPayPage({ setData: vi.fn() });
await page.onLoad({
requestId: 'request-1',
orderId: 'order-1',
payParams: encodeURIComponent(
JSON.stringify({
mode: 'short_series_coin',
signData: '{}',
paySig: 'pay-sig',
signature: 'user-sig',
}),
),
});
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
'genarrative:wechat-pay-result',
'request-1:success:order-1',
);
expect(previousPage.setData).toHaveBeenCalledWith({
webViewUrl:
'https://web.test/#tab=profile&wx_pay_result=request-1%3Asuccess%3Aorder-1',
});
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
});
+212
View File
@@ -0,0 +1,212 @@
/* global wx */
/* eslint-disable no-console */
const {
buildShareGridTileFileName,
buildShareGridTilePlan,
normalizeShareGridQuery,
} = require('../host-bridge/wechatHostBridgeShareGrid');
function downloadImage(imageUrl) {
return new Promise((resolve, reject) => {
wx.downloadFile({
url: imageUrl,
success(response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(response.tempFilePath);
return;
}
reject(new Error(`封面下载失败:${response.statusCode}`));
},
fail(error) {
reject(new Error(error.errMsg || '封面下载失败'));
},
});
});
}
function getImageInfo(src) {
return new Promise((resolve, reject) => {
wx.getImageInfo({
src,
success: resolve,
fail(error) {
reject(new Error(error.errMsg || '读取封面失败'));
},
});
});
}
function getCanvasNode(page) {
return new Promise((resolve, reject) => {
wx.createSelectorQuery()
.in(page)
.select('#share-grid-canvas')
.fields({ node: true, size: true })
.exec((results) => {
const canvas = results && results[0] && results[0].node;
if (canvas) {
resolve(canvas);
return;
}
reject(new Error('切图画布初始化失败'));
});
});
}
function canvasToTempFilePath(canvas, width, height) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
width,
height,
destWidth: width,
destHeight: height,
fileType: 'png',
success(response) {
resolve(response.tempFilePath);
},
fail(error) {
reject(new Error(error.errMsg || '导出切图失败'));
},
});
});
}
function saveImageToAlbum(filePath) {
return new Promise((resolve, reject) => {
wx.saveImageToPhotosAlbum({
filePath,
success() {
resolve();
},
fail(error) {
reject(new Error(error.errMsg || '保存到相册失败'));
},
});
});
}
function copyTempFileWithName(tempFilePath, fileName) {
const fileSystem = wx.getFileSystemManager && wx.getFileSystemManager();
const userDataPath = wx.env && wx.env.USER_DATA_PATH;
if (!fileSystem || !userDataPath || typeof fileSystem.copyFile !== 'function') {
return Promise.resolve(tempFilePath);
}
const targetPath = `${userDataPath}/${fileName}`;
return new Promise((resolve) => {
fileSystem.copyFile({
srcPath: tempFilePath,
destPath: targetPath,
success() {
resolve(targetPath);
},
fail() {
resolve(tempFilePath);
},
});
});
}
async function saveGridTiles(page, params, localImagePath, imageInfo) {
const canvas = await getCanvasNode(page);
const context = canvas.getContext('2d');
const image = canvas.createImage();
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error('封面绘制失败'));
image.src = localImagePath;
});
const plan = buildShareGridTilePlan(imageInfo.width, imageInfo.height);
for (const tile of plan) {
canvas.width = tile.sourceWidth;
canvas.height = tile.sourceHeight;
context.clearRect(0, 0, tile.sourceWidth, tile.sourceHeight);
context.drawImage(
image,
tile.sourceX,
tile.sourceY,
tile.sourceWidth,
tile.sourceHeight,
0,
0,
tile.sourceWidth,
tile.sourceHeight,
);
const tempFilePath = await canvasToTempFilePath(
canvas,
tile.sourceWidth,
tile.sourceHeight,
);
const namedFilePath = await copyTempFileWithName(
tempFilePath,
buildShareGridTileFileName(params, tile.index),
);
await saveImageToAlbum(namedFilePath);
page.setData({
savedCount: tile.index + 1,
});
}
}
function createWechatShareGridPage() {
return {
data: {
errorMessage: '',
loading: true,
savedCount: 0,
title: '九宫切图',
},
async onLoad(query = {}) {
const params = normalizeShareGridQuery(query);
this._shareGridParams = params;
this.setData({
errorMessage: '',
loading: true,
savedCount: 0,
title: params.title,
});
if (!params.imageUrl) {
this.setData({
errorMessage: '缺少封面图。',
loading: false,
});
return;
}
try {
const localImagePath = await downloadImage(params.imageUrl);
const imageInfo = await getImageInfo(localImagePath);
await saveGridTiles(this, params, localImagePath, imageInfo);
this.setData({
loading: false,
savedCount: 9,
});
wx.showToast({
title: '已保存',
icon: 'success',
});
} catch (error) {
console.error('[share-grid] save failed', error);
this.setData({
errorMessage:
error && error.message ? error.message : '九宫切图保存失败。',
loading: false,
});
}
},
handleBack() {
wx.navigateBack();
},
};
}
module.exports = {
createWechatShareGridPage,
};
@@ -0,0 +1,49 @@
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const shareGridBridgePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/wechatHostBridgeShareGrid.js',
);
const shareGridShellPath = path.resolve(
process.cwd(),
'miniprogram/shell/wechatShellShareGrid.js',
);
let createWechatShareGridPage;
function createPage() {
const page = createWechatShareGridPage();
return {
...page,
data: { ...page.data },
setData(patch) {
Object.assign(this.data, patch);
},
};
}
describe('wechat share-grid shell page', () => {
beforeEach(() => {
globalThis.wx = {
navigateBack: vi.fn(),
};
const shareGridBridge = loadCommonJsModule(shareGridBridgePath);
const shareGridShell = loadCommonJsModule(shareGridShellPath, {
'../host-bridge/wechatHostBridgeShareGrid': shareGridBridge,
});
createWechatShareGridPage = shareGridShell.createWechatShareGridPage;
});
test('shows a clear error when imageUrl is missing', async () => {
const page = createPage();
await page.onLoad({});
expect(page.data.loading).toBe(false);
expect(page.data.errorMessage).toBe('缺少封面图。');
});
});
@@ -0,0 +1,11 @@
const {
createSubscribeMessagePageController,
} = require('../host-bridge/wechatHostBridgeSubscribeMessage');
function createSubscribeMessagePage(pageContext, options = {}) {
return createSubscribeMessagePageController(pageContext, options);
}
module.exports = {
createSubscribeMessagePage,
};
@@ -0,0 +1,60 @@
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
const subscribeBridgePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.js',
);
const subscribeShellPath = path.resolve(
process.cwd(),
'miniprogram/shell/wechatShellSubscribeMessage.js',
);
describe('wechat subscribe-message shell page', () => {
let createSubscribeMessagePage;
beforeEach(() => {
globalThis.wx = {
navigateBack: vi.fn(),
requestSubscribeMessage: vi.fn(),
setStorageSync: vi.fn(),
};
const subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath);
const subscribeMessageShell = loadCommonJsModule(subscribeShellPath, {
'../host-bridge/wechatHostBridgeSubscribeMessage': subscribeMessageBridge,
});
createSubscribeMessagePage =
subscribeMessageShell.createSubscribeMessagePage;
});
test('requests generation-result subscribe template and stores result', () => {
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
options.success?.({
[TEST_TEMPLATE_ID]: 'accept',
});
});
const page = createSubscribeMessagePage(
{ setData: vi.fn() },
{ templateId: TEST_TEMPLATE_ID },
);
page.onLoad({ requestId: 'request-1' });
page.requestSubscribe();
expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({
tmplIds: [TEST_TEMPLATE_ID],
success: expect.any(Function),
fail: expect.any(Function),
});
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
'genarrative:wechat-subscribe-result',
'request-1:success',
);
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const webViewBridgePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/wechatHostBridgeWebView.js',
);
const webViewShellPath = path.resolve(
process.cwd(),
'miniprogram/shell/wechatShellWebView.js',
);
const config = {
API_BASE_URL: 'https://www.genarrative.world',
DEV_API_BASE_URL: 'https://dev.genarrative.world',
DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world',
MINI_PROGRAM_APP_ID: 'wx-test-app',
MINI_PROGRAM_ENV: 'release',
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world',
WEB_VIEW_SOURCE_QUERY: {
clientType: 'mini_program',
clientRuntime: 'wechat_mini_program',
},
};
let createWechatWebViewPage;
function createPage() {
const page = createWechatWebViewPage();
return {
...page,
data: { ...page.data },
setData(patch) {
Object.assign(this.data, patch);
},
};
}
describe('wechat web-view shell page', () => {
beforeEach(() => {
globalThis.wx = {
getAccountInfoSync: vi.fn(() => ({
miniProgram: { envVersion: 'release' },
})),
getStorageSync: vi.fn(() => ''),
getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })),
login: vi.fn(),
navigateBack: vi.fn(),
removeStorageSync: vi.fn(),
request: vi.fn(),
setStorageSync: vi.fn(),
showShareMenu: vi.fn(),
};
const webViewBridge = loadCommonJsModule(webViewBridgePath);
const webViewShell = loadCommonJsModule(webViewShellPath, {
'../config': config,
'../host-bridge/wechatHostBridgeWebView': webViewBridge,
});
createWechatWebViewPage = webViewShell.createWechatWebViewPage;
});
test('opens anonymous H5 web-view without eager login', async () => {
const page = createPage();
await page.onLoad({});
expect(globalThis.wx.login).not.toHaveBeenCalled();
expect(globalThis.wx.request).not.toHaveBeenCalled();
expect(page.data.loading).toBe(false);
expect(page.data.webViewUrl).toBe(
'https://www.genarrative.world?clientType=mini_program&clientRuntime=wechat_mini_program',
);
expect(globalThis.wx.showShareMenu).toHaveBeenCalledWith({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline'],
});
});
test('stores latest H5 share target for native share menu', () => {
const page = createPage();
page.handleWebViewMessage({
detail: {
data: [
{
data: {
type: 'genarrative:share-target',
payload: {
targetPath: '/works/detail',
work: 'BB-12345678',
title: '汪汪声浪',
},
},
},
],
},
});
expect(page.onShareAppMessage()).toEqual({
title: '陶泥儿',
path: '/pages/web-view/index?targetPath=%2Fworks%2Fdetail&work=BB-12345678',
});
expect(page.onShareTimeline()).toEqual({
title: '陶泥儿',
query: 'targetPath=%2Fworks%2Fdetail&work=BB-12345678',
});
});
});
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
export function loadCommonJsModule(filePath, requireMap = {}) {
const module = { exports: {} };
const sandbox = {
clearTimeout,
console,
getCurrentPages(...args) {
return globalThis.getCurrentPages(...args);
},
module,
exports: module.exports,
require(requestPath) {
if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) {
return requireMap[requestPath];
}
throw new Error(`Unexpected require: ${requestPath}`);
},
setTimeout,
URL,
URLSearchParams,
wx: globalThis.wx,
};
vm.runInNewContext(readFileSync(filePath, 'utf8'), sandbox, {
filename: filePath,
});
return module.exports;
}
+32 -4
View File
@@ -17,6 +17,16 @@ const expectedWechatHostBridgeFiles = [
'wechatHostBridgeWebView.js',
'wechatHostBridgeWebView.test.js',
];
const expectedWechatShellFiles = [
'wechatShellPayment.js',
'wechatShellPayment.test.js',
'wechatShellShareGrid.js',
'wechatShellShareGrid.test.js',
'wechatShellSubscribeMessage.js',
'wechatShellSubscribeMessage.test.js',
'wechatShellWebView.js',
'wechatShellWebView.test.js',
];
const expectedMobileHostBridgeFiles = [
'mobileHostBridge.test.ts',
'mobileHostBridge.ts',
@@ -210,15 +220,33 @@ function assertHostBridgeLayerLayout() {
'wechat host bridge files',
);
for (const pagePath of [
const wechatShellFiles = fs
.readdirSync('miniprogram/shell', { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
assertSameList(
wechatShellFiles,
expectedWechatShellFiles,
'wechat shell files',
);
const wechatPagePaths = [
'miniprogram/pages/web-view/index.js',
'miniprogram/pages/wechat-pay/index.js',
'miniprogram/pages/share-grid/index.js',
'miniprogram/pages/subscribe-message/index.js',
]) {
];
for (const pagePath of wechatPagePaths) {
const source = fs.readFileSync(pagePath, 'utf8');
if (source.includes("require('./index.shared')")) {
throw new Error(`${pagePath} must import from miniprogram/host-bridge`);
if (!source.includes("require('../../shell/")) {
throw new Error(`${pagePath} must import from miniprogram/shell`);
}
if (
source.includes("require('../../host-bridge/") ||
source.includes("require('./index.shared')")
) {
throw new Error(`${pagePath} must not import bridge logic directly`);
}
}
@@ -54,22 +54,27 @@ if (failures.length > 0) {
console.log('\n[wechat-miniprogram-auth-smoke] 通过');
function checkMiniProgramShell() {
const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
const shellPath = join(repoRoot, 'miniprogram', 'shell', 'wechatShellWebView.js');
const webViewBridgePath = join(repoRoot, 'miniprogram', 'host-bridge', 'wechatHostBridgeWebView.js');
const pagePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml');
const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts');
ensureNeedles(pagePath, ["require('../../shell/wechatShellWebView')"]);
ensureNeedles(shellPath, [
'/api/auth/wechat/miniprogram-login',
'/api/auth/wechat/bind-phone',
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
'auth_provider',
'auth_token',
'auth_binding_status',
'bindingStatus',
'pending_bind_phone',
'wechatPhoneCode',
]);
ensureNeedles(webViewBridgePath, [
'auth_provider',
'auth_token',
'auth_binding_status',
]);
ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']);
+61 -41
View File
@@ -5,12 +5,17 @@ import vm from 'node:vm';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const repoRoot = process.cwd();
const pageScriptPath = join(
const shellScriptPath = join(
repoRoot,
'miniprogram',
'pages',
'web-view',
'index.js',
'shell',
'wechatShellWebView.js',
);
const webViewHostBridgePath = join(
repoRoot,
'miniprogram',
'host-bridge',
'wechatHostBridgeWebView.js',
);
type MiniProgramPage = {
@@ -43,45 +48,28 @@ function loadWebViewPage(
wxMock: ReturnType<typeof createWxMock>,
configOverrides: Record<string, unknown> = {},
) {
let pageConfig: Record<string, unknown> | null = null;
const source = readFileSync(pageScriptPath, 'utf8');
const sandbox = {
console,
getCurrentPages: () => [],
module: { exports: {} },
Page(config: Record<string, unknown>) {
pageConfig = config;
(globalThis as unknown as { wx: ReturnType<typeof createWxMock> }).wx =
wxMock;
const webViewBridge = loadCommonJsModule(webViewHostBridgePath, {});
const shellModule = loadCommonJsModule(shellScriptPath, {
'../config': {
API_BASE_URL: 'https://www.genarrative.world/',
DEV_API_BASE_URL: 'https://dev.genarrative.world/',
DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/',
MINI_PROGRAM_APP_ID: 'wx-test-app',
MINI_PROGRAM_ENV: 'release',
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
WEB_VIEW_SOURCE_QUERY: {
clientType: 'mini_program',
clientRuntime: 'wechat_mini_program',
},
...configOverrides,
},
setTimeout(callback: () => void) {
callback();
return 1;
},
require(requestPath: string) {
if (requestPath === '../../config') {
return {
API_BASE_URL: 'https://www.genarrative.world/',
DEV_API_BASE_URL: 'https://dev.genarrative.world/',
DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/',
MINI_PROGRAM_APP_ID: 'wx-test-app',
MINI_PROGRAM_ENV: 'release',
WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/',
WEB_VIEW_SOURCE_QUERY: {
clientType: 'mini_program',
clientRuntime: 'wechat_mini_program',
},
...configOverrides,
};
}
throw new Error(`Unexpected require: ${requestPath}`);
},
wx: wxMock,
'../host-bridge/wechatHostBridgeWebView': webViewBridge,
}) as {
createWechatWebViewPage: () => Record<string, unknown>;
};
vm.runInNewContext(source, sandbox, { filename: pageScriptPath });
if (!pageConfig) {
throw new Error('web-view page did not call Page()');
}
const pageConfig = shellModule.createWechatWebViewPage();
const page = {
...pageConfig,
@@ -94,6 +82,34 @@ function loadWebViewPage(
return page;
}
function loadCommonJsModule(
filePath: string,
requireMap: Record<string, unknown>,
) {
const source = readFileSync(filePath, 'utf8');
const module = { exports: {} as Record<string, unknown> };
const sandbox = {
console,
getCurrentPages: () => [],
module,
exports: module.exports,
setTimeout(callback: () => void) {
callback();
return 1;
},
require(requestPath: string) {
if (Object.prototype.hasOwnProperty.call(requireMap, requestPath)) {
return requireMap[requestPath];
}
throw new Error(`Unexpected require: ${requestPath}`);
},
wx: globalThis.wx,
};
vm.runInNewContext(source, sandbox, { filename: filePath });
return module.exports;
}
describe('mini-program web-view auth page', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -264,6 +280,10 @@ describe('mini-program web-view auth page', () => {
data: {
token: 'jwt-pending-wechat',
bindingStatus: 'pending_bind_phone',
user: {
displayName: '陶泥玩家',
publicUserCode: 'SY-12345678',
},
},
});
});
@@ -744,6 +744,7 @@ mod tests {
started_at: Some("2026-06-03T00:00:00Z".to_string()),
completed_at: None,
updated_at: "2026-06-03T00:00:00Z".to_string(),
updated_at_micros: 1_780_444_800_000_000,
lease_token: lease_token.map(ToOwned::to_owned),
}
}