Files
Genarrative/apps/desktop-shell/src-tauri/src/host_bridge/files.rs
T
kdletters 0ecafa128c 收口桌面文件导入错误边界
桌面文件导入原生读取失败返回稳定 host_error

文件导入校验错误保留稳定 invalid_request 语义

补充桌面壳门禁和共享决策记录
2026-06-21 17:11:11 +08:00

406 lines
13 KiB
Rust

use crate::host_bridge::file_payloads::{
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, ImportFilePayloadError,
};
use crate::host_bridge::protocol::{failed, ok, HostBridgeRequest, HostBridgeResponse};
use serde_json::json;
use tauri_plugin_dialog::DialogExt;
fn file_export_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "cancelled", "file export cancelled")
}
fn file_import_cancelled_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "cancelled", "file import cancelled")
}
fn file_export_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "host_error", "file export unavailable")
}
fn file_import_unavailable_response(request: &HostBridgeRequest) -> HostBridgeResponse {
failed(request.id.clone(), "host_error", "file import unavailable")
}
fn log_desktop_file_export_failure(label: &str) -> bool {
eprintln!("desktop file export failed for {label}");
false
}
fn log_desktop_file_import_failure(label: &str) -> bool {
eprintln!("desktop file import failed for {label}");
false
}
fn file_import_payload_error_response(
request: &HostBridgeRequest,
label: &str,
error: ImportFilePayloadError,
) -> HostBridgeResponse {
match error.invalid_message() {
Some(message) => failed(request.id.clone(), "invalid_request", message),
None => {
log_desktop_file_import_failure(label);
file_import_unavailable_response(request)
}
}
}
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 file_export_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_export_failure("path.convert");
return file_export_unavailable_response(request);
}
};
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)) => {
log_desktop_file_export_failure("write.text");
return file_export_unavailable_response(request);
}
Err(_error) => {
log_desktop_file_export_failure("write.text.join");
return file_export_unavailable_response(request);
}
};
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 file_import_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_import_failure("path.convert");
return file_import_unavailable_response(request);
}
};
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)) => file_import_payload_error_response(request, "read.text", error),
Err(_error) => {
log_desktop_file_import_failure("read.text.join");
file_import_unavailable_response(request)
}
}
}
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 file_import_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_import_failure("path.convert");
return file_import_unavailable_response(request);
}
};
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)) => file_import_payload_error_response(request, "read.document", error),
Err(_error) => {
log_desktop_file_import_failure("read.document.join");
file_import_unavailable_response(request)
}
}
}
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 file_export_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_export_failure("path.convert");
return file_export_unavailable_response(request);
}
};
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)) => {
log_desktop_file_export_failure("write.image");
return file_export_unavailable_response(request);
}
Err(_error) => {
log_desktop_file_export_failure("write.image.join");
return file_export_unavailable_response(request);
}
};
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 file_import_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_import_failure("path.convert");
return file_import_unavailable_response(request);
}
};
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)) => file_import_payload_error_response(request, "read.image", error),
Err(_error) => {
log_desktop_file_import_failure("read.image.join");
file_import_unavailable_response(request)
}
}
}
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 file_import_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_import_failure("path.convert");
return file_import_unavailable_response(request);
}
};
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)) => file_import_payload_error_response(request, "read.audio", error),
Err(_error) => {
log_desktop_file_import_failure("read.audio.join");
file_import_unavailable_response(request)
}
}
}
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 file_export_cancelled_response(request);
};
let path = match file_path.into_path() {
Ok(path) => path,
Err(_error) => {
log_desktop_file_export_failure("path.convert");
return file_export_unavailable_response(request);
}
};
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)) => {
log_desktop_file_export_failure("write.audio");
return file_export_unavailable_response(request);
}
Err(_error) => {
log_desktop_file_export_failure("write.audio.join");
return file_export_unavailable_response(request);
}
};
ok(
request.id.clone(),
json!({
"action": "saved",
"fileName": file_name,
"bytes": byte_count,
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
#[test]
fn desktop_file_cancelled_responses_are_stable() {
let request = request("file.importText");
let export_error = file_export_cancelled_response(&request)
.error
.expect("export error");
assert_eq!(export_error.code, "cancelled");
assert_eq!(export_error.message, "file export cancelled");
let import_error = file_import_cancelled_response(&request)
.error
.expect("import error");
assert_eq!(import_error.code, "cancelled");
assert_eq!(import_error.message, "file import cancelled");
}
#[test]
fn desktop_file_unavailable_responses_are_stable() {
let request = request("file.importText");
let export_error = file_export_unavailable_response(&request)
.error
.expect("export error");
assert_eq!(export_error.code, "host_error");
assert_eq!(export_error.message, "file export unavailable");
let import_error = file_import_unavailable_response(&request)
.error
.expect("import error");
assert_eq!(import_error.code, "host_error");
assert_eq!(import_error.message, "file import unavailable");
}
#[test]
fn desktop_file_failures_are_logged_without_exposing_native_detail() {
assert!(!log_desktop_file_export_failure("path.convert"));
assert!(!log_desktop_file_export_failure("write.text"));
assert!(!log_desktop_file_import_failure("path.convert"));
assert!(!log_desktop_file_import_failure("read.text.join"));
}
#[test]
fn desktop_file_failures_log_stable_label_only() {
assert!(!log_desktop_file_export_failure("write.image.join"));
assert!(!log_desktop_file_import_failure("read.audio.join"));
}
#[test]
fn desktop_file_import_validation_errors_keep_stable_invalid_request() {
let request = request("file.importText");
let error = file_import_payload_error_response(
&request,
"read.text",
ImportFilePayloadError::InvalidRequest("text MIME must be allowed"),
)
.error
.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "text MIME must be allowed");
}
#[test]
fn desktop_file_import_native_read_errors_use_stable_host_error() {
let request = request("file.importText");
let error = file_import_payload_error_response(
&request,
"read.text",
ImportFilePayloadError::NativeRead,
)
.error
.expect("error");
assert_eq!(error.code, "host_error");
assert_eq!(error.message, "file import unavailable");
}
}