收口HostBridge重复请求回放
Expo壳按request id复用进行中请求并回放已完成响应 Tauri壳在唯一HostBridge命令外层增加重复请求等待与回放 补充两端测试、配置门禁、原生壳方案和共享决策记录
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 replay:Expo 和 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 icon:Expo 移动壳启动页和 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 回归。
|
||||
|
||||
Reference in New Issue
Block a user