收口HostBridge重复请求回放

Expo壳按request id复用进行中请求并回放已完成响应

Tauri壳在唯一HostBridge命令外层增加重复请求等待与回放

补充两端测试、配置门禁、原生壳方案和共享决策记录
This commit is contained in:
2026-06-18 13:07:54 +08:00
parent e64b73e9b4
commit 4c4682069b
7 changed files with 209 additions and 12 deletions
@@ -705,6 +705,14 @@ const requiredMainSnippets = [
'DownloadEvent::Requested { .. } => false',
'.on_download(|_webview, event| should_allow_desktop_webview_download(&event))',
'NewWindowResponse::Deny',
'HostBridgeReplayState',
'HostBridgeReplayReservation',
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
'.manage(HostBridgeReplayState::default())',
'replay_state.reserve(&request.id)',
'HostBridgeReplayState::wait_for_response',
'execute_host_bridge_request(app, request).await',
'replay_state.complete(slot, response)',
];
assertSameList(
+118 -5
View File
@@ -1,10 +1,11 @@
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::fs;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
@@ -38,6 +39,7 @@ const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200;
const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80;
const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240;
const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000;
const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128;
const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray";
const TRAY_MENU_SHOW: &str = "show-main-window";
const TRAY_MENU_RELOAD: &str = "reload-main-window";
@@ -63,13 +65,13 @@ struct HostBridgeRuntime {
capabilities: Vec<&'static str>,
}
#[derive(Debug, Serialize)]
#[derive(Clone, Debug, Serialize)]
struct HostBridgeError {
code: &'static str,
message: String,
}
#[derive(Debug, Serialize)]
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct HostBridgeResponse {
bridge: &'static str,
@@ -87,6 +89,73 @@ struct DesktopShareState {
target: Mutex<Option<Value>>,
}
#[derive(Debug, Default)]
struct HostBridgeReplayState {
cache: Mutex<HostBridgeReplayCache>,
}
#[derive(Debug, Default)]
struct HostBridgeReplayCache {
order: Vec<String>,
slots: HashMap<String, Arc<HostBridgeReplaySlot>>,
}
#[derive(Debug, Default)]
struct HostBridgeReplaySlot {
response: Mutex<Option<HostBridgeResponse>>,
ready: Condvar,
}
#[derive(Debug)]
enum HostBridgeReplayReservation {
Execute(Arc<HostBridgeReplaySlot>),
Wait(Arc<HostBridgeReplaySlot>),
}
impl HostBridgeReplayState {
fn reserve(&self, request_id: &str) -> HostBridgeReplayReservation {
let mut cache = self.cache.lock().expect("host bridge replay cache lock");
if let Some(slot) = cache.slots.get(request_id) {
return HostBridgeReplayReservation::Wait(slot.clone());
}
let slot = Arc::new(HostBridgeReplaySlot::default());
cache.order.push(request_id.to_string());
cache.slots.insert(request_id.to_string(), slot.clone());
while cache.order.len() > HOST_BRIDGE_RESPONSE_CACHE_MAX {
if let Some(oldest_request_id) = cache.order.first().cloned() {
cache.order.remove(0);
cache.slots.remove(&oldest_request_id);
}
}
HostBridgeReplayReservation::Execute(slot)
}
fn complete(
&self,
slot: Arc<HostBridgeReplaySlot>,
response: HostBridgeResponse,
) -> HostBridgeResponse {
let mut stored_response = slot.response.lock().expect("host bridge replay slot lock");
*stored_response = Some(response.clone());
slot.ready.notify_all();
response
}
fn wait_for_response(slot: Arc<HostBridgeReplaySlot>) -> HostBridgeResponse {
let mut stored_response = slot.response.lock().expect("host bridge replay slot lock");
while stored_response.is_none() {
stored_response = slot
.ready
.wait(stored_response)
.expect("host bridge replay slot wait");
}
stored_response.clone().expect("host bridge replay response")
}
}
fn desktop_platform() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
@@ -1247,8 +1316,7 @@ fn resolve_host_bridge_request(request: HostBridgeRequest) -> HostBridgeResponse
}
}
#[tauri::command]
async fn host_bridge_request(
async fn execute_host_bridge_request(
app: tauri::AppHandle,
request: HostBridgeRequest,
) -> HostBridgeResponse {
@@ -1604,9 +1672,31 @@ async fn host_bridge_request(
}
}
#[tauri::command]
async fn host_bridge_request(
app: tauri::AppHandle,
replay_state: tauri::State<'_, HostBridgeReplayState>,
request: HostBridgeRequest,
) -> Result<HostBridgeResponse, String> {
if let Some(response) = validate_request(&request) {
return Ok(response);
}
let response = match replay_state.reserve(&request.id) {
HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot),
HostBridgeReplayReservation::Execute(slot) => {
let response = execute_host_bridge_request(app, request).await;
replay_state.complete(slot, response)
}
};
Ok(response)
}
fn main() {
tauri::Builder::default()
.manage(DesktopShareState::default())
.manage(HostBridgeReplayState::default())
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
match resolve_desktop_single_instance_action() {
DesktopSingleInstanceAction::ShowMainWindow => {
@@ -1813,6 +1903,29 @@ mod tests {
assert_eq!(response.error.expect("error").code, "invalid_request");
}
#[test]
fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() {
let replay_state = HostBridgeReplayState::default();
let mut side_effect_count = 0;
let first_reservation = replay_state.reserve("request-1");
let first_response = match first_reservation {
HostBridgeReplayReservation::Execute(slot) => {
side_effect_count += 1;
replay_state.complete(slot, ok("request-1".to_string(), json!(true)))
}
HostBridgeReplayReservation::Wait(_) => panic!("first request must execute"),
};
let second_response = match replay_state.reserve("request-1") {
HostBridgeReplayReservation::Execute(_) => panic!("duplicate request must not execute"),
HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot),
};
assert_eq!(side_effect_count, 1);
assert_eq!(second_response.ok, first_response.ok);
assert_eq!(second_response.result, first_response.result);
}
#[test]
fn invalid_string_payload_is_rejected() {
let mut invalid = request("clipboard.writeText");
@@ -649,6 +649,11 @@ for (const snippet of [
'normalizeHostBridgeExportFileName',
'normalizeHostBridgeClipboardText',
'base64Data',
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
'completedHostBridgeResponses',
'inFlightHostBridgeResponses',
'resolveMobileHostBridgeResponse',
'rememberHostBridgeResponse',
]) {
if (!bridgeSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge missing ${snippet}`);
+23 -1
View File
@@ -44,6 +44,8 @@ const DENIED_NOTIFICATION_PERMISSION = {
expires: 'never',
} as NotificationPermissionStatus;
let requestSequence = 0;
vi.mock('expo-clipboard', () => ({
getStringAsync: vi.fn(),
setStringAsync: vi.fn(),
@@ -171,10 +173,11 @@ function request(
method: HostBridgeMethod,
payload?: unknown,
): HostBridgeRequest {
requestSequence += 1;
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'request-1',
id: `request-${requestSequence}`,
method,
payload,
};
@@ -216,6 +219,7 @@ function setPlatformOS(os: 'ios' | 'android') {
}
afterEach(() => {
requestSequence = 0;
vi.mocked(Appearance.getColorScheme).mockReset();
vi.mocked(Appearance.getColorScheme).mockReturnValue('light');
vi.mocked(Haptics.impactAsync).mockReset();
@@ -465,6 +469,24 @@ describe('handleMobileHostBridgeMessage', () => {
expect(Linking.openURL).not.toHaveBeenCalled();
});
test('重复 HostBridge request id 回放首次结果且不重复触发系统动作', async () => {
const duplicateRequest = request('share.open', {
title: '测试作品',
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
});
const [firstResponse, secondResponse] = await Promise.all([
send(duplicateRequest),
send(duplicateRequest),
]);
const replayedResponse = await send(duplicateRequest);
expectOk(firstResponse);
expect(secondResponse).toEqual(firstResponse);
expect(replayedResponse).toEqual(firstResponse);
expect(Share.share).toHaveBeenCalledTimes(1);
});
test('network.status 返回 Expo Network 真实状态', async () => {
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
type: Network.NetworkStateType.CELLULAR,
+44 -5
View File
@@ -59,6 +59,7 @@ const IMPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local';
const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128;
const HOST_BRIDGE_TEXT_MIME_TYPES = new Set<HostBridgeTextMimeType>([
'text/plain',
'text/markdown',
@@ -132,6 +133,11 @@ export type MobileHostBridgeNavigation = {
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
const completedHostBridgeResponses = new Map<string, HostBridgeResponse>();
const inFlightHostBridgeResponses = new Map<
string,
Promise<HostBridgeResponse>
>();
export function configureMobileHostBridgeNavigation(
nextNavigation: MobileHostBridgeNavigation | null,
@@ -984,6 +990,41 @@ function normalizeError(error: unknown): HostBridgeError {
};
}
function rememberHostBridgeResponse(response: HostBridgeResponse) {
completedHostBridgeResponses.set(response.id, response);
if (completedHostBridgeResponses.size > HOST_BRIDGE_RESPONSE_CACHE_MAX) {
const oldestRequestId = completedHostBridgeResponses.keys().next().value;
if (oldestRequestId) {
completedHostBridgeResponses.delete(oldestRequestId);
}
}
return response;
}
async function resolveMobileHostBridgeResponse(request: HostBridgeRequest) {
const completedResponse = completedHostBridgeResponses.get(request.id);
if (completedResponse) {
return completedResponse;
}
const inFlightResponse = inFlightHostBridgeResponses.get(request.id);
if (inFlightResponse) {
return await inFlightResponse;
}
const responsePromise = handleRequest(request)
.catch((error: unknown) => failure(request, normalizeError(error)))
.then(rememberHostBridgeResponse);
inFlightHostBridgeResponses.set(request.id, responsePromise);
try {
return await responsePromise;
} finally {
inFlightHostBridgeResponses.delete(request.id);
}
}
export async function handleMobileHostBridgeMessage(
rawMessage: string,
sendResponse: (response: HostBridgeResponse) => void,
@@ -999,14 +1040,12 @@ export async function handleMobileHostBridgeMessage(
return;
}
try {
sendResponse(await handleRequest(parsed));
} catch (error) {
sendResponse(failure(parsed, normalizeError(error)));
}
sendResponse(await resolveMobileHostBridgeResponse(parsed));
}
export function resetMobileHostBridgeForTest() {
currentShareTarget = null;
navigation = null;
completedHostBridgeResponses.clear();
inFlightHostBridgeResponses.clear();
}
@@ -67,6 +67,7 @@
- 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `<a download>` 落盘,桌面文件保存只能通过 `file.exportText``file.exportImage``file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。
- 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。
- 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 HostBridge request id replayExpo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。
- 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:`
- 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 也会统一扫描两端壳生产源码,防止把临时替身、占位文案或伪实现带进可分发壳。
- 2026-06-18 移动壳启动页与 adaptive iconExpo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。
@@ -2384,6 +2385,13 @@
- 影响范围:根依赖、桌面壳依赖、桌面壳配置检查和 Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 HostBridge request id replay
- 背景:H5 transport 会为每个 HostBridge 请求生成 id 并设置超时,但系统分享、外链打开、文件选择 / 保存、本地通知和窗口导航等宿主副作用如果收到重复 id,不应因为消息重试、快速双发或 WebView 事件抖动被执行两次。
- 决策:Expo 移动壳缓存已完成响应,并让进行中的同 id 请求共用同一个执行 Promise;Tauri 桌面壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 让同 id 请求等待 / 回放首次结果。重复 id 只返回首次响应,不二次执行宿主能力。
- 影响范围:`apps/mobile-shell/src/mobileHostBridge.ts``apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 移动壳渠道 SDK 依赖收口
- 背景:Expo 移动壳运行时依赖可能从根安装树解析;如果只检查 `apps/mobile-shell/package.json`,根 H5 包仍可能直接引入 Expo Updates、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等移动端发布通道、崩溃上报或 analytics SDK,让壳边界绕过真实渠道契约。
@@ -232,7 +232,7 @@ GameBridge 禁止:
- HostBridge request 必须校验 `bridge``version``id``method` 和 payload shape。
- 壳层只接受来自允许 origin / packaged asset 的消息。
- H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作
- 每个请求必须有超时,重复 `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`、壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口和两端壳生产源码临时替身词扫描不得漂移。
- 登录和支付能力在真实 SDK、渠道流程、后端契约和失败回退全部落地前不得进入 Expo / Tauri capabilities,也不得写进入口 URL `hostCapabilities`;两端配置检查会拒绝 `auth.requestLogin``payment.request` 的伪声明。
@@ -348,6 +348,8 @@ GameBridge 禁止:
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 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。
2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*``ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp``apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。
2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。