fix: 修复桌面端下载保存对话框

This commit is contained in:
2026-05-21 17:04:49 +08:00
parent 1288553df8
commit d56eaf5efc
10 changed files with 451 additions and 1 deletions
+70
View File
@@ -237,8 +237,11 @@ dependencies = [
name = "capacity-report-desktop"
version = "3.0.0"
dependencies = [
"reqwest",
"serde",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-shell",
]
@@ -2010,6 +2013,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2512,6 +2516,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -3214,6 +3242,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
+3
View File
@@ -9,7 +9,10 @@ edition = "2021"
tauri-build = { version = "2", features = [] }
[dependencies]
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
[features]
File diff suppressed because one or more lines are too long
+66
View File
@@ -2402,6 +2402,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
+66
View File
@@ -2402,6 +2402,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
"type": "string",
+129
View File
@@ -10,11 +10,13 @@ use std::thread;
use std::time::{Duration, Instant};
use tauri::{Manager, Runtime};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt;
const SERVER_PORT: u16 = 9081;
const SERVER_ADDR: &str = "127.0.0.1:9081";
const HTTP_ERROR_PREFIX: &str = "CAPAREPORT_HTTP_ERROR:";
#[cfg(target_os = "windows")]
const SERVER_PROCESS_NAME: &str = "capareport-server.exe";
@@ -25,6 +27,27 @@ struct ServerProcess {
struct ServerState(Mutex<Option<ServerProcess>>);
#[derive(serde::Deserialize)]
struct DownloadHeader {
name: String,
value: String,
}
#[derive(serde::Deserialize)]
struct DownloadFileRequest {
url: String,
method: String,
filename: String,
headers: Vec<DownloadHeader>,
body: Option<String>,
}
#[derive(serde::Serialize)]
struct DownloadFileResult {
saved: bool,
path: Option<String>,
}
fn copy_resource<R: Runtime>(
app: &tauri::App<R>,
name: &str,
@@ -186,9 +209,115 @@ fn stop_server<R: Runtime>(app: &tauri::AppHandle<R>) {
}
}
#[tauri::command]
async fn download_to_file(
app: tauri::AppHandle,
window: tauri::Window,
request: DownloadFileRequest,
) -> Result<DownloadFileResult, String> {
let filename = safe_download_filename(&request.filename);
let mut dialog = app
.dialog()
.file()
.set_file_name(&filename)
.set_parent(&window);
if let Some(extension) = Path::new(&filename)
.extension()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
{
dialog = dialog.add_filter(extension.to_ascii_uppercase(), &[extension]);
}
let Some(file_path) = dialog
.blocking_save_file()
.map(|file_path| file_path.into_path().map_err(|error| error.to_string()))
.transpose()?
else {
return Ok(DownloadFileResult {
saved: false,
path: None,
});
};
if let Err(error) = write_download(request, &file_path).await {
let _ = fs::remove_file(&file_path);
return Err(error);
}
Ok(DownloadFileResult {
saved: true,
path: Some(file_path.to_string_lossy().to_string()),
})
}
async fn write_download(request: DownloadFileRequest, file_path: &Path) -> Result<(), String> {
let method = reqwest::Method::from_bytes(request.method.as_bytes())
.map_err(|error| format!("Invalid download method: {error}"))?;
let client = reqwest::Client::new();
let mut builder = client.request(method, request.url);
for header in request.headers {
let name = reqwest::header::HeaderName::from_bytes(header.name.as_bytes())
.map_err(|error| format!("Invalid download header: {error}"))?;
let value = reqwest::header::HeaderValue::from_str(&header.value)
.map_err(|error| format!("Invalid download header value: {error}"))?;
builder = builder.header(name, value);
}
if let Some(body) = request.body {
builder = builder.body(body);
}
let mut response = builder
.send()
.await
.map_err(|error| format!("Download request failed: {error}"))?;
let status = response.status();
if !status.is_success() {
let body = response
.bytes()
.await
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
.unwrap_or_default();
return Err(format!("{HTTP_ERROR_PREFIX}{}:{body}", status.as_u16()));
}
let mut file = fs::File::create(file_path)
.map_err(|error| format!("Unable to create download file: {error}"))?;
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("Unable to read download stream: {error}"))?
{
file.write_all(&chunk)
.map_err(|error| format!("Unable to write download file: {error}"))?;
}
file.flush()
.map_err(|error| format!("Unable to flush download file: {error}"))?;
Ok(())
}
fn safe_download_filename(filename: &str) -> String {
let trimmed = filename.trim();
if trimmed.is_empty() {
return "download.bin".to_string();
}
Path::new(trimmed)
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.unwrap_or("download.bin")
.to_string()
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![download_to_file])
.manage(ServerState(Mutex::new(None)))
.setup(|app| {
start_server(app)?;