From b931f36bde2eb77d286b044b6cb4b6b64bc2d0f9 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Wed, 18 Feb 2026 22:24:41 +0800 Subject: [PATCH] feat(desktop): stream download progress and auto-launch installer --- desktop-client/src-tauri/src/lib.rs | 142 +++++++++++++++++++++++++++- desktop-client/src/App.vue | 95 ++++++++++++++++++- 2 files changed, 231 insertions(+), 6 deletions(-) diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 2c34c1a..eb855a7 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -7,7 +7,9 @@ use std::fs; use std::io::Write; use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; -use std::time::{Duration, UNIX_EPOCH}; +use std::process::Command; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tauri::Emitter; struct ApiState { client: reqwest::Client, @@ -21,6 +23,47 @@ struct BridgeResponse { data: Value, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct NativeDownloadProgressPayload { + task_id: String, + downloaded_bytes: u64, + total_bytes: Option, + progress: Option, + resumed_bytes: u64, + done: bool, +} + +fn emit_native_download_progress( + window: &tauri::WebviewWindow, + task_id: &str, + downloaded_bytes: u64, + total_bytes: Option, + resumed_bytes: u64, + done: bool, +) { + if task_id.trim().is_empty() { + return; + } + + let progress = total_bytes + .filter(|total| *total > 0) + .map(|total| (downloaded_bytes as f64 / total as f64) * 100.0); + + let payload = NativeDownloadProgressPayload { + task_id: task_id.to_string(), + downloaded_bytes, + total_bytes, + progress, + resumed_bytes, + done, + }; + + if let Err(err) = window.emit("native-download-progress", payload) { + eprintln!("emit native-download-progress failed: {}", err); + } +} + fn normalize_base_url(base_url: &str) -> String { let trimmed = base_url.trim(); if trimmed.is_empty() { @@ -550,8 +593,10 @@ async fn api_create_direct_link( #[tauri::command] async fn api_native_download( state: tauri::State<'_, ApiState>, + window: tauri::WebviewWindow, url: String, file_name: Option, + task_id: Option, ) -> Result { let trimmed_url = url.trim().to_string(); if trimmed_url.is_empty() { @@ -595,6 +640,16 @@ async fn api_native_download( let save_path = alloc_download_path(&download_dir, preferred_name); fs::rename(&resume_temp_path, &save_path) .map_err(|err| format!("完成断点下载失败: {}", err))?; + if let Some(ref id) = task_id { + emit_native_download_progress( + &window, + id, + existing_size, + Some(existing_size), + existing_size, + true, + ); + } let mut data = Map::new(); data.insert("success".to_string(), Value::Bool(true)); data.insert( @@ -625,6 +680,26 @@ async fn api_native_download( } let append_mode = existing_size > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT; + let total_bytes = if append_mode { + response + .content_length() + .map(|remaining| remaining.saturating_add(existing_size)) + } else { + response.content_length() + }; + let resumed_bytes = if append_mode { existing_size } else { 0 }; + + if let Some(ref id) = task_id { + emit_native_download_progress( + &window, + id, + if append_mode { existing_size } else { 0 }, + total_bytes, + resumed_bytes, + false, + ); + } + if !append_mode && resume_temp_path.exists() { fs::remove_file(&resume_temp_path) .map_err(|err| format!("重置断点下载文件失败: {}", err))?; @@ -640,6 +715,7 @@ async fn api_native_download( let mut downloaded_bytes: u64 = if append_mode { existing_size } else { 0 }; let mut stream = response; + let mut last_emit = Instant::now(); while let Some(chunk) = stream .chunk() .await @@ -649,6 +725,20 @@ async fn api_native_download( .write_all(&chunk) .map_err(|err| format!("写入文件失败: {}", err))?; downloaded_bytes += chunk.len() as u64; + + if let Some(ref id) = task_id { + if last_emit.elapsed() >= Duration::from_millis(120) { + emit_native_download_progress( + &window, + id, + downloaded_bytes, + total_bytes, + resumed_bytes, + false, + ); + last_emit = Instant::now(); + } + } } target_file @@ -659,6 +749,17 @@ async fn api_native_download( fs::rename(&resume_temp_path, &save_path) .map_err(|err| format!("保存下载文件失败: {}", err))?; + if let Some(ref id) = task_id { + emit_native_download_progress( + &window, + id, + downloaded_bytes, + total_bytes.or(Some(downloaded_bytes)), + resumed_bytes, + true, + ); + } + let mut data = Map::new(); data.insert("success".to_string(), Value::Bool(true)); data.insert( @@ -681,6 +782,44 @@ async fn api_native_download( }) } +#[tauri::command] +fn api_launch_installer(installer_path: String) -> Result { + let path_text = installer_path.trim().to_string(); + if path_text.is_empty() { + return Err("安装包路径不能为空".to_string()); + } + + let installer = PathBuf::from(&path_text); + if !installer.exists() { + return Err("安装包不存在,请重新下载".to_string()); + } + if !installer.is_file() { + return Err("安装包路径无效".to_string()); + } + + #[cfg(target_os = "windows")] + let spawn_result = Command::new(&installer).spawn(); + + #[cfg(target_os = "macos")] + let spawn_result = Command::new("open").arg(&installer).spawn(); + + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + let spawn_result = Command::new("xdg-open").arg(&installer).spawn(); + + spawn_result.map_err(|err| format!("启动安装程序失败: {}", err))?; + + let mut data = Map::new(); + data.insert("success".to_string(), Value::Bool(true)); + data.insert("message".to_string(), Value::String("安装程序已启动".to_string())); + data.insert("installerPath".to_string(), Value::String(path_text)); + + Ok(BridgeResponse { + ok: true, + status: 200, + data: Value::Object(data), + }) +} + #[tauri::command] async fn api_check_client_update( state: tauri::State<'_, ApiState>, @@ -1060,6 +1199,7 @@ pub fn run() { api_delete_share, api_create_direct_link, api_native_download, + api_launch_installer, api_check_client_update, api_list_local_files, api_upload_file_resumable, diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index 6929402..12c992a 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -4,7 +4,8 @@ import { invoke } from "@tauri-apps/api/core"; import { openPath, openUrl } from "@tauri-apps/plugin-opener"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { getVersion } from "@tauri-apps/api/app"; -import type { UnlistenFn } from "@tauri-apps/api/event"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWebview } from "@tauri-apps/api/webview"; type NavKey = "files" | "transfers" | "shares" | "sync" | "updates"; @@ -40,6 +41,15 @@ type BridgeResponse = { data: Record; }; +type NativeDownloadProgressEvent = { + taskId?: string; + downloadedBytes?: number; + totalBytes?: number | null; + progress?: number | null; + resumedBytes?: number; + done?: boolean; +}; + type LocalSyncFileItem = { path: string; relativePath: string; @@ -149,6 +159,7 @@ const dropState = reactive({ failed: 0, }); let unlistenDragDrop: UnlistenFn | null = null; +let unlistenNativeDownloadProgress: UnlistenFn | null = null; let syncTimer: ReturnType | null = null; const toast = reactive({ @@ -347,6 +358,35 @@ function fileTypeLabel(item: FileItem) { return "文件"; } +function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) { + const taskId = String(payload?.taskId || "").trim(); + if (!taskId) return; + + const downloadedBytes = Number(payload?.downloadedBytes || 0); + const totalBytesRaw = payload?.totalBytes; + const totalBytes = totalBytesRaw === null || totalBytesRaw === undefined ? NaN : Number(totalBytesRaw); + const eventProgress = Number(payload?.progress); + const calculatedProgress = Number.isFinite(eventProgress) + ? eventProgress + : (Number.isFinite(totalBytes) && totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : NaN); + const boundedProgress = Number.isFinite(calculatedProgress) + ? Math.max(1, Math.min(payload?.done ? 100 : 99.8, calculatedProgress)) + : NaN; + + const patch: Partial = { + speed: "下载中", + status: "downloading", + note: Number.isFinite(totalBytes) && totalBytes > 0 + ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` + : `已下载 ${formatBytes(downloadedBytes)}`, + }; + if (Number.isFinite(boundedProgress)) { + patch.progress = Number(boundedProgress.toFixed(1)); + } + + updateTransferTask(taskId, patch); +} + function fileIcon(item: FileItem) { if (item.isDirectory || item.type === "directory") return "📁"; const name = String(item.name || "").toLowerCase(); @@ -674,6 +714,7 @@ async function installLatestUpdate(): Promise { const response = await invokeBridge("api_native_download", { url: updateState.downloadUrl, fileName: installerName, + taskId, }); try { @@ -686,13 +727,30 @@ async function installLatestUpdate(): Promise { }); const savePath = String(response.data?.savePath || "").trim(); if (savePath) { + const launchResponse = await invokeBridge("api_launch_installer", { + installerPath: savePath, + }); + if (launchResponse.ok && launchResponse.data?.success) { + updateTransferTask(taskId, { + speed: "-", + progress: 100, + status: "done", + note: "安装程序已启动,客户端即将退出", + }); + showToast("安装程序已启动,客户端即将退出", "success"); + setTimeout(() => { + void getCurrentWindow().close(); + }, 400); + return true; + } + try { await openPath(savePath); } catch (error) { - console.error("open installer failed", error); + console.error("open installer fallback failed", error); } } - showToast("更新包已下载,已尝试启动安装程序", "success"); + showToast("更新包已下载,请手动运行安装程序", "info"); return true; } @@ -1258,6 +1316,7 @@ async function downloadSelected(target?: FileItem | null) { const nativeResponse = await invokeBridge("api_native_download", { url: signedUrl, fileName: current.displayName || current.name, + taskId, }); if (nativeResponse.ok && nativeResponse.data?.success) { @@ -1387,6 +1446,7 @@ async function retryTransferTask(taskId: string) { const response = await invokeBridge("api_native_download", { url: task.downloadUrl, fileName: task.fileName || task.name, + taskId, }); if (response.ok && response.data?.success) { const resumedBytes = Number(response.data?.resumedBytes || 0); @@ -1595,6 +1655,16 @@ async function registerDragDropListener() { } } +async function registerNativeDownloadProgressListener() { + try { + unlistenNativeDownloadProgress = await listen("native-download-progress", (event) => { + applyNativeDownloadProgress(event.payload || {}); + }); + } catch (error) { + console.error("register native download progress listener failed", error); + } +} + watch(nav, async (next) => { if (next === "shares" && authenticated.value) { await loadShares(); @@ -1622,6 +1692,7 @@ onMounted(async () => { window.addEventListener("click", handleGlobalClick); window.addEventListener("keydown", handleGlobalKey); await registerDragDropListener(); + await registerNativeDownloadProgressListener(); await initClientVersion(); await restoreSession(); }); @@ -1634,6 +1705,10 @@ onBeforeUnmount(() => { unlistenDragDrop(); unlistenDragDrop = null; } + if (unlistenNativeDownloadProgress) { + unlistenNativeDownloadProgress(); + unlistenNativeDownloadProgress = null; + } }); @@ -1843,6 +1918,7 @@ onBeforeUnmount(() => {
+ {{ Math.max(0, Math.min(100, Math.round(task.progress))) }}%
@@ -2793,8 +2869,9 @@ select:focus { } .progress { - height: 8px; - background: #e4edf9; + height: 10px; + background: #dbe7f8; + border: 1px solid #cadef6; border-radius: 999px; overflow: hidden; } @@ -2802,6 +2879,14 @@ select:focus { .bar { height: 100%; background: linear-gradient(90deg, #1d6fff, #57a2ff); + transition: width 0.14s linear; +} + +.task-percent { + justify-self: end; + color: #3f5f86; + font-size: 11px; + font-weight: 600; } .share-list {