收口H5桌面壳命令入口

共享HostBridge契约导出唯一Tauri命令名

H5原生壳Transport改用共享Tauri命令常量

桌面壳配置检查对齐H5与Rust命令入口

宿主壳方案文档补充Tauri命令入口门禁

共享决策日志记录H5 Tauri命令边界
This commit is contained in:
2026-06-18 14:06:05 +08:00
parent c2dd4424e4
commit 8754269705
7 changed files with 60 additions and 3 deletions
+41 -1
View File
@@ -25,6 +25,11 @@ const sharedContractPath = new URL(
import.meta.url,
);
const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8');
const nativeAppHostBridgePath = new URL(
'../../../src/services/host-bridge/nativeAppHostBridge.ts',
import.meta.url,
);
const nativeAppHostBridgeSource = fs.readFileSync(nativeAppHostBridgePath, 'utf8');
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
const main = fs.readFileSync(mainPath, 'utf8');
const productionSourceRoots = [
@@ -375,6 +380,17 @@ function extractRustStringArrayConst(source, constName) {
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractTsStringConst(source, constName) {
const match = source.match(
new RegExp(`export const ${constName}\\s*=\\s*'([^']+)';`),
);
if (!match) {
throw new Error(`unable to read TypeScript const ${constName}`);
}
return match[1];
}
function extractDesktopCapabilities(source) {
const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/);
if (!match) {
@@ -446,6 +462,12 @@ function extractTauriInvokeCommands(source) {
.filter(Boolean);
}
function extractNativeAppTauriInvokeCommands(source) {
return [...source.matchAll(/\btauriInvoke[\s\S]*?\(\s*([^,\n]+?)\s*,/g)]
.map((match) => match[1].trim())
.filter(Boolean);
}
function assertGeneratedPermissions(commandNames) {
if (!fs.existsSync(generatedPermissionDir)) {
return;
@@ -726,7 +748,11 @@ const allowedPermissions = [
'core:default',
'allow-host-bridge-request',
];
const allowedTauriCommands = ['host_bridge_request'];
const sharedTauriCommand = extractTsStringConst(
sharedContractSource,
'HOST_BRIDGE_TAURI_COMMAND',
);
const allowedTauriCommands = [sharedTauriCommand];
const requiredMainSnippets = [
'tauri_plugin_single_instance::init',
'resolve_desktop_single_instance_action',
@@ -821,6 +847,20 @@ const requiredMainSnippets = [
'replay_state.complete(slot, response)',
];
assertSameList(
allowedTauriCommands,
['host_bridge_request'],
'shared Tauri HostBridge command',
);
assertSameList(
extractNativeAppTauriInvokeCommands(nativeAppHostBridgeSource),
['HOST_BRIDGE_TAURI_COMMAND'],
'H5 native app Tauri invoke command source',
);
if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) {
throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND');
}
assertSameList(
capability.windows ?? [],
['main'],
@@ -2445,6 +2445,13 @@
- 影响范围:根 `package.json``apps/mobile-shell/package.json``apps/desktop-shell/package.json``apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 H5 Tauri command 入口收口
- 背景:桌面壳 Rust 侧已经只暴露 `host_bridge_request` 一个 command,但 H5 `nativeAppHostBridge` 如果直接写死 command 名或以后调用其它 Tauri command,会绕过共享 HostBridge 契约和桌面 capability 审计。
- 决策:`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_TAURI_COMMAND='host_bridge_request'` 作为 H5 到 Tauri 的唯一 command 名;`src/services/host-bridge/nativeAppHostBridge.ts` 必须通过该常量调用 `window.__TAURI__.core.invoke`。桌面壳配置检查同时对齐共享常量、Tauri build manifest、Rust `generate_handler!` 和 H5 transport,拒绝 H5 侧写死 command 字符串或调用其它 Tauri command。
- 影响范围:`packages/shared/src/contracts/hostBridge.ts``src/services/host-bridge/nativeAppHostBridge.ts``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts packages/shared/src/contracts/hostBridge.test.ts``npm run desktop-shell:typecheck``npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 原生 HostBridge 入站消息来源收口
- 背景:H5 主站会同时承载原生壳 HostBridge 和后续 AI H5 sandbox / GameBridge;如果 H5 侧只按 JSON envelope 识别 HostBridge response / eventsandbox iframe 可以构造同形 `postMessage` 干扰待处理宿主请求或伪造宿主事件。
@@ -352,6 +352,8 @@ GameBridge 禁止:
2026-06-18 追加:桌面壳 JS guest 依赖进入门禁。`apps/desktop-shell/package.json` 和根 H5 `package.json` 不得安装 `@tauri-apps/api` 或任何 `@tauri-apps/plugin-*` 包,避免生产前端绕过 `nativeAppHostBridge` 直接调用 Tauri JS 客户端 APITauri CLI 仍只作为构建工具留在 devDependencies,桌面系统能力继续由 Rust 侧 Cargo 插件和唯一 `host_bridge_request` command 承接。
2026-06-18 追加:H5 到 Tauri 的 command 名进入共享契约。`packages/shared/src/contracts/hostBridge.ts` 导出 `HOST_BRIDGE_TAURI_COMMAND='host_bridge_request'``nativeAppHostBridge` 只能通过该常量调用 Tauri 注入的 `core.invoke`;桌面壳配置检查会对齐共享常量、Tauri build manifest、Rust `generate_handler!` 和 H5 transport,禁止 H5 侧写死或调用其它 Tauri command。
2026-06-18 追加:原生壳注入消息来源进入门禁。Expo 和 Tauri 注入给 H5 的 HostBridge response / event 都显式带 `origin: window.location.origin``source: window`H5 `nativeAppHostBridge` listener 会忽略带非当前窗口 source 或非当前页面 origin 的 message。这样后续 AI sandbox iframe 即使能向父页面 `postMessage` 同形 envelope,也不能结算宿主请求或伪造宿主事件;GameBridge 继续走单独 allowlist。
2026-06-18 追加:HostBridge request id 进入宿主侧 replay 门禁。Expo 壳会缓存已完成响应并让进行中的同 id 请求共用同一执行结果;Tauri 壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 对同 id 请求做等待 / 回放。重复 id 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
HOST_BRIDGE_TAURI_COMMAND,
isHostBridgeMethod,
isHostBridgeCapability,
normalizeHostBridgeBadgeCount,
@@ -15,6 +16,10 @@ import {
} from './hostBridge';
describe('HostBridge shared contract helpers', () => {
test('固定 Tauri 只暴露唯一 HostBridge command', () => {
expect(HOST_BRIDGE_TAURI_COMMAND).toBe('host_bridge_request');
});
test('只允许明确的外链协议交给宿主打开', () => {
expect(normalizeHostBridgeExternalUrl(' https://example.com/a ')).toBe(
'https://example.com/a',
@@ -1,5 +1,6 @@
export const HOST_BRIDGE_PROTOCOL = 'GenarrativeHostBridge';
export const HOST_BRIDGE_VERSION = 1;
export const HOST_BRIDGE_TAURI_COMMAND = 'host_bridge_request';
export type HostShellKind = 'browser' | 'wechat_mini_program' | 'native_app';
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_TAURI_COMMAND,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
type HostBridgeResponse,
@@ -111,7 +112,7 @@ describe('nativeAppHostBridge', () => {
expect(canUseTauriHostBridge()).toBe(true);
expect(canUseNativeAppHostBridge()).toBe(true);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
expect(invoke).toHaveBeenCalledWith(HOST_BRIDGE_TAURI_COMMAND, {
request: expect.objectContaining({
method: 'auth.requestLogin',
}),
@@ -1,5 +1,6 @@
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_TAURI_COMMAND,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeEvent,
@@ -233,7 +234,7 @@ export async function requestNativeAppHostBridge<Result = unknown>(
const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke;
if (typeof tauriInvoke === 'function') {
const response = await tauriInvoke<HostBridgeResponse<Result>>(
'host_bridge_request',
HOST_BRIDGE_TAURI_COMMAND,
{ request },
);
if (!isHostBridgeResponse(response)) {