原生壳补齐Tauri请求超时
H5 Tauri HostBridge transport 按 timeoutMs 释放卡住的 invoke 请求 补充 Tauri 超时、计时器清理和迟到回包不改写结果的单测 桌面壳配置门禁锁定 Tauri transport 超时封装 同步宿主壳方案文档和共享决策日志
This commit is contained in:
@@ -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 2:Expo 移动壳 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, {
|
||||
|
||||
Reference in New Issue
Block a user