diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index 83f6dfc..d4b40d4 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -103,8 +103,8 @@ pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> { crate::db::remove_ownership(&connection, id) } -pub fn primary_path_for_id( - app_handle: &tauri::AppHandle, +pub fn primary_path_for_id( + app_handle: &tauri::AppHandle, id: &str, ) -> Result, String> { Ok(load_records(app_handle)? @@ -130,7 +130,7 @@ pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result Ok(paths) } -fn load_records(app_handle: &tauri::AppHandle) -> Result, String> { +fn load_records(app_handle: &tauri::AppHandle) -> Result, String> { let database = app_handle.state::(); let connection = database.lock()?; crate::db::load_ownership(&connection).map(|records| { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81de6fe..e72576a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1286,7 +1286,7 @@ async fn test_deno(app_handle: tauri::AppHandle) -> Result { } } -pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle) -> bool { +pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle) -> bool { if !path.is_absolute() || path.components().any(|component| { matches!( @@ -1322,7 +1322,7 @@ fn canonicalize_with_missing_components(path: &std::path::Path) -> Option Vec { +fn approved_download_roots(app_handle: &tauri::AppHandle) -> Vec { use tauri::Manager; let mut roots = Vec::new(); @@ -1511,7 +1511,7 @@ pub struct EngineStatusResult { pub engines: Vec, } -pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf { +pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf { use tauri::Manager; let mut resolved = std::path::PathBuf::from(path); if let Some(stripped) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) { @@ -2759,6 +2759,13 @@ async fn resume_download( return; } log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone); + let _ = app_handle_clone.emit( + "download-state", + crate::ipc::DownloadStateEvent::new( + &id_clone, + crate::ipc::DownloadStatus::Downloading, + ), + ); }); return Ok(true); } @@ -2884,19 +2891,36 @@ async fn remove_download( cleanup_result } -async fn remove_download_assets( +pub(crate) async fn remove_download_assets( primary: &std::path::Path, - app_handle: &tauri::AppHandle, + app_handle: &tauri::AppHandle, ) -> Result<(), String> { if !is_safe_path(primary, app_handle) { return Err("Download asset path is outside an allowed download location".to_string()); } if primary.exists() { - if let Err(e) = trash::delete(primary) { - log::warn!("failed to move downloaded file to Trash, attempting hard delete: {}", e); - if let Err(hard_err) = std::fs::remove_file(primary) { - return Err(format!("failed to move downloaded file to Trash ({e}) and hard delete failed ({hard_err})")); + let mut retries = 5; + loop { + let res = if primary.is_dir() { + tokio::fs::remove_dir_all(primary).await.map_err(|e| e.to_string()) + } else { + if let Err(e) = trash::delete(primary) { + log::warn!("failed to move downloaded file to Trash, attempting hard delete: {}", e); + std::fs::remove_file(primary).map_err(|e| e.to_string()) + } else { + Ok(()) + } + }; + match res { + Ok(_) => break, + Err(e) => { + if retries == 0 { + return Err(e); + } + retries -= 1; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } } } } @@ -2906,9 +2930,19 @@ async fn remove_download_assets( candidate_os.push(suffix); let candidate = std::path::PathBuf::from(candidate_os); if candidate.exists() && is_safe_path(&candidate, app_handle) { - tokio::fs::remove_file(&candidate) - .await - .map_err(|error| format!("failed to remove '{}': {error}", candidate.display()))?; + let mut retries = 5; + loop { + match tokio::fs::remove_file(&candidate).await { + Ok(_) => break, + Err(_) if retries == 0 => { + return Err(format!("failed to remove '{}' after retries", candidate.display())); + } + Err(_) => { + retries -= 1; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } + } } } diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 10c34bb..f2a2a7c 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -483,6 +483,15 @@ impl QueueManager { self.release_registered_id(id).await; } PendingOutcome::Error(error) => { + if error.to_ascii_lowercase().contains("checksum") { + log::warn!("Checksum error detected for {}, cleaning up assets", id); + if let Ok(primary_path) = crate::download_ownership::primary_path_for_id(&self.app_handle, id) { + if let Some(path) = primary_path.as_deref() { + let _ = crate::remove_download_assets(path, &self.app_handle).await; + } + } + } + self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; self.emit_failed(id, error); @@ -868,7 +877,12 @@ impl SidecarSpawner for ProductionSpawner { options.insert("http-passwd".to_string(), serde_json::json!(pass)); } if let Some(chk) = &payload.checksum { - options.insert("checksum".to_string(), serde_json::json!(chk)); + let formatted_chk = if let Some((algo, digest)) = chk.split_once('=') { + format!("{}={}", algo.to_ascii_lowercase(), digest) + } else { + chk.clone() + }; + options.insert("checksum".to_string(), serde_json::json!(formatted_chk)); } if let Some(ua) = &payload.user_agent { options.insert("user-agent".to_string(), serde_json::json!(ua)); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 3342c7c..8b405aa 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -6,7 +6,7 @@ use serde_json::{Map, Value}; use std::collections::HashMap; use tauri::{AppHandle, Manager}; -pub fn load_settings(app_handle: &AppHandle) -> Result { +pub fn load_settings(app_handle: &AppHandle) -> Result { let database = app_handle.state::(); let connection = database.lock()?; let stored = crate::db::load_settings(&connection)? diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index a9ab437..48591c3 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -88,8 +88,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void return; } - let timeoutDuration = toast.duration ?? 5000; - if (timeoutDuration < 5000) timeoutDuration = 5000; + let timeoutDuration = toast.duration ?? 3000; if (isHovered) { return; @@ -136,7 +135,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void return (
void className={`app-toast-item shrink-0 ${isVisible ? 'pointer-events-auto' : 'pointer-events-none'} flex items-start gap-3 rounded-[16px] border px-4 py-3 shadow-[0_8px_30px_rgb(0,0,0,0.12),0_0_20px_var(--tw-shadow-color)] backdrop-blur-xl text-[14px] leading-relaxed transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${style}`} style={{ opacity: isVisible ? 1 : 0, - transform: isVisible ? 'translateY(0) scale(1)' : 'translateY(24px) scale(0.95)', + transform: isVisible ? 'translateY(0) scale(1)' : 'scale(0.95)', transformOrigin: 'bottom center', }} onMouseEnter={() => setIsHovered(true)} diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index da9e5ca..bd3389a 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -58,6 +58,13 @@ export async function initDownloadListener() { const current = mainStore.downloads.find(d => d.id === payload.id); if (current) { const status = payload.status as DownloadStatus; + + // Prevent race condition: don't transition backwards from terminal state + if ((current.status === 'completed' || current.status === 'failed') && + (status !== 'completed' && status !== 'failed')) { + return; + } + const progress = useDownloadProgressStore.getState().progressMap[payload.id]; const updates: Partial = { status,