From 24ac734503e5e8474f2e6031fbb2b34fcb4cc958 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Wed, 18 Feb 2026 19:25:52 +0800 Subject: [PATCH] feat(desktop): native download and working context menu actions --- desktop-client/src-tauri/src/lib.rs | 147 +++++++++++++++++++++++++++- desktop-client/src/App.vue | 74 ++++++++++---- 2 files changed, 200 insertions(+), 21 deletions(-) diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 7c53a39..9e529c4 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -2,6 +2,10 @@ use reqwest::Method; use reqwest::StatusCode; use serde::Serialize; use serde_json::{Map, Value}; +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; use std::time::Duration; struct ApiState { @@ -42,6 +46,68 @@ fn fallback_json(status: StatusCode, text: &str) -> Value { Value::Object(data) } +fn sanitize_file_name(name: &str) -> String { + let raw = name.trim(); + let mut cleaned = String::with_capacity(raw.len()); + for ch in raw.chars() { + if matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0') { + cleaned.push('_'); + } else { + cleaned.push(ch); + } + } + let normalized = cleaned.trim().trim_matches('.').to_string(); + if normalized.is_empty() { + "download.bin".to_string() + } else { + normalized + } +} + +fn resolve_download_dir() -> PathBuf { + if let Some(home) = env::var_os("USERPROFILE") { + return PathBuf::from(home).join("Downloads"); + } + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home).join("Downloads"); + } + PathBuf::from(".") +} + +fn split_file_name(name: &str) -> (String, String) { + if let Some(index) = name.rfind('.') { + if index > 0 && index < name.len() - 1 { + let stem = name[..index].to_string(); + let ext = name[index + 1..].to_string(); + return (stem, ext); + } + } + (name.to_string(), String::new()) +} + +fn alloc_download_path(download_dir: &Path, preferred_name: &str) -> PathBuf { + let safe_name = sanitize_file_name(preferred_name); + let first = download_dir.join(&safe_name); + if !first.exists() { + return first; + } + + let (stem, ext) = split_file_name(&safe_name); + for index in 1..10000 { + let candidate_name = if ext.is_empty() { + format!("{} ({})", stem, index) + } else { + format!("{} ({}).{}", stem, index, ext) + }; + let candidate = download_dir.join(candidate_name); + if !candidate.exists() { + return candidate; + } + } + + first +} + async fn request_json( client: &reqwest::Client, method: Method, @@ -451,6 +517,84 @@ async fn api_create_direct_link( .await } +#[tauri::command] +async fn api_native_download( + state: tauri::State<'_, ApiState>, + url: String, + file_name: Option, +) -> Result { + let trimmed_url = url.trim().to_string(); + if trimmed_url.is_empty() { + return Err("下载地址不能为空".to_string()); + } + + let response = state + .client + .get(&trimmed_url) + .send() + .await + .map_err(|err| format!("下载请求失败: {}", err))?; + + let status = response.status(); + if !status.is_success() { + return Ok(BridgeResponse { + ok: false, + status: status.as_u16(), + data: fallback_json(status, "下载失败"), + }); + } + + let preferred_name = file_name + .as_deref() + .map(|name| name.trim()) + .filter(|name| !name.is_empty()) + .unwrap_or("download.bin"); + + let download_dir = resolve_download_dir(); + if !download_dir.exists() { + fs::create_dir_all(&download_dir) + .map_err(|err| format!("创建下载目录失败: {}", err))?; + } + + let save_path = alloc_download_path(&download_dir, preferred_name); + let mut target_file = + fs::File::create(&save_path).map_err(|err| format!("创建文件失败: {}", err))?; + + let mut downloaded_bytes: u64 = 0; + let mut stream = response; + while let Some(chunk) = stream + .chunk() + .await + .map_err(|err| format!("读取下载流失败: {}", err))? + { + target_file + .write_all(&chunk) + .map_err(|err| format!("写入文件失败: {}", err))?; + downloaded_bytes += chunk.len() as u64; + } + + target_file + .flush() + .map_err(|err| format!("刷新文件失败: {}", err))?; + + let mut data = Map::new(); + data.insert("success".to_string(), Value::Bool(true)); + data.insert( + "savePath".to_string(), + Value::String(save_path.to_string_lossy().to_string()), + ); + data.insert( + "downloadedBytes".to_string(), + Value::Number(serde_json::Number::from(downloaded_bytes)), + ); + + Ok(BridgeResponse { + ok: true, + status: 200, + data: Value::Object(data), + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let client = reqwest::Client::builder() @@ -475,7 +619,8 @@ pub fn run() { api_get_my_shares, api_create_share, api_delete_share, - api_create_direct_link + api_create_direct_link, + api_native_download ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index 9aba14b..bb2dd43 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -270,6 +270,17 @@ function showToast(message: string, type = "info") { }, 2500); } +function prependTransferTask(task: { id: string; name: string; speed: string; progress: number; status: string }) { + transferTasks.value = [task, ...transferTasks.value.slice(0, 49)]; +} + +function updateTransferTask( + id: string, + patch: Partial<{ name: string; speed: string; progress: number; status: string }>, +) { + transferTasks.value = transferTasks.value.map((task) => (task.id === id ? { ...task, ...patch } : task)); +} + async function invokeBridge(command: string, payload: Record) { try { return await invoke(command, payload); @@ -328,6 +339,20 @@ async function loadShares(silent = false) { if (!silent) sharesLoading.value = false; } +async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview") { + const targetPath = buildItemPath(item); + const response = await invokeBridge("api_get_download_url", { + baseUrl: appConfig.baseUrl, + path: targetPath, + mode, + }); + if (response.ok && response.data?.success && response.data?.downloadUrl) { + return String(response.data.downloadUrl); + } + showToast(response.data?.message || "获取链接失败", "error"); + return ""; +} + async function createShareForItem(current: FileItem) { const response = await invokeBridge("api_create_share", { baseUrl: appConfig.baseUrl, @@ -571,28 +596,32 @@ async function downloadSelected(target?: FileItem | null) { showToast("当前仅支持下载文件", "info"); return; } - const targetPath = buildItemPath(current); - const response = await invokeBridge("api_get_download_url", { - baseUrl: appConfig.baseUrl, - path: targetPath, - mode: "download", + const signedUrl = await getSignedUrlForItem(current, "download"); + if (!signedUrl) return; + + const taskId = `D-${Date.now()}`; + prependTransferTask({ + id: taskId, + name: current.displayName || current.name, + speed: "原生下载", + progress: 1, + status: "downloading", }); - if (response.ok && response.data?.success && response.data?.downloadUrl) { - await openUrl(response.data.downloadUrl); - showToast("已调用系统浏览器开始下载", "success"); - transferTasks.value = [ - { - id: `D-${Date.now()}`, - name: current.displayName || current.name, - speed: "直连", - progress: 100, - status: "dispatched", - }, - ...transferTasks.value.slice(0, 29), - ]; + + const nativeResponse = await invokeBridge("api_native_download", { + url: signedUrl, + fileName: current.displayName || current.name, + }); + + if (nativeResponse.ok && nativeResponse.data?.success) { + updateTransferTask(taskId, { speed: "-", progress: 100, status: "done" }); + const savedPath = nativeResponse.data?.savePath ? `\n${nativeResponse.data.savePath}` : ""; + showToast(`下载完成${savedPath}`, "success"); return; } - showToast(response.data?.message || "获取下载链接失败", "error"); + + updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed" }); + showToast(nativeResponse.data?.message || "原生下载失败", "error"); } function selectFile(item: FileItem) { @@ -604,6 +633,11 @@ async function openItem(item: FileItem) { if (item.isDirectory || item.type === "directory") { const nextPath = buildItemPath(item); await loadFiles(nextPath); + return; + } + const previewUrl = await getSignedUrlForItem(item, "preview"); + if (previewUrl) { + await openUrl(previewUrl); } } @@ -921,7 +955,7 @@ onBeforeUnmount(() => { @click.stop >