fix: resolve download state, toast UI, and checksum cleanup loopholes

- Prevent backward state transitions for downloads in frontend store.
- Add Downloading event emission upon aria2 unpause.
- Fix toast container visual bleed on exit by adding overflow-hidden.
- Add retry backoff for deleted files to prevent Windows file lock errors.
- Lowercase checksum hash types before passing to aria2.
- Clean up lingering files upon checksum verification failure.
- Convert Tauri AppHandle arguments to support generic runtimes.
This commit is contained in:
NimBold
2026-06-27 18:25:36 +03:30
parent a22a26f4ce
commit b2b370161a
6 changed files with 75 additions and 21 deletions
+3 -3
View File
@@ -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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<Option<PathBuf>, String> {
Ok(load_records(app_handle)?
@@ -130,7 +130,7 @@ pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>
Ok(paths)
}
fn load_records(app_handle: &tauri::AppHandle) -> Result<Vec<DownloadOwnershipRecord>, String> {
fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<Vec<DownloadOwnershipRecord>, String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::load_ownership(&connection).map(|records| {
+46 -12
View File
@@ -1286,7 +1286,7 @@ async fn test_deno(app_handle: tauri::AppHandle) -> Result<String, String> {
}
}
pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle) -> bool {
pub(crate) fn is_safe_path<R: tauri::Runtime>(path: &std::path::Path, app_handle: &tauri::AppHandle<R>) -> bool {
if !path.is_absolute()
|| path.components().any(|component| {
matches!(
@@ -1322,7 +1322,7 @@ fn canonicalize_with_missing_components(path: &std::path::Path) -> Option<std::p
Some(canonical)
}
fn approved_download_roots(app_handle: &tauri::AppHandle) -> Vec<std::path::PathBuf> {
fn approved_download_roots<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Vec<std::path::PathBuf> {
use tauri::Manager;
let mut roots = Vec::new();
@@ -1511,7 +1511,7 @@ pub struct EngineStatusResult {
pub engines: Vec<EngineStatusItem>,
}
pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf {
pub(crate) fn resolve_path<R: tauri::Runtime>(path: &str, app_handle: &tauri::AppHandle<R>) -> 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<R: tauri::Runtime>(
primary: &std::path::Path,
app_handle: &tauri::AppHandle,
app_handle: &tauri::AppHandle<R>,
) -> 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;
}
}
}
}
}
+15 -1
View File
@@ -483,6 +483,15 @@ impl<R: tauri::Runtime> QueueManager<R> {
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));
+1 -1
View File
@@ -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<PersistedSettings, String> {
pub fn load_settings<R: tauri::Runtime>(app_handle: &AppHandle<R>) -> Result<PersistedSettings, String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
let stored = crate::db::load_settings(&connection)?
+3 -4
View File
@@ -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 (
<div
className={`w-full transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] flex flex-col justify-end ${isVisible ? 'mb-3' : 'mb-0'}`}
className={`w-full overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] flex flex-col justify-end ${isVisible ? 'mb-3' : 'mb-0'}`}
style={{
height: isVisible && contentHeight !== undefined ? contentHeight : 0,
}}
@@ -153,7 +152,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => 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)}
+7
View File
@@ -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<DownloadItem> = {
status,