原生壳补齐Tauri请求超时

H5 Tauri HostBridge transport 按 timeoutMs 释放卡住的 invoke 请求

补充 Tauri 超时、计时器清理和迟到回包不改写结果的单测

桌面壳配置门禁锁定 Tauri transport 超时封装

同步宿主壳方案文档和共享决策日志
This commit is contained in:
2026-06-18 23:02:07 +08:00
parent aca596a441
commit 6c17b4917f
5 changed files with 174 additions and 10 deletions
+16 -1
View File
@@ -773,7 +773,11 @@ function collectRustSourceRelativePaths(files) {
}
function extractNativeAppTauriInvokeCommands(source) {
return [...source.matchAll(/\btauriInvoke[\s\S]*?\(\s*([^,\n]+?)\s*,/g)]
return [
...source.matchAll(
/\btauriInvoke(?:<[^)]*>)?\(\s*([A-Z_][A-Z0-9_]*)\s*,/g,
),
]
.map((match) => match[1].trim())
.filter(Boolean);
}
@@ -1264,6 +1268,17 @@ assertSameList(
if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) {
throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND');
}
for (const snippet of [
'function createNativeHostBridgeTimeoutError()',
'async function invokeTauriHostBridgeWithTimeout',
'Promise.race',
'tauriInvoke<HostBridgeResponse<Result>>(HOST_BRIDGE_TAURI_COMMAND',
"message: 'host_bridge_timeout'",
]) {
if (!nativeAppHostBridgeSource.includes(snippet)) {
throw new Error(`H5 native app Tauri transport missing ${snippet}`);
}
}
assertSameList(
capability.windows ?? [],
@@ -2466,6 +2466,13 @@
- 影响范围:`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 H5 Tauri HostBridge 请求超时
- 背景:共享 HostBridge request 已包含 `timeoutMs`React Native WebView transport 会在 H5 侧释放超时请求,但 Tauri transport 如果直接等待 `core.invoke`Rust command 卡住时 H5 也会一直等待,违背“每个请求必须有超时”的壳层约束。
- 决策:`src/services/host-bridge/nativeAppHostBridge.ts` 的 Tauri transport 必须通过前端侧超时封装调用 `HOST_BRIDGE_TAURI_COMMAND`,与 React Native WebView transport 共享 `timeoutMs` 归一化和 `timeout / host_bridge_timeout` 错误语义。桌面宿主迟到返回时不能改写 H5 已拒绝的请求结果;`apps/desktop-shell/scripts/check-config.mjs` 锁定 Tauri transport 的超时封装,避免后续退回裸 `invoke`
- 影响范围:`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 src/services/host-bridge/hostBridge.test.ts``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-18 原生壳关键依赖版本收口
- 背景:Expo / React Native WebView / Tauri / Cargo 插件版本会直接影响 WebView 安全默认值、managed config 解析、production bundle、Tauri capability、插件初始化和 release 构建行为;如果只改依赖声明,壳行为可能绕过 HostBridge 门禁和现有验收口径静默漂移。
@@ -239,7 +239,7 @@ GameBridge 禁止:
- HostBridge request 必须校验 `bridge``version``id``method` 和 payload shape`id` 归一后必须是 1-120 字符且不含控制字符,`method` 必须来自共享白名单,未知 method 作为非法 request 拒绝。
- 壳层只接受来自允许 origin / packaged asset 的消息。
- H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。
- 每个请求必须有超时重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。
- 每个请求必须有超时H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。
- 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run mobile-shell:export``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、微信 WebView / 支付 / 订阅 / 分享桥接行为、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。
- Expo SDK、React Native、`react-native-webview`、Tauri CLI、Tauri Rust crate 和桌面 Cargo 插件版本属于宿主壳行为边界。升级这些依赖前必须同步更新壳配置检查、`package-lock.json` / `Cargo.lock` 解析版本、本文档和对应验证结果,不能只改 package / Cargo 版本让生产壳行为静默漂移。
@@ -264,7 +264,7 @@ GameBridge 禁止:
- 现有业务入口只通过 HostBridge 调用登录、支付、分享、原生页跳转。
- 增加 H5 单测覆盖:支持、超时、不支持、错误回包、浏览器 fallback。
当前状态:已新增 `src/services/host-bridge/nativeAppHostBridge.ts`,支持 React Native WebView `postMessage` 和 Tauri `invoke('host_bridge_request')` 两种真实 transport。登录、支付和原生页跳转如果宿主明确返回 `unsupported_method` / `unsupported_capability`,H5 回退到原有路径;生产代码不返回 mock 成功。
当前状态:已新增 `src/services/host-bridge/nativeAppHostBridge.ts`,支持 React Native WebView `postMessage` 和 Tauri `invoke('host_bridge_request')` 两种真实 transport。两条 transport 都会按 `timeoutMs` 在 H5 侧释放请求,超时统一抛出 `timeout / host_bridge_timeout`登录、支付和原生页跳转如果宿主明确返回 `unsupported_method` / `unsupported_capability`,H5 回退到原有路径;生产代码不返回 mock 成功。
### Phase 2Expo 移动壳 MVP
@@ -394,6 +394,8 @@ GameBridge 禁止:
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 追加:H5 到 Tauri 的 `core.invoke` 也必须经过 `nativeAppHostBridge` 的超时封装,不能裸等 Rust command Promise。Tauri transport 与 React Native WebView transport 共享 `timeoutMs` 归一化和 `timeout / host_bridge_timeout` 错误语义;如果桌面宿主迟到返回,H5 侧已拒绝的请求结果不得被改写。`apps/desktop-shell/scripts/check-config.mjs` 会锁定 H5 Tauri transport 的超时封装,避免后续退回裸 `invoke`
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 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。
@@ -119,6 +119,110 @@ describe('nativeAppHostBridge', () => {
});
});
test('Tauri invoke 超时时按 HostBridge 超时错误拒绝请求', async () => {
vi.useFakeTimers();
const invoke = vi.fn(
() =>
new Promise<HostBridgeResponse>(() => {
// 保持宿主命令挂起,让 H5 超时先返回。
}),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
const pending = requestNativeAppHostBridge('share.open', undefined, {
timeoutMs: 10,
});
const assertion = expect(pending).rejects.toMatchObject({
name: 'timeout',
message: 'host_bridge_timeout',
});
await vi.advanceTimersByTimeAsync(11);
await assertion;
expect(vi.getTimerCount()).toBe(0);
});
test('Tauri invoke 成功回包后清理超时计时器', async () => {
vi.useFakeTimers();
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
const request = (args as { request: HostBridgeRequest }).request;
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: true,
result: {
handled: true,
},
} satisfies HostBridgeResponse;
});
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(
requestNativeAppHostBridge('share.open', undefined, {
timeoutMs: 1000,
}),
).resolves.toEqual({
handled: true,
});
expect(vi.getTimerCount()).toBe(0);
});
test('Tauri invoke 超时后的迟到回包不改变已拒绝结果', async () => {
vi.useFakeTimers();
let resolveInvoke:
| ((response: HostBridgeResponse<{ handled: boolean }>) => void)
| null = null;
const invoke = vi.fn(
() =>
new Promise<HostBridgeResponse<{ handled: boolean }>>((resolve) => {
resolveInvoke = resolve;
}),
);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
const pending = requestNativeAppHostBridge('share.open', undefined, {
timeoutMs: 10,
});
const assertion = expect(pending).rejects.toMatchObject({
name: 'timeout',
message: 'host_bridge_timeout',
});
await vi.advanceTimersByTimeAsync(11);
await assertion;
const request = (invoke.mock.calls[0]?.[1] as { request: HostBridgeRequest })
.request;
resolveInvoke?.({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: true,
result: {
handled: true,
},
});
await Promise.resolve();
await expect(pending).rejects.toMatchObject({
name: 'timeout',
message: 'host_bridge_timeout',
});
});
test('没有原生宿主 bridge 时返回 null', async () => {
await expect(
requestNativeAppHostBridge('host.getRuntime'),
@@ -32,6 +32,11 @@ type PendingNativeRequest = {
timeoutId: ReturnType<typeof setTimeout>;
};
type NativeTauriInvoke = <Result = unknown>(
command: string,
args?: Record<string, unknown>,
) => Promise<Result>;
const pendingNativeRequests = new Map<string, PendingNativeRequest>();
const nativeEventListeners = new Map<string, Set<(payload: unknown) => void>>();
@@ -66,6 +71,13 @@ function createHostBridgeError(error: HostBridgeError) {
return result;
}
function createNativeHostBridgeTimeoutError() {
return createHostBridgeError({
code: 'timeout',
message: 'host_bridge_timeout',
});
}
function isHostBridgeResponse(value: unknown): value is HostBridgeResponse {
if (!value || typeof value !== 'object') {
return false;
@@ -174,6 +186,32 @@ function ensureNativeBridgeListener() {
nativeBridgeListenerInstalled = true;
}
async function invokeTauriHostBridgeWithTimeout<Result>(
tauriInvoke: NativeTauriInvoke,
request: HostBridgeRequest,
timeoutMs: number,
) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(createNativeHostBridgeTimeoutError());
}, timeoutMs);
});
try {
return await Promise.race([
tauriInvoke<HostBridgeResponse<Result>>(HOST_BRIDGE_TAURI_COMMAND, {
request,
}),
timeoutPromise,
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
export function canUseReactNativeHostBridge() {
return (
typeof resolveNativeWindow()?.ReactNativeWebView?.postMessage ===
@@ -233,9 +271,10 @@ export async function requestNativeAppHostBridge<Result = unknown>(
const tauriInvoke = nativeWindow.__TAURI__?.core?.invoke;
if (typeof tauriInvoke === 'function') {
const response = await tauriInvoke<HostBridgeResponse<Result>>(
HOST_BRIDGE_TAURI_COMMAND,
{ request },
const response = await invokeTauriHostBridgeWithTimeout<Result>(
tauriInvoke,
request,
timeoutMs,
);
if (!isHostBridgeResponse(response)) {
throw new Error('host_bridge_invalid_response');
@@ -256,10 +295,7 @@ export async function requestNativeAppHostBridge<Result = unknown>(
return await new Promise<Result>((resolve, reject) => {
const timeoutId = setTimeout(() => {
pendingNativeRequests.delete(request.id);
reject(createHostBridgeError({
code: 'timeout',
message: 'host_bridge_timeout',
}));
reject(createNativeHostBridgeTimeoutError());
}, timeoutMs);
pendingNativeRequests.set(request.id, {