From ff64021c207fe6efe4815a5c4d34b75277e48d13 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 22:54:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3=E6=A1=8C=E9=9D=A2=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=A1=A5=E6=8E=A5=E6=89=A7=E8=A1=8C=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Tauri 文件导入导出执行编排收口到 files 模块 让桌面 dispatch 只委托文件能力请求 同步桌面壳文件边界门禁、架构文档和共享记忆 --- apps/desktop-shell/scripts/check-config.mjs | 89 ++++++- .../src-tauri/src/host_bridge/dispatch.rs | 211 +--------------- .../src-tauri/src/host_bridge/files.rs | 238 +++++++++++++++++- .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 1 + ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 + 6 files changed, 324 insertions(+), 218 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index f89b35891..44b5b5d3d 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -86,6 +86,14 @@ const desktopHostBridgeDispatchSource = fs.readFileSync( desktopHostBridgeDispatchPath, 'utf8', ); +const desktopHostBridgeFilesPath = new URL( + '../src-tauri/src/host_bridge/files.rs', + import.meta.url, +); +const desktopHostBridgeFilesSource = fs.readFileSync( + desktopHostBridgeFilesPath, + 'utf8', +); const desktopHostBridgeNavigationPath = new URL( '../src-tauri/src/host_bridge/navigation.rs', import.meta.url, @@ -951,7 +959,7 @@ function extractDesktopHandledMethods(source) { } function extractDesktopHostBridgeMethodBody(source, method) { - const methodStart = source.indexOf(`"${method}" => {`); + const methodStart = source.indexOf(`"${method}" =>`); if (methodStart < 0) { throw new Error(`desktop shell HostBridge missing method ${method}`); } @@ -968,29 +976,65 @@ function extractDesktopHostBridgeMethodBody(source, method) { ); } -function extractDesktopDialogFilter(source, method, filterLabel) { - const methodBody = extractDesktopHostBridgeMethodBody(source, method); - const match = methodBody.match( +function extractFunctionBody(source, functionName) { + const functionStart = source.search( + new RegExp(`(?:pub\\(crate\\)\\s+)?(?:async\\s+)?fn\\s+${functionName}\\s*\\(`), + ); + if (functionStart < 0) { + throw new Error(`unable to read Rust function ${functionName}`); + } + + const bodyStart = source.indexOf('{', functionStart); + if (bodyStart < 0) { + throw new Error(`unable to read Rust function body ${functionName}`); + } + + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const character = source[index]; + if (character === '{') { + depth += 1; + } else if (character === '}') { + depth -= 1; + if (depth === 0) { + return source.slice(bodyStart, index + 1); + } + } + } + + throw new Error(`unable to read Rust function body ${functionName}`); +} + +function extractDesktopDialogFilter(source, ownerName, filterLabel) { + const ownerBody = extractFunctionBody(source, ownerName); + const match = ownerBody.match( new RegExp(`\\.add_filter\\(\\s*"${filterLabel}",\\s*&\\[([^\\]]*)\\]\\s*,?\\s*\\)`), ); if (!match) { throw new Error( - `desktop shell ${method} must use a ${filterLabel} system dialog filter`, + `desktop shell ${ownerName} must use a ${filterLabel} system dialog filter`, ); } return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); } -function assertDesktopDialogBoundary(method, filterLabel, expected, action) { +function assertDesktopDialogBoundary(method, functionName, filterLabel, expected, action) { const methodBody = extractDesktopHostBridgeMethodBody( desktopHostBridgeDispatchSource, method, ); + if (!methodBody.includes(`${functionName}(&app, &request).await`)) { + throw new Error(`desktop shell ${method} must delegate to files module`); + } + const fileFunctionBody = extractFunctionBody( + desktopHostBridgeFilesSource, + functionName, + ); assertSameList( extractDesktopDialogFilter( - desktopHostBridgeDispatchSource, - method, + desktopHostBridgeFilesSource, + functionName, filterLabel, ), expected, @@ -1001,10 +1045,10 @@ function assertDesktopDialogBoundary(method, filterLabel, expected, action) { action === 'save' ? '.blocking_save_file()' : '.blocking_pick_file()'; const blockedDialogAction = action === 'save' ? '.blocking_pick_file()' : '.blocking_save_file()'; - if (!methodBody.includes(requiredDialogAction)) { + if (!fileFunctionBody.includes(requiredDialogAction)) { throw new Error(`desktop shell ${method} must use ${requiredDialogAction}`); } - if (methodBody.includes(blockedDialogAction)) { + if (fileFunctionBody.includes(blockedDialogAction)) { throw new Error(`desktop shell ${method} must not use ${blockedDialogAction}`); } } @@ -2327,46 +2371,71 @@ for (const staleTimeoutBoundary of [ assertDesktopDialogBoundary( 'file.exportText', + 'export_desktop_host_bridge_text_file', 'Text', ['txt', 'json', 'md', 'csv'], 'save', ); assertDesktopDialogBoundary( 'file.importText', + 'import_desktop_host_bridge_text_file', 'Text', ['txt', 'md', 'markdown', 'csv', 'json'], 'pick', ); assertDesktopDialogBoundary( 'file.importDocument', + 'import_desktop_host_bridge_document_file', 'Document', ['txt', 'md', 'markdown', 'csv', 'json', 'docx'], 'pick', ); assertDesktopDialogBoundary( 'file.exportImage', + 'export_desktop_host_bridge_image_file', 'Image', ['png', 'jpg', 'jpeg', 'webp'], 'save', ); assertDesktopDialogBoundary( 'file.importImage', + 'import_desktop_host_bridge_image_file', 'Image', ['png', 'jpg', 'jpeg', 'webp'], 'pick', ); assertDesktopDialogBoundary( 'file.importAudio', + 'import_desktop_host_bridge_audio_file', 'Audio', ['mp3', 'm4a', 'mp4', 'wav', 'ogg', 'webm'], 'pick', ); assertDesktopDialogBoundary( 'file.exportAudio', + 'export_desktop_host_bridge_audio_file', 'Audio', ['mp3', 'm4a', 'wav', 'ogg', 'webm'], 'save', ); +for (const snippet of [ + '.dialog()', + 'blocking_save_file', + 'blocking_pick_file', + 'export_text_payload', + 'import_text_file_payload', + 'import_document_file_payload', + 'export_image_payload', + 'import_image_file_payload', + 'import_audio_file_payload', + 'export_audio_payload', + 'write_export_text_file', + 'write_export_bytes_file', +]) { + if (desktopHostBridgeDispatchSource.includes(snippet)) { + throw new Error(`desktop shell dispatch must delegate file boundary instead of ${snippet}`); + } +} assertSameList( capability.windows ?? [], diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs index feff6f21c..71747c0cb 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs @@ -5,9 +5,10 @@ use crate::host_bridge::clipboard::{ read_desktop_host_bridge_clipboard_text, write_desktop_host_bridge_clipboard_text, }; use crate::host_bridge::files::{ - export_audio_payload, export_image_payload, export_text_payload, import_audio_file_payload, - import_document_file_payload, import_image_file_payload, import_text_file_payload, - write_export_bytes_file, write_export_text_file, + export_desktop_host_bridge_audio_file, export_desktop_host_bridge_image_file, + export_desktop_host_bridge_text_file, import_desktop_host_bridge_audio_file, + import_desktop_host_bridge_document_file, import_desktop_host_bridge_image_file, + import_desktop_host_bridge_text_file, }; use crate::host_bridge::navigation::{ open_desktop_host_bridge_external_url, open_desktop_host_bridge_native_page, @@ -25,7 +26,6 @@ use crate::host_bridge::share::{ use crate::host_bridge::title::set_desktop_host_bridge_window_title; use crate::shell::webview::desktop_platform; use serde_json::json; -use tauri_plugin_dialog::DialogExt; fn desktop_runtime() -> HostBridgeRuntime { HostBridgeRuntime { @@ -67,202 +67,13 @@ pub(super) async fn execute_host_bridge_request( "app.reloadWebView" => reload_desktop_host_bridge_webview(&app, &request), "clipboard.writeText" => write_desktop_host_bridge_clipboard_text(&app, &request), "clipboard.readText" => read_desktop_host_bridge_clipboard_text(&app, &request), - "file.exportText" => { - let (file_name, content) = match export_text_payload(&request) { - Ok(payload) => payload, - Err(response) => return response, - }; - let file_path = app - .dialog() - .file() - .add_filter("Text", &["txt", "json", "md", "csv"]) - .set_file_name(file_name.clone()) - .blocking_save_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let export_result = - tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content)) - .await; - let bytes = match export_result { - Ok(Ok(bytes)) => bytes, - Ok(Err(error)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": bytes, - }), - ) - } - "file.importText" => { - let file_path = app - .dialog() - .file() - .add_filter("Text", &["txt", "md", "markdown", "csv", "json"]) - .blocking_pick_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = - tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.importDocument" => { - let file_path = app - .dialog() - .file() - .add_filter( - "Document", - &["txt", "md", "markdown", "csv", "json", "docx"], - ) - .blocking_pick_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = - tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path)) - .await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.exportImage" => { - let (file_name, bytes) = match export_image_payload(&request) { - Ok(payload) => payload, - Err(response) => return response, - }; - let file_path = app - .dialog() - .file() - .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) - .set_file_name(file_name.clone()) - .blocking_save_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let export_result = - tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)) - .await; - let byte_count = match export_result { - Ok(Ok(byte_count)) => byte_count, - Ok(Err(error)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": byte_count, - }), - ) - } - "file.importImage" => { - let file_path = app - .dialog() - .file() - .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) - .blocking_pick_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = tauri::async_runtime::spawn_blocking(move || { - import_image_file_payload(path, "selected", None) - }) - .await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.importAudio" => { - let file_path = app - .dialog() - .file() - .add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"]) - .blocking_pick_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file import cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let import_result = - tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; - match import_result { - Ok(Ok(payload)) => ok(request.id, payload), - Ok(Err(error)) => failed(request.id, "invalid_request", error), - Err(error) => failed(request.id, "host_error", error.to_string()), - } - } - "file.exportAudio" => { - let (file_name, bytes) = match export_audio_payload(&request) { - Ok(payload) => payload, - Err(response) => return response, - }; - let file_path = app - .dialog() - .file() - .add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"]) - .set_file_name(file_name.clone()) - .blocking_save_file(); - let Some(file_path) = file_path else { - return failed(request.id, "cancelled", "file export cancelled"); - }; - let path = match file_path.into_path() { - Ok(path) => path, - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - let export_result = - tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)) - .await; - let byte_count = match export_result { - Ok(Ok(byte_count)) => byte_count, - Ok(Err(error)) => return failed(request.id, "host_error", error), - Err(error) => return failed(request.id, "host_error", error.to_string()), - }; - ok( - request.id, - json!({ - "action": "saved", - "fileName": file_name, - "bytes": byte_count, - }), - ) - } + "file.exportText" => export_desktop_host_bridge_text_file(&app, &request).await, + "file.importText" => import_desktop_host_bridge_text_file(&app, &request).await, + "file.importDocument" => import_desktop_host_bridge_document_file(&app, &request).await, + "file.exportImage" => export_desktop_host_bridge_image_file(&app, &request).await, + "file.importImage" => import_desktop_host_bridge_image_file(&app, &request).await, + "file.importAudio" => import_desktop_host_bridge_audio_file(&app, &request).await, + "file.exportAudio" => export_desktop_host_bridge_audio_file(&app, &request).await, "app.setTitle" => set_desktop_host_bridge_window_title(&app, &request), "app.setBadgeCount" => match set_desktop_app_badge_count(&app, &request) { Ok(()) => ok(request.id, json!(true)), diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs index 31bca778e..2d44cb81e 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/files.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/files.rs @@ -1,8 +1,9 @@ -use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse}; +use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; +use tauri_plugin_dialog::DialogExt; pub(crate) const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024; @@ -398,9 +399,8 @@ pub(crate) fn export_image_payload( "image exceeds file export size limit", )); } - ensure_image_bytes_match_mime_type(&bytes, mime_type).map_err(|message| { - failed(request.id.clone(), "invalid_request", message) - })?; + ensure_image_bytes_match_mime_type(&bytes, mime_type) + .map_err(|message| failed(request.id.clone(), "invalid_request", message))?; let file_name = payload .get("fileName") @@ -465,9 +465,8 @@ pub(crate) fn export_audio_payload( "audio exceeds file export size limit", )); } - ensure_audio_bytes_match_mime_type(&bytes, mime_type).map_err(|message| { - failed(request.id.clone(), "invalid_request", message) - })?; + ensure_audio_bytes_match_mime_type(&bytes, mime_type) + .map_err(|message| failed(request.id.clone(), "invalid_request", message))?; let file_name = payload .get("fileName") @@ -564,6 +563,226 @@ pub(crate) fn import_audio_file_payload(path: PathBuf) -> Result })) } +pub(crate) async fn export_desktop_host_bridge_text_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let (file_name, content) = match export_text_payload(request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Text", &["txt", "json", "md", "csv"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return failed(request.id.clone(), "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_text_file(path, content)).await; + let bytes = match export_result { + Ok(Ok(bytes)) => bytes, + Ok(Err(error)) => return failed(request.id.clone(), "host_error", error), + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + + ok( + request.id.clone(), + json!({ + "action": "saved", + "fileName": file_name, + "bytes": bytes, + }), + ) +} + +pub(crate) async fn import_desktop_host_bridge_text_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let file_path = app + .dialog() + .file() + .add_filter("Text", &["txt", "md", "markdown", "csv", "json"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id.clone(), "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_text_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id.clone(), payload), + Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), + Err(error) => failed(request.id.clone(), "host_error", error.to_string()), + } +} + +pub(crate) async fn import_desktop_host_bridge_document_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let file_path = app + .dialog() + .file() + .add_filter( + "Document", + &["txt", "md", "markdown", "csv", "json", "docx"], + ) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id.clone(), "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_document_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id.clone(), payload), + Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), + Err(error) => failed(request.id.clone(), "host_error", error.to_string()), + } +} + +pub(crate) async fn export_desktop_host_bridge_image_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let (file_name, bytes) = match export_image_payload(request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return failed(request.id.clone(), "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await; + let byte_count = match export_result { + Ok(Ok(byte_count)) => byte_count, + Ok(Err(error)) => return failed(request.id.clone(), "host_error", error), + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + ok( + request.id.clone(), + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) +} + +pub(crate) async fn import_desktop_host_bridge_image_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let file_path = app + .dialog() + .file() + .add_filter("Image", &["png", "jpg", "jpeg", "webp"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id.clone(), "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + let import_result = tauri::async_runtime::spawn_blocking(move || { + import_image_file_payload(path, "selected", None) + }) + .await; + match import_result { + Ok(Ok(payload)) => ok(request.id.clone(), payload), + Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), + Err(error) => failed(request.id.clone(), "host_error", error.to_string()), + } +} + +pub(crate) async fn import_desktop_host_bridge_audio_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let file_path = app + .dialog() + .file() + .add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"]) + .blocking_pick_file(); + let Some(file_path) = file_path else { + return failed(request.id.clone(), "cancelled", "file import cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + let import_result = + tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; + match import_result { + Ok(Ok(payload)) => ok(request.id.clone(), payload), + Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error), + Err(error) => failed(request.id.clone(), "host_error", error.to_string()), + } +} + +pub(crate) async fn export_desktop_host_bridge_audio_file( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let (file_name, bytes) = match export_audio_payload(request) { + Ok(payload) => payload, + Err(response) => return response, + }; + let file_path = app + .dialog() + .file() + .add_filter("Audio", &["mp3", "m4a", "wav", "ogg", "webm"]) + .set_file_name(file_name.clone()) + .blocking_save_file(); + let Some(file_path) = file_path else { + return failed(request.id.clone(), "cancelled", "file export cancelled"); + }; + let path = match file_path.into_path() { + Ok(path) => path, + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + let export_result = + tauri::async_runtime::spawn_blocking(move || write_export_bytes_file(path, bytes)).await; + let byte_count = match export_result { + Ok(Ok(byte_count)) => byte_count, + Ok(Err(error)) => return failed(request.id.clone(), "host_error", error), + Err(error) => return failed(request.id.clone(), "host_error", error.to_string()), + }; + ok( + request.id.clone(), + json!({ + "action": "saved", + "fileName": file_name, + "bytes": byte_count, + }), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -1108,7 +1327,10 @@ mod tests { assert_eq!(detect_image_mime_type(&webp_bytes()), Some("image/webp")); assert_eq!(detect_image_mime_type(b"text"), None); assert_eq!(detect_audio_mime_type(&mp3_bytes()), Some("audio/mpeg")); - assert_eq!(detect_audio_mime_type(&mp4_audio_bytes()), Some("audio/mp4")); + assert_eq!( + detect_audio_mime_type(&mp4_audio_bytes()), + Some("audio/mp4") + ); assert_eq!(detect_audio_mime_type(&wav_bytes()), Some("audio/wav")); assert_eq!(detect_audio_mime_type(&ogg_bytes()), Some("audio/ogg")); assert_eq!(detect_audio_mime_type(&webm_bytes()), Some("audio/webm")); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 470e522c9..d2fc5864a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -31,6 +31,7 @@ - 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。 - 2026-06-19 原生壳分享桥接边界:Expo `share.setTarget` / `share.open` 的缓存目标、分享 payload 归一和系统分享调用统一收口在 `apps/mobile-shell/src/host-bridge/share.ts`;Tauri `share.setTarget` / `share.open` 的缓存目标、分享文本生成、剪贴板 fallback 写入和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/share.rs`。两端 `dispatch` 只负责委托对应 share 模块,配置检查会拒绝分发层直接持有分享状态、生成分享文本或写入分享剪贴板结果。 - 2026-06-19 桌面壳窗口标题桥接边界:Tauri `app.setTitle` 的 payload 校验、非空 / 控制字符拒绝、80 字符截断和主窗口 `set_title` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;`dispatch.rs` 只负责委托 `set_desktop_host_bridge_window_title(...)`。桌面壳配置检查和根级结构门禁会覆盖 `title.rs` 文件清单、共享标题长度镜像和 dispatch 委托关系。 +- 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`;`dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。 - 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。 - 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。 - 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。该回读请求的短超时由共享契约 `HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS` 声明,H5 facade 不得本地重声明。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index ba01d1433..a929a59af 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -519,6 +519,7 @@ GameBridge 禁止: - 移动端接入系统分享、推送、原生登录和渠道支付。 - 移动端和桌面端的自动更新、崩溃上报、analytics、渠道分发、原生登录和渠道支付都必须等真实 SDK、后端契约、发布流程和隐私口径确定后逐项接入;文件导出、图片拖拽导入、系统托盘、即时本地通知和系统分享已按真实宿主能力逐项接入。 +- Tauri 桌面壳的文件导入导出执行边界统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`,系统文件对话框过滤器、用户取消语义、路径转换、异步读写和 HostBridge 响应都由文件模块负责;`dispatch.rs` 只按 method 委托文件模块。 - 所有新增能力先更新 HostBridge 契约和测试,再落壳实现。 ### Phase 5:AI H5 sandbox diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index db3f626bf..5d4d0ca82 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -81,6 +81,8 @@ HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_ - `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportAudioResult()` 复核文件名、MIME、base64 和字节数。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;视觉小说结果页音乐和环境音素材上传同样优先调用宿主音频导入,再把返回副本转换成浏览器 `File` 后继续交给 `uploadVisualNovelAsset` 上传和场景音频字段写回链路。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 - `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportAudioPayload()` 预校验文件名、MIME、base64 和 20 MiB 上限,Expo 与 Tauri 壳仍必须二次校验真实字节与 MIME。Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob`、`fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。 +Tauri 桌面壳的文件能力边界统一在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs` 内完成:该模块同时持有文件 payload 校验、系统文件对话框过滤器、用户取消语义、路径转换、异步读写和 HostBridge 响应归一;`dispatch.rs` 只按 method 委托文件模块,不直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper。 + ## 迁移顺序 1. 新增 `src/services/host-bridge/`,沉淀宿主运行态识别和微信小程序 JS SDK 加载,并暴露通用 HostBridge 能力接口。