mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user