use reqwest::Method; use reqwest::StatusCode; use rusqlite::{params, Connection}; use serde::Serialize; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use std::env; use std::fs; use std::io::Write; use std::io::{Read, Seek, SeekFrom}; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; #[cfg(target_os = "windows")] use std::process::Stdio; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; use tokio::time::sleep; #[cfg(target_os = "windows")] use windows_sys::Win32::Security::Cryptography::{ CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, }; #[cfg(target_os = "windows")] use windows_sys::Win32::Foundation::LocalFree; #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; #[cfg(target_os = "windows")] const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; #[cfg(target_os = "windows")] const DETACHED_PROCESS: u32 = 0x00000008; const RESUMABLE_CHUNK_MAX_RETRIES: u32 = 3; const RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS: u64 = 900; struct ApiState { client: reqwest::Client, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct BridgeResponse { ok: bool, status: u16, 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, } #[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, 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 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() { return String::new(); } trimmed.trim_end_matches('/').to_string() } fn join_api_url(base_url: &str, path: &str) -> String { format!("{}{}", normalize_base_url(base_url), path) } fn sanitize_device_id_component(raw: &str) -> String { let mut output = String::new(); let mut last_is_dash = false; for ch in raw.chars() { let normalized = ch.to_ascii_lowercase(); if normalized.is_ascii_alphanumeric() { output.push(normalized); last_is_dash = false; } else if !last_is_dash { output.push('-'); last_is_dash = true; } } output.trim_matches('-').to_string() } fn build_desktop_client_meta() -> (String, String, String) { let os = match env::consts::OS { "windows" => "Windows", "macos" => "macOS", "linux" => "Linux", other => other, }; let platform = format!("{}-{}", os, env::consts::ARCH); let host_name = env::var("COMPUTERNAME") .or_else(|_| env::var("HOSTNAME")) .unwrap_or_default(); let host_trimmed = host_name.trim(); let device_name = if host_trimmed.is_empty() { format!("桌面客户端 · {}", platform) } else { format!("{} · {}", host_trimmed, platform) }; let id_seed = if host_trimmed.is_empty() { platform.clone() } else { format!("{}-{}", host_trimmed, platform) }; let normalized = sanitize_device_id_component(&id_seed); let device_id = if normalized.is_empty() { "desktop-client".to_string() } else { format!("desktop-{}", normalized) }; (platform, device_name, device_id) } fn build_upload_file_fingerprint(meta: &fs::Metadata) -> Option { let size = meta.len(); let modified_ms = meta .modified() .ok() .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or(0); let fingerprint = format!("v1:size:{}:mtime:{}", size, modified_ms); if fingerprint.len() > 120 { None } else { Some(fingerprint) } } fn is_retryable_upload_status(status: u16) -> bool { matches!(status, 408 | 425 | 429 | 500 | 502 | 503 | 504) } fn is_retryable_transport_error(err: &reqwest::Error) -> bool { err.is_timeout() || err.is_connect() || err.is_request() || err.is_body() } fn build_chunk_retry_delay(attempt: u32) -> Duration { let multiplier = 2_u64.saturating_pow(attempt.min(5)); let ms = RESUMABLE_CHUNK_RETRY_BASE_DELAY_MS.saturating_mul(multiplier).min(15_000); Duration::from_millis(ms) } fn to_hex_string(bytes: &[u8]) -> String { let mut output = String::with_capacity(bytes.len() * 2); for b in bytes { output.push_str(&format!("{:02x}", b)); } output } fn compute_file_sha256_hex(file_path: &Path) -> Result { let mut file = fs::File::open(file_path).map_err(|err| format!("打开文件失败: {}", err))?; let mut hasher = Sha256::new(); let mut buf = vec![0_u8; 1024 * 256]; loop { let read = file .read(&mut buf) .map_err(|err| format!("读取文件失败: {}", err))?; if read == 0 { break; } hasher.update(&buf[..read]); } Ok(to_hex_string(&hasher.finalize())) } #[cfg(target_os = "windows")] fn dpapi_protect_bytes(input: &[u8]) -> Result, String> { if input.is_empty() { return Ok(Vec::new()); } let mut in_blob = CRYPT_INTEGER_BLOB { cbData: input.len() as u32, pbData: input.as_ptr() as *mut u8, }; let mut out_blob = CRYPT_INTEGER_BLOB { cbData: 0, pbData: std::ptr::null_mut(), }; let ok = unsafe { CryptProtectData( &mut in_blob, std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), CRYPTPROTECT_UI_FORBIDDEN, &mut out_blob, ) }; if ok == 0 { return Err(format!("加密登录状态失败: {}", std::io::Error::last_os_error())); } let data = unsafe { std::slice::from_raw_parts(out_blob.pbData as *const u8, out_blob.cbData as usize).to_vec() }; unsafe { LocalFree(out_blob.pbData as *mut core::ffi::c_void); } Ok(data) } #[cfg(target_os = "windows")] fn dpapi_unprotect_bytes(input: &[u8]) -> Result, String> { if input.is_empty() { return Ok(Vec::new()); } let mut in_blob = CRYPT_INTEGER_BLOB { cbData: input.len() as u32, pbData: input.as_ptr() as *mut u8, }; let mut out_blob = CRYPT_INTEGER_BLOB { cbData: 0, pbData: std::ptr::null_mut(), }; let ok = unsafe { CryptUnprotectData( &mut in_blob, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), CRYPTPROTECT_UI_FORBIDDEN, &mut out_blob, ) }; if ok == 0 { return Err(format!("解密登录状态失败: {}", std::io::Error::last_os_error())); } let data = unsafe { std::slice::from_raw_parts(out_blob.pbData as *const u8, out_blob.cbData as usize).to_vec() }; unsafe { LocalFree(out_blob.pbData as *mut core::ffi::c_void); } Ok(data) } fn encode_login_password(raw_password: &str) -> Result { #[cfg(target_os = "windows")] { let protected = dpapi_protect_bytes(raw_password.as_bytes())?; return Ok(format!("dpapi:{}", to_hex_string(&protected))); } #[cfg(not(target_os = "windows"))] { Ok(raw_password.to_string()) } } fn decode_login_password(stored_password: &str) -> Result { let raw = stored_password.trim(); if let Some(hex_body) = raw.strip_prefix("dpapi:") { #[cfg(target_os = "windows")] { if hex_body.len() % 2 != 0 { return Err("登录状态密文格式无效".to_string()); } let mut encrypted = Vec::with_capacity(hex_body.len() / 2); let bytes = hex_body.as_bytes(); let mut index = 0; while index < bytes.len() { let part = std::str::from_utf8(&bytes[index..index + 2]) .map_err(|_| "登录状态密文格式无效".to_string())?; let value = u8::from_str_radix(part, 16) .map_err(|_| "登录状态密文格式无效".to_string())?; encrypted.push(value); index += 2; } let plain = dpapi_unprotect_bytes(&encrypted)?; return String::from_utf8(plain).map_err(|_| "登录状态密文解码失败".to_string()); } #[cfg(not(target_os = "windows"))] { let _ = hex_body; return Err("当前系统不支持读取该登录状态密文".to_string()); } } Ok(raw.to_string()) } fn fallback_json(status: StatusCode, text: &str) -> Value { let mut data = Map::new(); data.insert("success".to_string(), Value::Bool(status.is_success())); data.insert( "message".to_string(), Value::String(if text.trim().is_empty() { format!("HTTP {}", status.as_u16()) } else { text.to_string() }), ); 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 } fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, url: &str) -> PathBuf { let mut hasher = std::collections::hash_map::DefaultHasher::new(); use std::hash::{Hash, Hasher}; preferred_name.hash(&mut hasher); url.hash(&mut hasher); let digest = format!("{:016x}", hasher.finish()); let safe_name = sanitize_file_name(preferred_name); let temp_name = format!(".{}.{}.part", safe_name, digest); download_dir.join(temp_name) } fn is_update_installer_file_name(file_name: &str) -> bool { let lower = file_name.trim().to_ascii_lowercase(); if !lower.ends_with(".exe") { return false; } lower.starts_with("wanwan-cloud-desktop_v") || file_name.trim().starts_with("玩玩云_v") } fn cleanup_old_update_installers( download_dir: &Path, keep_file_name: &str, keep_latest: usize, ) -> Result<(), String> { let mut entries: Vec<(PathBuf, SystemTime)> = Vec::new(); let normalized_keep = keep_file_name.trim(); for entry in fs::read_dir(download_dir).map_err(|err| format!("扫描下载目录失败: {}", err))? { let path = match entry { Ok(item) => item.path(), Err(_) => continue, }; if !path.is_file() { continue; } let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; if !is_update_installer_file_name(file_name) { continue; } let modified = fs::metadata(&path) .ok() .and_then(|meta| meta.modified().ok()) .unwrap_or(SystemTime::UNIX_EPOCH); entries.push((path, modified)); } entries.sort_by(|a, b| b.1.cmp(&a.1)); let retain_count = keep_latest.max(1); let mut retained = 0usize; for (path, _) in entries { let file_name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or_default() .to_string(); let should_keep = file_name == normalized_keep || retained < retain_count; if should_keep { retained += 1; continue; } let _ = fs::remove_file(&path); } Ok(()) } 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 encoded_password = encode_login_password(password)?; 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, encoded_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)?, )) }); drop(stmt); match row { Ok((base_url, username, password)) => { match decode_login_password(&password) { Ok(decoded_password) => Ok(Some((base_url, username, decoded_password))), Err(err) => { eprintln!("decode login state failed, clearing invalid state: {}", err); let _ = conn.execute("DELETE FROM login_state WHERE id = 1", []); Ok(None) } } } 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 .text() .await .map_err(|err| format!("读取响应失败: {}", err))?; let data = match serde_json::from_str::(&text) { Ok(parsed) => parsed, Err(_) => fallback_json(status, &text), }; Ok(BridgeResponse { ok: status.is_success(), status: status.as_u16(), data, }) } async fn request_json( client: &reqwest::Client, method: Method, url: String, body: Option, csrf_token: Option, ) -> Result { if url.is_empty() { return Err("API 地址不能为空".to_string()); } let mut request = client .request(method, &url) .header("Accept", "application/json") .header("Content-Type", "application/json"); if let Some(csrf) = csrf_token { request = request.header("X-CSRF-Token", csrf); } if let Some(payload) = body { request = request.json(&payload); } let response = request .send() .await .map_err(|err| format!("请求失败: {}", err))?; let status = response.status(); let text = response .text() .await .map_err(|err| format!("读取响应失败: {}", err))?; let data = match serde_json::from_str::(&text) { Ok(parsed) => parsed, Err(_) => fallback_json(status, &text), }; Ok(BridgeResponse { ok: status.is_success(), status: status.as_u16(), data, }) } async fn fetch_csrf_token(client: &reqwest::Client, base_url: &str) -> Result, String> { let response = request_json( client, Method::GET, join_api_url(base_url, "/api/csrf-token"), None, None, ) .await?; let token = response .data .get("csrfToken") .and_then(Value::as_str) .map(|v| v.to_string()); Ok(token) } async fn request_with_optional_csrf( client: &reqwest::Client, method: Method, base_url: &str, path: &str, body: Option, need_csrf: bool, ) -> Result { let csrf_token = if need_csrf { fetch_csrf_token(client, base_url).await? } else { None }; request_json( client, method, join_api_url(base_url, path), body, csrf_token, ) .await } #[tauri::command] async fn api_login( state: tauri::State<'_, ApiState>, base_url: String, username: String, password: String, captcha: Option, ) -> Result { let (platform, device_name, device_id) = build_desktop_client_meta(); let mut body = Map::new(); body.insert("username".to_string(), Value::String(username)); body.insert("password".to_string(), Value::String(password)); body.insert("client_type".to_string(), Value::String("desktop".to_string())); body.insert("platform".to_string(), Value::String(platform)); body.insert("device_name".to_string(), Value::String(device_name)); body.insert("device_id".to_string(), Value::String(device_id)); if let Some(value) = captcha { if !value.trim().is_empty() { body.insert("captcha".to_string(), Value::String(value)); } } request_with_optional_csrf( &state.client, Method::POST, &base_url, "/api/login", Some(Value::Object(body)), false, ) .await } #[tauri::command] async fn api_get_profile( state: tauri::State<'_, ApiState>, base_url: String, ) -> Result { request_with_optional_csrf( &state.client, Method::GET, &base_url, "/api/user/profile", None, false, ) .await } #[tauri::command] async fn api_list_online_devices( state: tauri::State<'_, ApiState>, base_url: String, ) -> Result { request_with_optional_csrf( &state.client, Method::GET, &base_url, "/api/user/online-devices", None, false, ) .await } #[tauri::command] async fn api_kick_online_device( state: tauri::State<'_, ApiState>, base_url: String, session_id: String, ) -> Result { let session = session_id.trim().to_string(); if session.is_empty() { return Err("会话标识不能为空".to_string()); } if session.len() > 128 { return Err("会话标识长度无效".to_string()); } let api_path = format!( "/api/user/online-devices/{}/kick", urlencoding::encode(&session) ); request_with_optional_csrf( &state.client, Method::POST, &base_url, &api_path, Some(Value::Object(Map::new())), true, ) .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>, base_url: String, path: String, ) -> Result { let normalized = if path.trim().is_empty() { "/".to_string() } else { path }; let encoded = urlencoding::encode(&normalized); let api_url = format!("{}?path={}", join_api_url(&base_url, "/api/files"), encoded); request_json(&state.client, Method::GET, api_url, None, None).await } #[tauri::command] async fn api_logout( state: tauri::State<'_, ApiState>, base_url: String, ) -> Result { request_with_optional_csrf( &state.client, Method::POST, &base_url, "/api/logout", Some(Value::Object(Map::new())), true, ) .await } #[tauri::command] async fn api_refresh_token( state: tauri::State<'_, ApiState>, base_url: String, ) -> Result { request_json( &state.client, Method::POST, join_api_url(&base_url, "/api/refresh-token"), Some(Value::Object(Map::new())), None, ) .await } #[tauri::command] async fn api_search_files( state: tauri::State<'_, ApiState>, base_url: String, path: String, keyword: String, search_type: Option, limit: Option, ) -> Result { let normalized_path = if path.trim().is_empty() { "/".to_string() } else { path }; let kind = search_type .unwrap_or_else(|| "all".to_string()) .trim() .to_string(); let max_limit = limit.unwrap_or(100).clamp(1, 500); let api_url = format!( "{}?path={}&keyword={}&type={}&limit={}", join_api_url(&base_url, "/api/files/search"), urlencoding::encode(&normalized_path), urlencoding::encode(&keyword), urlencoding::encode(&kind), max_limit ); request_json(&state.client, Method::GET, api_url, None, None).await } #[tauri::command] async fn api_mkdir( state: tauri::State<'_, ApiState>, base_url: String, path: String, folder_name: String, ) -> Result { let mut body = Map::new(); body.insert("path".to_string(), Value::String(path)); body.insert("folderName".to_string(), Value::String(folder_name)); request_with_optional_csrf( &state.client, Method::POST, &base_url, "/api/files/mkdir", Some(Value::Object(body)), true, ) .await } #[tauri::command] async fn api_rename_file( state: tauri::State<'_, ApiState>, base_url: String, path: String, old_name: String, new_name: String, ) -> Result { let mut body = Map::new(); body.insert("path".to_string(), Value::String(path)); body.insert("oldName".to_string(), Value::String(old_name)); body.insert("newName".to_string(), Value::String(new_name)); request_with_optional_csrf( &state.client, Method::POST, &base_url, "/api/files/rename", Some(Value::Object(body)), true, ) .await } #[tauri::command] async fn api_delete_file( state: tauri::State<'_, ApiState>, base_url: String, path: String, file_name: String, ) -> Result { let mut body = Map::new(); body.insert("path".to_string(), Value::String(path)); body.insert("fileName".to_string(), Value::String(file_name)); request_with_optional_csrf( &state.client, Method::POST, &base_url, "/api/files/delete", Some(Value::Object(body)), true, ) .await } #[tauri::command] async fn api_get_download_url( state: tauri::State<'_, ApiState>, base_url: String, path: String, mode: Option, ) -> Result { let normalized_mode = mode.unwrap_or_else(|| "download".to_string()); let api_url = format!( "{}?path={}&mode={}", join_api_url(&base_url, "/api/files/download-url"), urlencoding::encode(&path), urlencoding::encode(&normalized_mode) ); request_json(&state.client, Method::GET, api_url, None, None).await } #[tauri::command] async fn api_get_my_shares( state: tauri::State<'_, ApiState>, base_url: String, ) -> Result { request_with_optional_csrf( &state.client, Method::GET, &base_url, "/api/share/my", None, false, ) .await } #[tauri::command] async fn api_get_my_direct_links( state: tauri::State<'_, ApiState>, base_url: String, ) -> Result { request_with_optional_csrf( &state.client, Method::GET, &base_url, "/api/direct-link/my", None, false, ) .await } #[tauri::command] async fn api_delete_direct_link( state: tauri::State<'_, ApiState>, base_url: String, link_id: i64, ) -> Result { if link_id <= 0 { return Err("无效的直链ID".to_string()); } let csrf_token = fetch_csrf_token(&state.client, &base_url).await?; let path = format!("/api/direct-link/{}", link_id); request_json( &state.client, Method::DELETE, join_api_url(&base_url, &path), None, csrf_token, ) .await } #[tauri::command] async fn api_create_share( state: tauri::State<'_, ApiState>, base_url: String, share_type: String, file_path: String, file_name: Option, password: Option, expiry_days: Option, ) -> Result { let mut body = Map::new(); body.insert("share_type".to_string(), Value::String(share_type)); body.insert("file_path".to_string(), Value::String(file_path)); if let Some(name) = file_name { if !name.trim().is_empty() { body.insert("file_name".to_string(), Value::String(name.trim().to_string())); } } if let Some(raw_password) = password { let value = raw_password.trim(); if value.is_empty() { body.insert("password".to_string(), Value::Null); } else { body.insert("password".to_string(), Value::String(value.to_string())); } } if let Some(days) = expiry_days { if days > 0 { body.insert("expiry_days".to_string(), Value::Number(days.into())); } else { body.insert("expiry_days".to_string(), Value::Null); } } else { body.insert("expiry_days".to_string(), Value::Null); } request_with_optional_csrf( &state.client, Method::POST, &base_url, "/api/share/create", Some(Value::Object(body)), true, ) .await } #[tauri::command] async fn api_delete_share( state: tauri::State<'_, ApiState>, base_url: String, share_id: u64, ) -> Result { if share_id == 0 { return Err("无效的分享ID".to_string()); } let csrf_token = fetch_csrf_token(&state.client, &base_url).await?; let path = format!("/api/share/{}", share_id); request_json( &state.client, Method::DELETE, join_api_url(&base_url, &path), None, csrf_token, ) .await } #[tauri::command] async fn api_create_direct_link( state: tauri::State<'_, ApiState>, base_url: String, file_path: String, file_name: Option, expiry_days: Option, ) -> Result { let mut body = Map::new(); body.insert("file_path".to_string(), Value::String(file_path)); if let Some(name) = file_name { if !name.trim().is_empty() { body.insert("file_name".to_string(), Value::String(name.trim().to_string())); } } if let Some(days) = expiry_days { if days > 0 { body.insert("expiry_days".to_string(), Value::Number(days.into())); } else { body.insert("expiry_days".to_string(), Value::Null); } } else { body.insert("expiry_days".to_string(), Value::Null); } request_with_optional_csrf( &state.client, Method::POST, &base_url, "/api/direct-link/create", Some(Value::Object(body)), true, ) .await } #[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() { return Err("下载地址不能为空".to_string()); } 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 resume_temp_path = build_download_resume_temp_path(&download_dir, preferred_name, &trimmed_url); let existing_size = if resume_temp_path.exists() { fs::metadata(&resume_temp_path) .ok() .map(|meta| meta.len()) .unwrap_or(0) } else { 0 }; let mut request = state.client.get(&trimmed_url); if existing_size > 0 { request = request.header("Range", format!("bytes={}-", existing_size)); } let response = request .send() .await .map_err(|err| format!("下载请求失败: {}", err))?; let status = response.status(); if status == reqwest::StatusCode::RANGE_NOT_SATISFIABLE && existing_size > 0 { 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(saved_name) = save_path.file_name().and_then(|name| name.to_str()) { if is_update_installer_file_name(saved_name) { let _ = cleanup_old_update_installers(&download_dir, saved_name, 3); } } 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( "savePath".to_string(), Value::String(save_path.to_string_lossy().to_string()), ); data.insert( "downloadedBytes".to_string(), Value::Number(serde_json::Number::from(existing_size)), ); data.insert( "resumedBytes".to_string(), Value::Number(serde_json::Number::from(existing_size)), ); return Ok(BridgeResponse { ok: true, status: 200, data: Value::Object(data), }); } if !status.is_success() { return Ok(BridgeResponse { ok: false, status: status.as_u16(), data: fallback_json(status, "下载失败"), }); } 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))?; } let mut target_file = fs::OpenOptions::new() .create(true) .write(true) .append(append_mode) .truncate(!append_mode) .open(&resume_temp_path) .map_err(|err| format!("创建文件失败: {}", err))?; 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 .map_err(|err| format!("读取下载流失败: {}", err))? { target_file .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 .flush() .map_err(|err| format!("刷新文件失败: {}", err))?; 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(saved_name) = save_path.file_name().and_then(|name| name.to_str()) { if is_update_installer_file_name(saved_name) { let _ = cleanup_old_update_installers(&download_dir, saved_name, 3); } } 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( "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)), ); data.insert( "resumedBytes".to_string(), Value::Number(serde_json::Number::from(if append_mode { existing_size } else { 0 })), ); Ok(BridgeResponse { ok: true, status: 200, data: Value::Object(data), }) } #[tauri::command] fn api_compute_file_sha256(file_path: String) -> Result { let normalized = file_path.trim().to_string(); if normalized.is_empty() { return Err("文件路径不能为空".to_string()); } let target = PathBuf::from(&normalized); if !target.exists() { return Err("文件不存在".to_string()); } if !target.is_file() { return Err("无效的文件路径".to_string()); } let file_size = fs::metadata(&target) .map(|meta| meta.len()) .map_err(|err| format!("读取文件大小失败: {}", err))?; let sha256 = compute_file_sha256_hex(&target)?; let mut data = Map::new(); data.insert("success".to_string(), Value::Bool(true)); data.insert("filePath".to_string(), Value::String(normalized)); data.insert("sha256".to_string(), Value::String(sha256)); data.insert( "fileSize".to_string(), Value::Number(serde_json::Number::from(file_size)), ); Ok(BridgeResponse { ok: true, status: 200, data: Value::Object(data), }) } #[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] fn api_silent_install_and_restart(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 windows_log_file_path: String; #[cfg(target_os = "windows")] let windows_script_file_path: String; #[cfg(target_os = "windows")] { let current_exe = env::current_exe().map_err(|err| format!("获取当前程序路径失败: {}", err))?; let current_pid = std::process::id(); 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 log_path = temp_dir.join(format!("silent-update-{}.log", script_stamp)); windows_log_file_path = log_path.to_string_lossy().to_string(); windows_script_file_path = script_path.to_string_lossy().to_string(); let installer_text = installer.to_string_lossy().replace('"', "\"\""); let app_text = current_exe.to_string_lossy().replace('"', "\"\""); let log_text = log_path.to_string_lossy().replace('"', "\"\""); let bootstrap_content = format!( "[bootstrap] silent updater prepared\r\npid={}\r\ninstaller={}\r\nscript={}\r\n", current_pid, installer.to_string_lossy(), script_path.to_string_lossy() ); fs::write(&log_path, bootstrap_content).map_err(|err| format!("写入更新日志失败: {}", err))?; let script_content = format!( "@echo off\r\n\ setlocal enableextensions\r\n\ set \"INSTALLER={installer}\"\r\n\ set \"APP_EXE={app_exe}\"\r\n\ set \"APP_PID={app_pid}\"\r\n\ set \"LOG_FILE={log_file}\"\r\n\ echo [%%date%% %%time%%] update script started > \"%LOG_FILE%\"\r\n\ if not exist \"%INSTALLER%\" (\r\n\ echo [%%date%% %%time%%] installer not found: %INSTALLER% >> \"%LOG_FILE%\"\r\n\ exit /b 2\r\n\ )\r\n\ timeout /t 1 /nobreak >nul\r\n\ taskkill /PID %APP_PID% /F >nul 2>nul\r\n\ timeout /t 1 /nobreak >nul\r\n\ start \"\" /wait \"%INSTALLER%\" /S\r\n\ set \"INSTALL_EXIT=%ERRORLEVEL%\"\r\n\ echo [%%date%% %%time%%] installer exit code: %INSTALL_EXIT% >> \"%LOG_FILE%\"\r\n\ set \"RETRY_COUNT=0\"\r\n\ :wait_for_app\r\n\ if exist \"%APP_EXE%\" goto launch_app\r\n\ if exist \"%LOCALAPPDATA%\\玩玩云\\desktop-client.exe\" (\r\n\ set \"APP_EXE=%LOCALAPPDATA%\\玩玩云\\desktop-client.exe\"\r\n\ goto launch_app\r\n\ )\r\n\ if %RETRY_COUNT% GEQ 25 goto app_missing\r\n\ set /a RETRY_COUNT+=1\r\n\ timeout /t 1 /nobreak >nul\r\n\ goto wait_for_app\r\n\ :launch_app\r\n\ start \"\" \"%APP_EXE%\"\r\n\ set \"START_EXIT=%ERRORLEVEL%\"\r\n\ echo [%%date%% %%time%%] launch app exit code: %START_EXIT% path=%APP_EXE% >> \"%LOG_FILE%\"\r\n\ goto cleanup\r\n\ :app_missing\r\n\ echo [%%date%% %%time%%] app exe not found after install >> \"%LOG_FILE%\"\r\n\ :cleanup\r\n\ del \"%~f0\" >nul 2>nul\r\n", installer = installer_text, app_exe = app_text, app_pid = current_pid, log_file = log_text ); fs::write(&script_path, script_content).map_err(|err| format!("写入更新脚本失败: {}", err))?; let mut updater_cmd = Command::new("cmd"); let spawn_result = updater_cmd .arg("/D") .arg("/C") .arg("call") .arg(&script_path) .current_dir(&temp_dir) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) .spawn(); if let Err(err) = spawn_result { let _ = fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) .and_then(|mut file| { writeln!(file, "[bootstrap] failed to spawn updater cmd: {}", err)?; Ok(()) }); return Err(format!("启动静默更新流程失败: {}", err)); } let mut cleanup_entries: Vec = fs::read_dir(&temp_dir) .ok() .into_iter() .flat_map(|entries| entries.filter_map(Result::ok)) .map(|entry| entry.path()) .filter(|path| { path.file_name() .and_then(|name| name.to_str()) .map(|name| name.starts_with("silent-update-")) .unwrap_or(false) }) .collect(); cleanup_entries.sort_by_key(|path| { fs::metadata(path) .ok() .and_then(|meta| meta.modified().ok()) .unwrap_or(SystemTime::UNIX_EPOCH) }); if cleanup_entries.len() > 24 { let remove_count = cleanup_entries.len().saturating_sub(24); for stale_path in cleanup_entries.into_iter().take(remove_count) { let _ = fs::remove_file(stale_path); } } } #[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)); #[cfg(target_os = "windows")] { data.insert( "logPath".to_string(), Value::String( env::temp_dir() .join("wanwan-cloud-desktop") .to_string_lossy() .to_string(), ), ); data.insert( "logFilePath".to_string(), Value::String(windows_log_file_path), ); data.insert( "scriptPath".to_string(), Value::String(windows_script_file_path), ); } Ok(BridgeResponse { ok: true, status: 200, data: Value::Object(data), }) } #[tauri::command] async fn api_check_client_update( state: tauri::State<'_, ApiState>, base_url: String, current_version: String, platform: Option, channel: Option, ) -> Result { let normalized_platform = platform .unwrap_or_else(|| "windows-x64".to_string()) .trim() .to_string(); let normalized_channel = channel .unwrap_or_else(|| "stable".to_string()) .trim() .to_string(); let api_url = format!( "{}?currentVersion={}&platform={}&channel={}", join_api_url(&base_url, "/api/client/desktop-update"), urlencoding::encode(current_version.trim()), urlencoding::encode(&normalized_platform), urlencoding::encode(&normalized_channel) ); request_json(&state.client, Method::GET, api_url, None, None).await } #[tauri::command] async fn api_list_local_files(dir_path: String) -> Result { let trimmed = dir_path.trim().to_string(); if trimmed.is_empty() { return Err("本地目录不能为空".to_string()); } let root = PathBuf::from(&trimmed); if !root.exists() { return Err("本地目录不存在".to_string()); } if !root.is_dir() { return Err("请选择有效的目录路径".to_string()); } let mut items: Vec = Vec::new(); for entry in walkdir::WalkDir::new(&root) .follow_links(false) .into_iter() .filter_map(Result::ok) { if !entry.file_type().is_file() { continue; } let full_path = entry.path(); let relative = full_path.strip_prefix(&root).unwrap_or(full_path); let relative_path = relative.to_string_lossy().replace('\\', "/"); if relative_path.trim().is_empty() { continue; } let metadata = match entry.metadata() { Ok(meta) => meta, Err(_) => continue, }; let modified_ms_u128 = metadata .modified() .ok() .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or(0); let modified_ms = std::cmp::min(modified_ms_u128, u128::from(u64::MAX)) as u64; let mut row = Map::new(); row.insert( "path".to_string(), Value::String(full_path.to_string_lossy().to_string()), ); row.insert("relativePath".to_string(), Value::String(relative_path)); row.insert( "size".to_string(), Value::Number(serde_json::Number::from(metadata.len())), ); row.insert( "modifiedMs".to_string(), Value::Number(serde_json::Number::from(modified_ms)), ); items.push(Value::Object(row)); } items.sort_by(|a, b| { let av = a .get("relativePath") .and_then(Value::as_str) .unwrap_or_default(); let bv = b .get("relativePath") .and_then(Value::as_str) .unwrap_or_default(); av.cmp(bv) }); let mut data = Map::new(); data.insert("success".to_string(), Value::Bool(true)); data.insert("rootPath".to_string(), Value::String(trimmed)); data.insert( "count".to_string(), Value::Number(serde_json::Number::from(items.len() as u64)), ); data.insert("items".to_string(), Value::Array(items)); Ok(BridgeResponse { ok: true, status: 200, data: Value::Object(data), }) } #[tauri::command] async fn api_upload_file_resumable( state: tauri::State<'_, ApiState>, 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() { return Err("上传文件路径不能为空".to_string()); } let source_path = PathBuf::from(&trimmed_path); if !source_path.exists() { return Err("上传文件不存在".to_string()); } if !source_path.is_file() { return Err("仅支持上传文件,不支持文件夹".to_string()); } let metadata = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?; let file_size = metadata.len(); if file_size == 0 { return Err("空文件不支持分片上传".to_string()); } let file_fingerprint = build_upload_file_fingerprint(&metadata); let file_name = source_path .file_name() .and_then(|name| name.to_str()) .map(|name| name.to_string()) .ok_or_else(|| "无法识别文件名".to_string())?; let normalized_target = if target_path.trim().is_empty() { "/".to_string() } else { target_path }; let effective_chunk = chunk_size.unwrap_or(4 * 1024 * 1024).clamp(256 * 1024, 32 * 1024 * 1024); let csrf_token = fetch_csrf_token(&state.client, &base_url).await?; let mut init_body = Map::new(); init_body.insert("filename".to_string(), Value::String(file_name.clone())); init_body.insert("path".to_string(), Value::String(normalized_target)); init_body.insert( "size".to_string(), Value::Number(serde_json::Number::from(file_size)), ); init_body.insert( "chunk_size".to_string(), Value::Number(serde_json::Number::from(effective_chunk)), ); if let Some(hash) = file_fingerprint.clone() { init_body.insert("file_hash".to_string(), Value::String(hash)); } let init_resp = request_json( &state.client, Method::POST, join_api_url(&base_url, "/api/upload/resumable/init"), Some(Value::Object(init_body)), csrf_token.clone(), ) .await?; if !init_resp.ok || !init_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) { return Ok(init_resp); } let session_id = init_resp .data .get("session_id") .and_then(Value::as_str) .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .ok_or_else(|| "分片上传会话创建失败".to_string())?; let server_chunk_size = init_resp .data .get("chunk_size") .and_then(Value::as_u64) .unwrap_or(effective_chunk) .max(1); let total_chunks = init_resp .data .get("total_chunks") .and_then(Value::as_u64) .unwrap_or_else(|| ((file_size + server_chunk_size - 1) / server_chunk_size).max(1)); let uploaded_chunks: std::collections::HashSet = init_resp .data .get("uploaded_chunks") .and_then(Value::as_array) .map(|arr| { arr.iter() .filter_map(Value::as_u64) .collect::>() }) .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; } let offset = chunk_index * server_chunk_size; let remaining = file_size.saturating_sub(offset); if remaining == 0 { break; } let read_size = std::cmp::min(remaining, server_chunk_size) as usize; source .seek(SeekFrom::Start(offset)) .map_err(|err| format!("读取分片失败: {}", err))?; let mut buf = vec![0_u8; read_size]; source .read_exact(&mut buf) .map_err(|err| format!("读取分片失败: {}", err))?; let chunk_part_name = format!("{}.part{}", file_name, chunk_index); let mut chunk_done = false; for attempt in 0..=RESUMABLE_CHUNK_MAX_RETRIES { let multipart = reqwest::multipart::Form::new() .text("session_id", session_id.clone()) .text("chunk_index", chunk_index.to_string()) .part( "chunk", reqwest::multipart::Part::bytes(buf.clone()).file_name(chunk_part_name.clone()), ); let mut request = state .client .post(join_api_url(&base_url, "/api/upload/resumable/chunk")) .header("Accept", "application/json") .timeout(Duration::from_secs(60 * 10)) .multipart(multipart); if let Some(token) = csrf_token.clone() { request = request.header("X-CSRF-Token", token); } let chunk_bridge = match request.send().await { Ok(chunk_resp) => parse_response_as_bridge(chunk_resp).await?, Err(err) => { if attempt < RESUMABLE_CHUNK_MAX_RETRIES && is_retryable_transport_error(&err) { sleep(build_chunk_retry_delay(attempt)).await; continue; } return Err(format!("上传分片失败: {}", err)); } }; let chunk_success = chunk_bridge.ok && chunk_bridge .data .get("success") .and_then(Value::as_bool) .unwrap_or(false); if chunk_success { chunk_done = true; break; } let message = chunk_bridge .data .get("message") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let retryable_status = is_retryable_upload_status(chunk_bridge.status); let retryable_message = message.contains("超时") || message.to_lowercase().contains("timeout") || message.contains("稍后重试"); if attempt < RESUMABLE_CHUNK_MAX_RETRIES && (retryable_status || retryable_message) { sleep(build_chunk_retry_delay(attempt)).await; continue; } return Ok(chunk_bridge); } if !chunk_done { return Err("上传分片失败,请重试".to_string()); } 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)); let mut complete_request = state .client .post(join_api_url(&base_url, "/api/upload/resumable/complete")) .header("Accept", "application/json") .header("Content-Type", "application/json") .timeout(Duration::from_secs(60 * 20)) .json(&Value::Object(complete_body)); if let Some(token) = csrf_token { complete_request = complete_request.header("X-CSRF-Token", token); } let complete_raw = complete_request .send() .await .map_err(|err| format!("完成分片上传失败: {}", err))?; let complete_resp = parse_response_as_bridge(complete_raw).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() { return Err("上传文件路径不能为空".to_string()); } let source_path = PathBuf::from(trimmed_path); if !source_path.exists() { return Err("上传文件不存在".to_string()); } if !source_path.is_file() { return Err("仅支持上传文件,不支持文件夹".to_string()); } let file_meta = fs::metadata(&source_path).map_err(|err| format!("读取文件信息失败: {}", err))?; let file_size = file_meta.len(); let file_fingerprint = build_upload_file_fingerprint(&file_meta); let file_name = source_path .file_name() .and_then(|name| name.to_str()) .map(|name| name.to_string()) .ok_or_else(|| "无法识别文件名".to_string())?; let normalized_target = if target_path.trim().is_empty() { "/".to_string() } else { target_path }; let csrf_token = fetch_csrf_token(&state.client, &base_url).await?; let upload_url = join_api_url(&base_url, "/api/upload"); if upload_url.trim().is_empty() { 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 .map_err(|err| format!("读取文件失败: {}", err))? .file_name(file_name); let mut multipart = reqwest::multipart::Form::new() .text("path", normalized_target) .part("file", file_part); if let Some(hash) = file_fingerprint { multipart = multipart.text("file_hash", hash); } let mut request = state .client .post(&upload_url) .header("Accept", "application/json") .timeout(Duration::from_secs(60 * 30)) .multipart(multipart); if let Some(csrf) = csrf_token { request = request.header("X-CSRF-Token", csrf); } let response = request .send() .await .map_err(|err| format!("上传请求失败: {}", err))?; let status = response.status(); let text = response .text() .await .map_err(|err| format!("读取响应失败: {}", err))?; let data = match serde_json::from_str::(&text) { 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(), status: status.as_u16(), data, }) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let client = reqwest::Client::builder() .cookie_store(true) .timeout(Duration::from_secs(90)) .build() .expect("failed to build reqwest client"); tauri::Builder::default() .manage(ApiState { client }) .plugin(tauri_plugin_dialog::init()) .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_online_devices, api_kick_online_device, api_list_files, api_logout, api_refresh_token, api_search_files, api_mkdir, api_rename_file, api_delete_file, api_get_download_url, api_get_my_shares, api_get_my_direct_links, api_delete_direct_link, api_create_share, api_delete_share, api_create_direct_link, api_native_download, api_compute_file_sha256, api_launch_installer, api_silent_install_and_restart, api_check_client_update, api_list_local_files, api_upload_file_resumable, api_upload_file ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }