加固宿主事件订阅门禁

新增原生壳检查脚本对 H5 HostBridge 事件订阅双能力门控的静态反查

补齐 HostBridge 事件订阅测试矩阵里的返回栈事件负向覆盖

更新宿主壳协议、壳方案和共享记忆中的事件门禁说明
This commit is contained in:
2026-06-19 08:41:42 +08:00
parent 153cd8db9c
commit c054e51580
6 changed files with 162 additions and 5 deletions
@@ -2455,8 +2455,8 @@
- 背景:`navigation.canGoBack` 订阅已经同时要求 `host.events` 和具体事件 capability,但 `app.lifecycle``network.statusChanged``file.imageDropped` 一度只校验具体事件 capability;旧壳或裁剪壳如果缺少 `host.events`,H5 仍可能绑定到不存在或不受控的事件通道。
- 决策:H5 所有 HostBridge 事件订阅入口统一使用“双能力门控”:必须同时声明 `host.events` 和对应事件 capability,才允许 `subscribeNativeAppHostBridgeEvent(...)` 绑定监听;缺任一能力时返回空取消函数。事件类 capability 继续不要求 request handler`host.events` 只表示宿主会通过 HostBridge message 注入受控事件,不作为 request method。
- 影响范围:`src/services/host-bridge/hostBridge.ts``src/services/host-bridge/hostBridge.test.ts`、宿主壳能力协议文档和 Expo / Tauri 宿主壳方案文档。
- 验证方式:`npm run test -- src/services/host-bridge/hostBridge.test.ts``npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
- 影响范围:`src/services/host-bridge/hostBridge.ts``src/services/host-bridge/hostBridge.test.ts``scripts/check-native-shells.mjs`宿主壳能力协议文档和 Expo / Tauri 宿主壳方案文档。
- 验证方式:`npm run test -- src/services/host-bridge/hostBridge.test.ts``npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`;根级原生壳门禁会反查共享事件清单、四个 H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,避免后续事件订阅绕过 `host.events`
## 2026-06-18 HostBridge capability / handler 关系门禁
@@ -216,7 +216,7 @@ npm run build
npm run check:native-shells
```
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、完整相对路径文档反查、H5 HostBridge 事件订阅双能力门控反查、移动端和桌面端单端源码清单门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。
该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。
该命令同时会运行微信小程序 `miniprogram/host-bridge/``miniprogram/shell/``pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。
该命令还会反查微信小程序 `app.json.pages``host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。
@@ -444,7 +444,7 @@ GameBridge 禁止:
2026-06-18 追加:HostBridge method 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳的 HostBridge 分发 case 不得处理共享契约外 methodTauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。两端配置检查会在 `npm run check:native-shells` 中拒绝 method 白名单漂移,新增宿主能力必须先更新共享契约,再落壳实现。
2026-06-19 追加:HostBridge event 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EVENTS` 是宿主注入事件名的唯一来源,当前只包含 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped`;事件名必须同时是 capability。Expo 壳事件注入函数使用 `HostBridgeEventName`Tauri 壳 `shell/events.rs` 镜像同一清单并在脚本生成前拒绝未知事件,H5 `nativeAppHostBridge` 只分发 `isHostBridgeEventName()` 认可的事件。H5 `subscribeHostAppLifecycle()``subscribeHostNetworkStatusChange()``subscribeHostNavigationCanGoBack()``subscribeHostImageDrop()` 必须同时校验 `host.events` 与对应事件 capability,缺任一能力时不绑定事件监听。
2026-06-19 追加:HostBridge event 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EVENTS` 是宿主注入事件名的唯一来源,当前只包含 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped`;事件名必须同时是 capability。Expo 壳事件注入函数使用 `HostBridgeEventName`Tauri 壳 `shell/events.rs` 镜像同一清单并在脚本生成前拒绝未知事件,H5 `nativeAppHostBridge` 只分发 `isHostBridgeEventName()` 认可的事件。H5 `subscribeHostAppLifecycle()``subscribeHostNetworkStatusChange()``subscribeHostNavigationCanGoBack()``subscribeHostImageDrop()` 必须同时校验 `host.events` 与对应事件 capability,缺任一能力时不绑定事件监听;根级 `npm run check:native-shells` 会反查这四个订阅 facade 都通过 `canUseNativeHostEventCapability(...)` 进入同一双能力门控,并拒绝新增事件后漏补 H5 facade 覆盖
2026-06-18 追加:HostBridge capability 与 request handler 关系进入门禁。共享契约中属于 request method 的 capability,如果被 Expo 或 Tauri 壳声明,就必须在对应壳的 HostBridge 分发中显式处理;反过来,壳分发中处理的 method 必须已被该壳声明,登录 / 支付等等待真实 SDK 的 method 只能保留明确 `unsupported_method` 路径。`host.events``app.lifecycle``network.statusChanged``file.imageDropped``navigation.canGoBack` 等事件类 capability 不要求 request handler。
@@ -73,7 +73,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;反馈页上传凭证、个人资料头像上传和方洞结果页图片槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。
HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。
HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。`npm run check:native-shells` 会反查共享事件清单、H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,防止后续事件订阅绕过双能力门控。
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg``audio/mp4``audio/wav``audio/ogg``audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。H5 的通用音频输入面板 `CreativeAudioInputPanel``native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。单次音频不超过 20 MiB,成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob``fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。
+121
View File
@@ -92,6 +92,24 @@ const h5HostBridgeRequiredCallChainFiles = [
'src/services/wechatMiniProgramSubscribe.ts',
'src/services/wechatMiniProgramShareGrid.ts',
];
const h5HostBridgeEventSubscriptionFacades = [
{
functionName: 'subscribeHostAppLifecycle',
eventName: 'app.lifecycle',
},
{
functionName: 'subscribeHostNetworkStatusChange',
eventName: 'network.statusChanged',
},
{
functionName: 'subscribeHostNavigationCanGoBack',
eventName: 'navigation.canGoBack',
},
{
functionName: 'subscribeHostImageDrop',
eventName: 'file.imageDropped',
},
];
const expectedWechatHostBridgeFiles = [
'dispatch.js',
'payment.js',
@@ -716,6 +734,41 @@ function extractStringConst(source, constName) {
return match[1];
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractFunctionSource(source, functionName) {
const declarationStart = source.indexOf(`function ${functionName}`);
const exportedDeclarationStart = source.indexOf(`export function ${functionName}`);
const start =
exportedDeclarationStart === -1
? declarationStart
: exportedDeclarationStart;
if (start === -1) {
throw new Error(`unable to read function ${functionName}`);
}
const openBrace = source.indexOf('{', start);
if (openBrace === -1) {
throw new Error(`unable to read function body ${functionName}`);
}
let depth = 0;
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') {
depth += 1;
} else if (source[index] === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(start, index + 1);
}
}
}
throw new Error(`unterminated function body ${functionName}`);
}
function extractTsStringObject(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`),
@@ -732,6 +785,71 @@ function extractTsStringObject(source, exportName) {
);
}
function assertH5HostBridgeEventSubscriptionGates() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const h5HostBridgeSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.ts',
'utf8',
);
const sharedEvents = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
assertSameList(
h5HostBridgeEventSubscriptionFacades.map((entry) => entry.eventName),
sharedEvents,
'H5 HostBridge event subscription facade coverage',
);
const helperSource = extractFunctionSource(
h5HostBridgeSource,
'canUseNativeHostEventCapability',
);
if (
!helperSource.includes("canUseNativeHostCapability('host.events')") ||
!helperSource.includes('canUseNativeHostCapability(capability)')
) {
throw new Error(
'H5 HostBridge event capability helper must require host.events and the event capability',
);
}
const subscribedEvents = [
...h5HostBridgeSource.matchAll(
/subscribeNativeAppHostBridgeEvent(?:<[^>]+>)?\(\s*['"]([^'"]+)['"]/g,
),
].map((entry) => entry[1]);
assertSameList(
subscribedEvents,
sharedEvents,
'H5 HostBridge subscribed event list',
);
for (const { functionName, eventName } of h5HostBridgeEventSubscriptionFacades) {
const functionSource = extractFunctionSource(h5HostBridgeSource, functionName);
if (
!functionSource.includes(`canUseNativeHostEventCapability('${eventName}')`)
) {
throw new Error(
`${functionName} must gate ${eventName} with host.events and the event capability`,
);
}
const directCapabilityPattern = new RegExp(
`canUseNativeHostCapability\\('${escapeRegExp(eventName)}'\\)`,
);
if (directCapabilityPattern.test(functionSource)) {
throw new Error(
`${functionName} must not bypass canUseNativeHostEventCapability for ${eventName}`,
);
}
}
}
function extractDocumentCapabilityList(source, marker) {
const markerIndex = source.indexOf(marker);
if (markerIndex === -1) {
@@ -1294,6 +1412,9 @@ assertExternalUrlProtocolParity();
console.log('[check:native-shells] wechat-mini-program-route-parity');
assertWechatMiniProgramRouteParity();
console.log('[check:native-shells] h5-host-bridge-event-subscription-gates');
assertH5HostBridgeEventSubscriptionGates();
console.log('[check:native-shells] production-shell-dev-scaffold-scan');
assertNoProductionShellDevScaffoldTerms();
@@ -341,6 +341,7 @@ describe('hostBridge', () => {
test('事件类能力缺少宿主事件通道时不订阅原生事件', () => {
const lifecycleListener = vi.fn();
const networkListener = vi.fn();
const navigationListener = vi.fn();
const imageDropListener = vi.fn();
window.history.replaceState(null, '', nativeAppPath(['app.lifecycle']));
@@ -388,6 +389,29 @@ describe('hostBridge', () => {
);
unsubscribeNetwork();
window.history.replaceState(
null,
'',
nativeAppPath(['navigation.canGoBack']),
);
const unsubscribeNavigation =
subscribeHostNavigationCanGoBack(navigationListener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribeNavigation();
window.history.replaceState(
null,
'',
@@ -417,12 +441,14 @@ describe('hostBridge', () => {
expect(lifecycleListener).not.toHaveBeenCalled();
expect(networkListener).not.toHaveBeenCalled();
expect(navigationListener).not.toHaveBeenCalled();
expect(imageDropListener).not.toHaveBeenCalled();
});
test('事件通道缺少具体事件能力时不订阅原生事件', () => {
const lifecycleListener = vi.fn();
const networkListener = vi.fn();
const navigationListener = vi.fn();
const imageDropListener = vi.fn();
window.history.replaceState(null, '', nativeAppPath(['host.events']));
@@ -431,6 +457,8 @@ describe('hostBridge', () => {
subscribeHostAppLifecycle(lifecycleListener);
const unsubscribeNetwork =
subscribeHostNetworkStatusChange(networkListener);
const unsubscribeNavigation =
subscribeHostNavigationCanGoBack(navigationListener);
const unsubscribeImageDrop =
subscribeHostImageDrop(imageDropListener);
@@ -450,6 +478,12 @@ describe('hostBridge', () => {
connectionType: 'wifi',
},
},
{
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
},
{
event: 'file.imageDropped',
payload: {
@@ -477,10 +511,12 @@ describe('hostBridge', () => {
unsubscribeLifecycle();
unsubscribeNetwork();
unsubscribeNavigation();
unsubscribeImageDrop();
expect(lifecycleListener).not.toHaveBeenCalled();
expect(networkListener).not.toHaveBeenCalled();
expect(navigationListener).not.toHaveBeenCalled();
expect(imageDropListener).not.toHaveBeenCalled();
});