0ecafa128c
桌面文件导入原生读取失败返回稳定 host_error 文件导入校验错误保留稳定 invalid_request 语义 补充桌面壳门禁和共享决策记录
406 lines
13 KiB
Rust
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");
|
|
}
|
|
}
|