From 87542697054dfe987790e4d9280da71725784b01 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 14:06:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3H5=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 共享HostBridge契约导出唯一Tauri命令名 H5原生壳Transport改用共享Tauri命令常量 桌面壳配置检查对齐H5与Rust命令入口 宿主壳方案文档补充Tauri命令入口门禁 共享决策日志记录H5 Tauri命令边界 --- apps/desktop-shell/scripts/check-config.mjs | 42 ++++++++++++++++++- .../shared-memory/decision-log.md | 7 ++++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + .../shared/src/contracts/hostBridge.test.ts | 5 +++ packages/shared/src/contracts/hostBridge.ts | 1 + .../host-bridge/nativeAppHostBridge.test.ts | 3 +- .../host-bridge/nativeAppHostBridge.ts | 3 +- 7 files changed, 60 insertions(+), 3 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 8a15fdbf0..23f1d1799 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -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'], diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a3795520f..8ba054c38 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 / event,sandbox iframe 可以构造同形 `postMessage` 干扰待处理宿主请求或伪造宿主事件。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 6b7dd3908..59229d8ea 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -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 客户端 API;Tauri 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 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 20a9e8bd1..e432dbff4 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -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', diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 460f52fa7..813c3f412 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -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'; diff --git a/src/services/host-bridge/nativeAppHostBridge.test.ts b/src/services/host-bridge/nativeAppHostBridge.test.ts index 179ea024b..faabe5725 100644 --- a/src/services/host-bridge/nativeAppHostBridge.test.ts +++ b/src/services/host-bridge/nativeAppHostBridge.test.ts @@ -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', }), diff --git a/src/services/host-bridge/nativeAppHostBridge.ts b/src/services/host-bridge/nativeAppHostBridge.ts index 3a0041fe0..dfcd145d8 100644 --- a/src/services/host-bridge/nativeAppHostBridge.ts +++ b/src/services/host-bridge/nativeAppHostBridge.ts @@ -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( const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke; if (typeof tauriInvoke === 'function') { const response = await tauriInvoke>( - 'host_bridge_request', + HOST_BRIDGE_TAURI_COMMAND, { request }, ); if (!isHostBridgeResponse(response)) {