diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index b9551396a..fcd1e31d5 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1252,6 +1252,10 @@ const desktopHostBridgePayloadLimits = { }; const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS'); const desktopEvents = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_EVENTS'); +const desktopErrorCodes = extractRustStringArrayConst( + rustHostSource, + 'HOST_BRIDGE_ERROR_CODES', +); const desktopHostBridgeProtocol = extractRustStringConst( rustHostSource, 'HOST_BRIDGE_PROTOCOL', @@ -1287,6 +1291,25 @@ if (desktopHostBridgeVersion !== sharedHostBridgeVersion) { ); } +assertSameList( + desktopErrorCodes, + [ + 'invalid_request', + 'unsupported_method', + 'unsupported_capability', + 'timeout', + 'cancelled', + 'host_error', + ], + 'desktop shell HostBridge error codes', +); +if ( + !rustHostSource.includes('HOST_BRIDGE_ERROR_CODES.contains(&code)') || + !rustHostSource.includes('desktop host bridge request failed') +) { + throw new Error('desktop shell protocol must normalize non-contract host errors'); +} + if (desktopPublicWebOrigin !== sharedPublicWebOrigin) { throw new Error( `desktop shell public web origin drifted: expected ${sharedPublicWebOrigin} but got ${desktopPublicWebOrigin}`, diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs index 2942f4d30..66345aa2c 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs @@ -34,6 +34,14 @@ pub(crate) const HOST_BRIDGE_METHODS: [&str; 25] = [ ]; pub(crate) const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120; const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128; +const HOST_BRIDGE_ERROR_CODES: [&str; 6] = [ + "invalid_request", + "unsupported_method", + "unsupported_capability", + "timeout", + "cancelled", + "host_error", +]; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -159,6 +167,12 @@ pub(crate) fn failed( code: &'static str, message: impl Into, ) -> HostBridgeResponse { + let (code, message) = if HOST_BRIDGE_ERROR_CODES.contains(&code) { + (code, message.into()) + } else { + ("host_error", "desktop host bridge request failed".to_string()) + }; + HostBridgeResponse { bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, @@ -167,7 +181,7 @@ pub(crate) fn failed( result: None, error: Some(HostBridgeError { code, - message: message.into(), + message, }), } } @@ -300,6 +314,20 @@ mod tests { assert_eq!(error.message, "invalid host bridge method"); } + #[test] + fn invalid_error_code_is_normalized_before_response() { + let response = failed( + "request-1".to_string(), + "native_clipboard_failure", + "native clipboard failed", + ); + + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "host_error"); + assert_eq!(error.message, "desktop host bridge request failed"); + } + #[test] fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() { let replay_state = HostBridgeReplayState::default(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 10f36ccf4..28438ab1b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -93,7 +93,7 @@ - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝自动生成权限目录缺失、权限文件集合漂移、多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 桌面壳 capability 最小化:Tauri 主窗口 capability 只授予 `allow-host-bridge-request`,不得授予 `core:default`、`core:*:default`、任意 core 子权限或 dialog / fs / notification / opener / clipboard / deep-link / window-state 等插件权限。窗口、菜单、托盘、剪贴板、文件、通知和外链能力只能由 Rust 壳内部调用,再经 `host_bridge_request` 分发。 - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 -- 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。Expo 壳捕获原生异常时只透传共享 `HostBridgeError.code` 白名单内且 `message` 为字符串的协议错误;未知原生错误对象统一归一为 `host_error` 和固定失败文案,不把 native 私有字段、任意错误码或非字符串 message 回传给 H5。 +- 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。Expo 壳捕获原生异常时只透传共享 `HostBridgeError.code` 白名单内且 `message` 为字符串的协议错误,Tauri 壳的 `failed(...)` 出口也必须先校验同一错误码白名单;未知原生错误对象或非法错误码统一归一为 `host_error` 和固定失败文案,不把 native 私有字段、任意错误码或非字符串 message 回传给 H5。 - 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 method,Tauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。 - 2026-06-18 HostBridge capability / handler 关系门禁:两端壳声明 request method capability 时必须有对应 HostBridge handler;壳 handler 处理的 method 必须已被该壳声明,登录 / 支付等 SDK-backed method 只能保留明确 `unsupported_method` 路径。事件类 capability 不要求 request handler。 - 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。