From 365ada1a4a33b8122e603f3b829ff401c10f5a5e Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Thu, 19 Feb 2026 00:12:33 +0800 Subject: [PATCH] feat(desktop): remember login in sqlite and streamline update flow --- backend/server.js | 2 +- desktop-client/package.json | 2 +- desktop-client/src-tauri/Cargo.lock | 76 +++- desktop-client/src-tauri/Cargo.toml | 3 +- desktop-client/src-tauri/src/lib.rs | 303 ++++++++++++++- desktop-client/src-tauri/tauri.conf.json | 2 +- desktop-client/src/App.vue | 476 ++++++++++++++++++++--- 7 files changed, 808 insertions(+), 56 deletions(-) diff --git a/backend/server.js b/backend/server.js index 08b7cd5..9ccc1ea 100644 --- a/backend/server.js +++ b/backend/server.js @@ -101,7 +101,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max( 10, Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30)) ); -const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.4'; +const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.5'; const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || ''; const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || ''; const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads'); diff --git a/desktop-client/package.json b/desktop-client/package.json index 1dc8ad0..e8e4a4a 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -1,7 +1,7 @@ { "name": "desktop-client", "private": true, - "version": "0.1.4", + "version": "0.1.5", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop-client/src-tauri/Cargo.lock b/desktop-client/src-tauri/Cargo.lock index 81a0f6d..4c439b1 100644 --- a/desktop-client/src-tauri/Cargo.lock +++ b/desktop-client/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -681,9 +693,10 @@ dependencies = [ [[package]] name = "desktop-client" -version = "0.1.4" +version = "0.1.5" dependencies = [ "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "tauri", @@ -917,6 +930,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1423,6 +1448,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1438,6 +1472,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1957,6 +2000,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3204,6 +3258,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -4624,6 +4692,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml index ade1dc4..7bb8ed0 100644 --- a/desktop-client/src-tauri/Cargo.toml +++ b/desktop-client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop-client" -version = "0.1.4" +version = "0.1.5" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -26,3 +26,4 @@ serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] } urlencoding = "2.1" walkdir = "2.5" +rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index eb855a7..455ee77 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use reqwest::Method; use reqwest::StatusCode; +use rusqlite::{params, Connection}; use serde::Serialize; use serde_json::{Map, Value}; use std::env; @@ -8,7 +9,7 @@ use std::io::Write; use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::{Duration, Instant, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; struct ApiState { @@ -34,6 +35,16 @@ struct NativeDownloadProgressPayload { done: bool, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct NativeUploadProgressPayload { + task_id: String, + uploaded_bytes: u64, + total_bytes: u64, + progress: f64, + done: bool, +} + fn emit_native_download_progress( window: &tauri::WebviewWindow, task_id: &str, @@ -64,6 +75,32 @@ fn emit_native_download_progress( } } +fn emit_native_upload_progress( + window: &tauri::WebviewWindow, + task_id: &str, + uploaded_bytes: u64, + total_bytes: u64, + done: bool, +) { + if task_id.trim().is_empty() { + return; + } + + let normalized_total = total_bytes.max(1); + let progress = (uploaded_bytes as f64 / normalized_total as f64) * 100.0; + let payload = NativeUploadProgressPayload { + task_id: task_id.to_string(), + uploaded_bytes, + total_bytes, + progress, + done, + }; + + if let Err(err) = window.emit("native-upload-progress", payload) { + eprintln!("emit native-upload-progress failed: {}", err); + } +} + fn normalize_base_url(base_url: &str) -> String { let trimmed = base_url.trim(); if trimmed.is_empty() { @@ -163,6 +200,81 @@ fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, ur download_dir.join(temp_name) } +fn resolve_local_state_dir() -> PathBuf { + if let Some(appdata) = env::var_os("APPDATA") { + return PathBuf::from(appdata).join("wanwan-cloud-desktop"); + } + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home).join(".wanwan-cloud-desktop"); + } + PathBuf::from(".").join(".wanwan-cloud-desktop") +} + +fn open_local_state_db() -> Result { + let state_dir = resolve_local_state_dir(); + fs::create_dir_all(&state_dir).map_err(|err| format!("创建本地状态目录失败: {}", err))?; + let db_path = state_dir.join("client_state.db"); + let conn = Connection::open(db_path).map_err(|err| format!("打开本地状态数据库失败: {}", err))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS login_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + base_url TEXT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + updated_at INTEGER NOT NULL + )", + [], + ) + .map_err(|err| format!("初始化本地状态表失败: {}", err))?; + Ok(conn) +} + +fn save_login_state_record(base_url: &str, username: &str, password: &str) -> Result<(), String> { + let conn = open_local_state_db()?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default(); + conn.execute( + "INSERT INTO login_state (id, base_url, username, password, updated_at) + VALUES (1, ?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET + base_url = excluded.base_url, + username = excluded.username, + password = excluded.password, + updated_at = excluded.updated_at", + params![base_url, username, password, now], + ) + .map_err(|err| format!("保存登录状态失败: {}", err))?; + Ok(()) +} + +fn load_login_state_record() -> Result, String> { + let conn = open_local_state_db()?; + let mut stmt = conn + .prepare("SELECT base_url, username, password FROM login_state WHERE id = 1 LIMIT 1") + .map_err(|err| format!("读取登录状态失败: {}", err))?; + let row = stmt.query_row([], |record| { + Ok(( + record.get::<_, String>(0)?, + record.get::<_, String>(1)?, + record.get::<_, String>(2)?, + )) + }); + match row { + Ok(value) => Ok(Some(value)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(format!("读取登录状态失败: {}", err)), + } +} + +fn clear_login_state_record() -> Result<(), String> { + let conn = open_local_state_db()?; + conn.execute("DELETE FROM login_state WHERE id = 1", []) + .map_err(|err| format!("清除登录状态失败: {}", err))?; + Ok(()) +} + async fn parse_response_as_bridge(response: reqwest::Response) -> Result { let status = response.status(); let text = response @@ -314,6 +426,70 @@ async fn api_get_profile( .await } +#[tauri::command] +fn api_save_login_state( + base_url: String, + username: String, + password: String, +) -> Result { + let normalized_base = normalize_base_url(&base_url); + let normalized_user = username.trim().to_string(); + if normalized_base.is_empty() { + return Err("服务地址不能为空".to_string()); + } + if normalized_user.is_empty() { + return Err("用户名不能为空".to_string()); + } + if password.trim().is_empty() { + return Err("密码不能为空".to_string()); + } + + save_login_state_record(&normalized_base, &normalized_user, &password)?; + + let mut data = Map::new(); + data.insert("success".to_string(), Value::Bool(true)); + data.insert("message".to_string(), Value::String("登录状态已保存".to_string())); + Ok(BridgeResponse { + ok: true, + status: 200, + data: Value::Object(data), + }) +} + +#[tauri::command] +fn api_load_login_state() -> Result { + let state = load_login_state_record()?; + let mut data = Map::new(); + data.insert("success".to_string(), Value::Bool(true)); + if let Some((base_url, username, password)) = state { + data.insert("hasState".to_string(), Value::Bool(true)); + data.insert("baseUrl".to_string(), Value::String(base_url)); + data.insert("username".to_string(), Value::String(username)); + data.insert("password".to_string(), Value::String(password)); + } else { + data.insert("hasState".to_string(), Value::Bool(false)); + } + + Ok(BridgeResponse { + ok: true, + status: 200, + data: Value::Object(data), + }) +} + +#[tauri::command] +fn api_clear_login_state() -> Result { + clear_login_state_record()?; + let mut data = Map::new(); + data.insert("success".to_string(), Value::Bool(true)); + data.insert("message".to_string(), Value::String("登录状态已清除".to_string())); + Ok(BridgeResponse { + ok: true, + status: 200, + data: Value::Object(data), + }) +} + #[tauri::command] async fn api_list_files( state: tauri::State<'_, ApiState>, @@ -820,6 +996,79 @@ fn api_launch_installer(installer_path: String) -> Result 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 current_exe = env::current_exe().map_err(|err| format!("获取当前程序路径失败: {}", err))?; + let temp_dir = env::temp_dir().join("wanwan-cloud-desktop"); + fs::create_dir_all(&temp_dir).map_err(|err| format!("创建更新脚本目录失败: {}", err))?; + let script_stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let script_path = temp_dir.join(format!("silent-update-{}.cmd", script_stamp)); + + let installer_text = installer.to_string_lossy().replace('"', "\"\""); + let app_text = current_exe.to_string_lossy().replace('"', "\"\""); + let script_content = format!( + "@echo off\r\n\ +setlocal\r\n\ +set \"INSTALLER={installer}\"\r\n\ +set \"APP_EXE={app_exe}\"\r\n\ +timeout /t 2 /nobreak >nul\r\n\ +start \"\" /wait \"%INSTALLER%\" /S\r\n\ +start \"\" \"%APP_EXE%\"\r\n\ +del \"%~f0\" >nul 2>nul\r\n", + installer = installer_text, + app_exe = app_text + ); + fs::write(&script_path, script_content).map_err(|err| format!("写入更新脚本失败: {}", err))?; + + let script_arg = script_path.to_string_lossy().to_string(); + Command::new("cmd") + .args(["/C", "start", "", "/min", &script_arg]) + .spawn() + .map_err(|err| format!("启动静默更新流程失败: {}", err))?; + } + + #[cfg(not(target_os = "windows"))] + { + #[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>, @@ -939,10 +1188,12 @@ async fn api_list_local_files(dir_path: String) -> Result, + window: tauri::WebviewWindow, base_url: String, file_path: String, target_path: String, chunk_size: Option, + task_id: Option, ) -> Result { let trimmed_path = file_path.trim().to_string(); if trimmed_path.is_empty() { @@ -1030,7 +1281,18 @@ async fn api_upload_file_resumable( }) .unwrap_or_default(); + let mut uploaded_bytes = uploaded_chunks.iter().fold(0_u64, |sum, chunk_index| { + let offset = chunk_index.saturating_mul(server_chunk_size); + let remaining = file_size.saturating_sub(offset); + let bytes = std::cmp::min(remaining, server_chunk_size); + sum.saturating_add(bytes) + }); + if let Some(ref id) = task_id { + emit_native_upload_progress(&window, id, uploaded_bytes, file_size, false); + } + let mut source = fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?; + let mut last_emit = Instant::now(); for chunk_index in 0..total_chunks { if uploaded_chunks.contains(&chunk_index) { continue; @@ -1078,26 +1340,44 @@ async fn api_upload_file_resumable( if !chunk_bridge.ok || !chunk_bridge.data.get("success").and_then(Value::as_bool).unwrap_or(false) { return Ok(chunk_bridge); } + + uploaded_bytes = uploaded_bytes.saturating_add(read_size as u64).min(file_size); + if let Some(ref id) = task_id { + if last_emit.elapsed() >= Duration::from_millis(120) { + emit_native_upload_progress(&window, id, uploaded_bytes, file_size, false); + last_emit = Instant::now(); + } + } } let mut complete_body = Map::new(); complete_body.insert("session_id".to_string(), Value::String(session_id)); - request_json( + let complete_resp = request_json( &state.client, Method::POST, join_api_url(&base_url, "/api/upload/resumable/complete"), Some(Value::Object(complete_body)), csrf_token, ) - .await + .await?; + + if complete_resp.ok && complete_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) { + if let Some(ref id) = task_id { + emit_native_upload_progress(&window, id, file_size, file_size, true); + } + } + + Ok(complete_resp) } #[tauri::command] async fn api_upload_file( state: tauri::State<'_, ApiState>, + window: tauri::WebviewWindow, base_url: String, file_path: String, target_path: String, + task_id: Option, ) -> Result { let trimmed_path = file_path.trim().to_string(); if trimmed_path.is_empty() { @@ -1111,6 +1391,9 @@ async fn api_upload_file( if !source_path.is_file() { return Err("仅支持上传文件,不支持文件夹".to_string()); } + let file_size = fs::metadata(&source_path) + .map(|meta| meta.len()) + .unwrap_or(0); let file_name = source_path .file_name() @@ -1129,6 +1412,10 @@ async fn api_upload_file( return Err("API 地址不能为空".to_string()); } + if let Some(ref id) = task_id { + emit_native_upload_progress(&window, id, 0, file_size.max(1), false); + } + // 使用流式 multipart 上传,避免大文件整块读入内存导致占用暴涨。 let file_part = reqwest::multipart::Part::file(&source_path) .await @@ -1164,6 +1451,12 @@ async fn api_upload_file( Ok(parsed) => parsed, Err(_) => fallback_json(status, &text), }; + let success = status.is_success() && data.get("success").and_then(Value::as_bool).unwrap_or(false); + if success { + if let Some(ref id) = task_id { + emit_native_upload_progress(&window, id, file_size, file_size.max(1), true); + } + } Ok(BridgeResponse { ok: status.is_success(), @@ -1186,6 +1479,9 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ api_login, + api_save_login_state, + api_load_login_state, + api_clear_login_state, api_get_profile, api_list_files, api_logout, @@ -1200,6 +1496,7 @@ pub fn run() { api_create_direct_link, api_native_download, api_launch_installer, + api_silent_install_and_restart, api_check_client_update, api_list_local_files, api_upload_file_resumable, diff --git a/desktop-client/src-tauri/tauri.conf.json b/desktop-client/src-tauri/tauri.conf.json index 0ac7a68..9e74da0 100644 --- a/desktop-client/src-tauri/tauri.conf.json +++ b/desktop-client/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "wanwan-cloud-desktop", - "version": "0.1.4", + "version": "0.1.5", "identifier": "cn.workyai.wanwancloud.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index faee3f5..56d7954 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -50,6 +50,14 @@ type NativeDownloadProgressEvent = { done?: boolean; }; +type NativeUploadProgressEvent = { + taskId?: string; + uploadedBytes?: number; + totalBytes?: number; + progress?: number; + done?: boolean; +}; + type LocalSyncFileItem = { path: string; relativePath: string; @@ -85,6 +93,7 @@ const loginForm = reactive({ username: "", password: "", captcha: "", + remember: true, }); const loginState = reactive({ @@ -132,7 +141,7 @@ const syncState = reactive({ nextRunAt: "", }); const updateState = reactive({ - currentVersion: "0.1.4", + currentVersion: "0.1.5", latestVersion: "", available: false, mandatory: false, @@ -144,6 +153,19 @@ const updateState = reactive({ }); const updateRuntime = reactive({ downloading: false, + installing: false, + taskId: "", + downloadedBytes: 0, + totalBytes: 0, + progress: 0, + speed: "-", + lastMeasureAt: 0, + lastMeasureBytes: 0, + installerPath: "", +}); +const updatePrompt = reactive({ + visible: false, + loading: false, }); const contextMenu = reactive({ visible: false, @@ -163,9 +185,22 @@ const dropState = reactive({ done: 0, failed: 0, }); +const uploadRuntime = reactive({ + active: false, + taskId: "", + fileName: "", + uploadedBytes: 0, + totalBytes: 0, + progress: 0, + speed: "-", + lastMeasureAt: 0, + lastMeasureBytes: 0, +}); let unlistenDragDrop: UnlistenFn | null = null; let unlistenNativeDownloadProgress: UnlistenFn | null = null; +let unlistenNativeUploadProgress: UnlistenFn | null = null; let syncTimer: ReturnType | null = null; +let hasCheckedUpdateAfterAuth = false; const toast = reactive({ visible: false, @@ -363,6 +398,46 @@ function fileTypeLabel(item: FileItem) { return "文件"; } +function formatSpeed(bytesPerSecond: number) { + if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "-"; + return `${formatBytes(bytesPerSecond)}/s`; +} + +function measureRate( + currentBytes: number, + state: { lastMeasureAt: number; lastMeasureBytes: number }, +) { + const now = Date.now(); + if (!state.lastMeasureAt) { + state.lastMeasureAt = now; + state.lastMeasureBytes = currentBytes; + return "-"; + } + const elapsedMs = now - state.lastMeasureAt; + if (elapsedMs < 180) { + return "-"; + } + const deltaBytes = Math.max(0, currentBytes - state.lastMeasureBytes); + state.lastMeasureAt = now; + state.lastMeasureBytes = currentBytes; + if (deltaBytes <= 0) return "0 B/s"; + const bytesPerSecond = (deltaBytes * 1000) / elapsedMs; + return formatSpeed(bytesPerSecond); +} + +function resetUpdateRuntime() { + updateRuntime.downloading = false; + updateRuntime.installing = false; + updateRuntime.taskId = ""; + updateRuntime.downloadedBytes = 0; + updateRuntime.totalBytes = 0; + updateRuntime.progress = 0; + updateRuntime.speed = "-"; + updateRuntime.lastMeasureAt = 0; + updateRuntime.lastMeasureBytes = 0; + updateRuntime.installerPath = ""; +} + function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) { const taskId = String(payload?.taskId || "").trim(); if (!taskId) return; @@ -378,6 +453,23 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) { ? Math.max(1, Math.min(payload?.done ? 100 : 99.8, calculatedProgress)) : NaN; + if (taskId === updateRuntime.taskId) { + updateRuntime.downloadedBytes = downloadedBytes; + updateRuntime.totalBytes = Number.isFinite(totalBytes) ? Math.max(0, totalBytes) : 0; + updateRuntime.progress = Number.isFinite(boundedProgress) + ? Number(boundedProgress.toFixed(1)) + : updateRuntime.progress; + const speedText = measureRate(downloadedBytes, updateRuntime); + if (speedText !== "-") { + updateRuntime.speed = speedText; + } + if (payload?.done) { + updateRuntime.progress = 100; + updateRuntime.speed = "-"; + } + return; + } + const patch: Partial = { speed: "下载中", status: "downloading", @@ -392,6 +484,45 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) { updateTransferTask(taskId, patch); } +function applyNativeUploadProgress(payload: NativeUploadProgressEvent) { + const taskId = String(payload?.taskId || "").trim(); + if (!taskId) return; + + const uploadedBytes = Number(payload?.uploadedBytes || 0); + const totalBytes = Math.max(1, Number(payload?.totalBytes || 0)); + const eventProgress = Number(payload?.progress); + const progressValue = Number.isFinite(eventProgress) + ? eventProgress + : (uploadedBytes / totalBytes) * 100; + const boundedProgress = Math.max(1, Math.min(payload?.done ? 100 : 99.8, progressValue)); + let transferSpeed = "上传中"; + if (taskId === uploadRuntime.taskId) { + const sampledSpeed = measureRate(uploadedBytes, uploadRuntime); + if (sampledSpeed !== "-") { + transferSpeed = sampledSpeed; + } + } + updateTransferTask(taskId, { + status: "uploading", + speed: transferSpeed, + progress: Number(boundedProgress.toFixed(1)), + note: `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`, + }); + + if (taskId === uploadRuntime.taskId) { + uploadRuntime.uploadedBytes = uploadedBytes; + uploadRuntime.totalBytes = totalBytes; + uploadRuntime.progress = Number(boundedProgress.toFixed(1)); + if (transferSpeed !== "上传中") { + uploadRuntime.speed = transferSpeed; + } + if (payload?.done) { + uploadRuntime.progress = 100; + uploadRuntime.speed = "-"; + } + } +} + function fileIcon(item: FileItem) { if (item.isDirectory || item.type === "directory") return "📁"; const name = String(item.name || "").toLowerCase(); @@ -690,8 +821,52 @@ async function checkClientUpdate(showResultToast = true): Promise { return false; } +function getUpdateSkipStorageKey() { + return `wanwan_desktop_skip_update_${String(user.value?.id || "guest")}`; +} + +function shouldSkipCurrentUpdatePrompt() { + const latest = String(updateState.latestVersion || "").trim(); + if (!latest) return false; + return localStorage.getItem(getUpdateSkipStorageKey()) === latest; +} + +function skipCurrentUpdatePrompt() { + const latest = String(updateState.latestVersion || "").trim(); + if (!latest) return; + localStorage.setItem(getUpdateSkipStorageKey(), latest); +} + +async function checkUpdateAfterLogin() { + if (!authenticated.value || hasCheckedUpdateAfterAuth) return; + hasCheckedUpdateAfterAuth = true; + const checked = await checkClientUpdate(false); + if (!checked || !updateState.available || !updateState.downloadUrl) return; + if (shouldSkipCurrentUpdatePrompt()) return; + updatePrompt.visible = true; +} + +function dismissUpdatePrompt(ignoreThisVersion = false) { + if (ignoreThisVersion) { + skipCurrentUpdatePrompt(); + } + updatePrompt.visible = false; +} + +async function confirmUpdateFromPrompt() { + if (updatePrompt.loading) return; + updatePrompt.loading = true; + updatePrompt.visible = false; + try { + nav.value = "updates"; + await installLatestUpdate(); + } finally { + updatePrompt.loading = false; + } +} + async function installLatestUpdate(): Promise { - if (updateRuntime.downloading) { + if (updateRuntime.downloading || updateRuntime.installing) { showToast("更新包正在下载,请稍候", "info"); return false; } @@ -701,20 +876,13 @@ async function installLatestUpdate(): Promise { return false; } + resetUpdateRuntime(); updateRuntime.downloading = true; const taskId = `UPD-${Date.now()}`; const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`; - prependTransferTask({ - id: taskId, - kind: "download", - name: installerName, - speed: "更新包下载", - progress: 2, - status: "downloading", - note: "正在下载升级安装包", - downloadUrl: updateState.downloadUrl, - fileName: installerName, - }); + updateRuntime.taskId = taskId; + updateRuntime.progress = 1; + updateRuntime.speed = "准备下载"; const response = await invokeBridge("api_native_download", { url: updateState.downloadUrl, @@ -724,25 +892,18 @@ async function installLatestUpdate(): Promise { try { if (response.ok && response.data?.success) { - updateTransferTask(taskId, { - speed: "-", - progress: 100, - status: "done", - note: "更新包已下载,准备启动安装", - }); + updateRuntime.downloading = false; + updateRuntime.installing = true; + updateRuntime.progress = 100; + updateRuntime.speed = "-"; const savePath = String(response.data?.savePath || "").trim(); + updateRuntime.installerPath = savePath; if (savePath) { - const launchResponse = await invokeBridge("api_launch_installer", { + const launchResponse = await invokeBridge("api_silent_install_and_restart", { installerPath: savePath, }); if (launchResponse.ok && launchResponse.data?.success) { - updateTransferTask(taskId, { - speed: "-", - progress: 100, - status: "done", - note: "安装程序已启动,客户端即将退出", - }); - showToast("安装程序已启动,客户端即将退出", "success"); + showToast("静默安装已启动,完成后会自动重启客户端", "success"); setTimeout(() => { void getCurrentWindow().close(); }, 400); @@ -756,20 +917,20 @@ async function installLatestUpdate(): Promise { } } showToast("更新包已下载,请手动运行安装程序", "info"); + setTimeout(() => { + resetUpdateRuntime(); + }, 1200); return true; } const message = String(response.data?.message || "下载更新包失败"); - updateTransferTask(taskId, { - speed: "-", - progress: 0, - status: "failed", - note: message, - }); + resetUpdateRuntime(); showToast(message, "error"); return false; } finally { - updateRuntime.downloading = false; + if (!updateRuntime.installing) { + updateRuntime.downloading = false; + } } } @@ -789,12 +950,13 @@ async function chooseSyncDirectory() { } } -async function uploadFileWithResume(filePath: string, targetPath: string) { +async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) { const resumableResponse = await invokeBridge("api_upload_file_resumable", { baseUrl: appConfig.baseUrl, filePath, targetPath, chunkSize: 4 * 1024 * 1024, + taskId: taskId || null, }); if (resumableResponse.ok && resumableResponse.data?.success) { @@ -811,6 +973,7 @@ async function uploadFileWithResume(filePath: string, targetPath: string) { baseUrl: appConfig.baseUrl, filePath, targetPath, + taskId: taskId || null, }); } @@ -918,7 +1081,7 @@ async function runSyncOnce(trigger: "manual" | "auto" = "manual") { }); updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 }); - const resp = await uploadFileWithResume(item.path, targetPath); + const resp = await uploadFileWithResume(item.path, targetPath, taskId); if (resp.ok && resp.data?.success) { syncState.uploadedCount += 1; @@ -1142,12 +1305,59 @@ async function openShareLink(share: ShareItem) { async function restoreSession() { const ok = await loadProfile(); - if (!ok) return; + if (!ok) return false; authenticated.value = true; loadSyncConfig(); rebuildSyncScheduler(); await loadFiles("/"); await loadShares(true); + await checkUpdateAfterLogin(); + return true; +} + +async function tryAutoLoginFromSavedState() { + if (authenticated.value) return true; + const stateResponse = await invokeBridge("api_load_login_state", {}); + if (!(stateResponse.ok && stateResponse.data?.success && stateResponse.data?.hasState)) { + return false; + } + + const savedBase = String(stateResponse.data?.baseUrl || "").trim(); + const savedUsername = String(stateResponse.data?.username || "").trim(); + const savedPassword = String(stateResponse.data?.password || ""); + if (!savedBase || !savedUsername || !savedPassword) { + return false; + } + + appConfig.baseUrl = savedBase.replace(/\/+$/, ""); + loginForm.username = savedUsername; + loginState.loading = true; + const loginResponse = await invokeBridge("api_login", { + baseUrl: appConfig.baseUrl, + username: savedUsername, + password: savedPassword, + captcha: null, + }); + loginState.loading = false; + if (!(loginResponse.ok && loginResponse.data?.success)) { + await invokeBridge("api_clear_login_state", {}); + return false; + } + + authenticated.value = true; + user.value = loginResponse.data.user || null; + nav.value = "files"; + loginForm.password = savedPassword; + loginForm.remember = true; + loadSyncConfig(); + rebuildSyncScheduler(); + await loadFiles("/"); + if (!user.value) { + await loadProfile(); + } + await checkUpdateAfterLogin(); + showToast("已恢复登录状态", "success"); + return true; } function buildItemPath(item: FileItem) { @@ -1210,12 +1420,23 @@ async function handleLogin() { user.value = response.data.user || null; nav.value = "files"; showToast("登录成功,正在同步文件目录", "success"); + hasCheckedUpdateAfterAuth = false; loadSyncConfig(); rebuildSyncScheduler(); await loadFiles("/"); if (!user.value) { await loadProfile(); } + if (loginForm.remember) { + await invokeBridge("api_save_login_state", { + baseUrl: appConfig.baseUrl, + username: loginForm.username.trim(), + password: loginForm.password, + }); + } else { + await invokeBridge("api_clear_login_state", {}); + } + await checkUpdateAfterLogin(); return; } @@ -1226,6 +1447,7 @@ async function handleLogin() { async function handleLogout() { await invokeBridge("api_logout", { baseUrl: appConfig.baseUrl }); + await invokeBridge("api_clear_login_state", {}); clearSyncScheduler(); authenticated.value = false; user.value = null; @@ -1248,6 +1470,9 @@ async function handleLogout() { syncState.lastSummary = ""; syncState.nextRunAt = ""; updateRuntime.downloading = false; + updateRuntime.installing = false; + updatePrompt.visible = false; + hasCheckedUpdateAfterAuth = false; showToast("已退出客户端", "info"); } @@ -1454,7 +1679,7 @@ async function retryTransferTask(taskId: string) { } await waitForTransferQueue(); updateTransferTask(taskId, { status: "uploading", speed: "重试上传", progress: 10, note: "正在重试" }); - const response = await uploadFileWithResume(task.filePath, task.targetPath); + const response = await uploadFileWithResume(task.filePath, task.targetPath, taskId); if (response.ok && response.data?.success) { updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: "重试成功" }); if (nav.value === "files") { @@ -1576,6 +1801,14 @@ function handleGlobalKey(event: KeyboardEvent) { } } +function handleGlobalContextMenu(event: MouseEvent) { + const target = event.target as HTMLElement | null; + if (!target) return; + if (target.closest(".context-menu")) return; + if (target.closest("input, textarea, [contenteditable=\"true\"]")) return; + event.preventDefault(); +} + function extractFileNameFromPath(filePath: string) { const trimmed = String(filePath || "").trim(); if (!trimmed) return ""; @@ -1625,11 +1858,24 @@ async function uploadDroppedFiles(paths: string[]) { }); updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 }); - const response = await uploadFileWithResume(filePath, pathState.currentPath); + uploadRuntime.active = true; + uploadRuntime.taskId = taskId; + uploadRuntime.fileName = displayName; + uploadRuntime.uploadedBytes = 0; + uploadRuntime.totalBytes = 0; + uploadRuntime.progress = 1; + uploadRuntime.speed = "准备上传"; + uploadRuntime.lastMeasureAt = 0; + uploadRuntime.lastMeasureBytes = 0; + + const response = await uploadFileWithResume(filePath, pathState.currentPath, taskId); if (response.ok && response.data?.success) { successCount += 1; dropState.done += 1; + uploadRuntime.progress = 100; + uploadRuntime.speed = "-"; + uploadRuntime.uploadedBytes = Math.max(uploadRuntime.uploadedBytes, uploadRuntime.totalBytes); updateTransferTask(taskId, { speed: "-", progress: 100, @@ -1639,6 +1885,7 @@ async function uploadDroppedFiles(paths: string[]) { } else { dropState.failed += 1; const message = String(response.data?.message || "上传失败"); + uploadRuntime.speed = "-"; updateTransferTask(taskId, { speed: "-", progress: 0, @@ -1649,6 +1896,10 @@ async function uploadDroppedFiles(paths: string[]) { } dropState.uploading = false; + setTimeout(() => { + uploadRuntime.active = false; + uploadRuntime.taskId = ""; + }, 1600); const failedMessage = dropState.failed > 0 ? `,失败 ${dropState.failed} 个` : ""; showToast(`上传完成:成功 ${dropState.done} 个${failedMessage}`, dropState.failed > 0 ? "info" : "success"); if (successCount > 0) { @@ -1699,6 +1950,16 @@ async function registerNativeDownloadProgressListener() { } } +async function registerNativeUploadProgressListener() { + try { + unlistenNativeUploadProgress = await listen("native-upload-progress", (event) => { + applyNativeUploadProgress(event.payload || {}); + }); + } catch (error) { + console.error("register native upload progress listener failed", error); + } +} + watch(nav, async (next) => { if (next === "shares" && authenticated.value) { await loadShares(); @@ -1725,15 +1986,21 @@ watch( onMounted(async () => { window.addEventListener("click", handleGlobalClick); window.addEventListener("keydown", handleGlobalKey); + window.addEventListener("contextmenu", handleGlobalContextMenu); await registerDragDropListener(); await registerNativeDownloadProgressListener(); + await registerNativeUploadProgressListener(); await initClientVersion(); - await restoreSession(); + const restored = await restoreSession(); + if (!restored) { + await tryAutoLoginFromSavedState(); + } }); onBeforeUnmount(() => { window.removeEventListener("click", handleGlobalClick); window.removeEventListener("keydown", handleGlobalKey); + window.removeEventListener("contextmenu", handleGlobalContextMenu); clearSyncScheduler(); if (unlistenDragDrop) { unlistenDragDrop(); @@ -1743,6 +2010,10 @@ onBeforeUnmount(() => { unlistenNativeDownloadProgress(); unlistenNativeDownloadProgress = null; } + if (unlistenNativeUploadProgress) { + unlistenNativeUploadProgress(); + unlistenNativeUploadProgress = null; + } }); @@ -1783,6 +2054,10 @@ onBeforeUnmount(() => { 密码 +