mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +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)
|
crate::db::remove_ownership(&connection, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn primary_path_for_id(
|
pub fn primary_path_for_id<R: tauri::Runtime>(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<Option<PathBuf>, String> {
|
) -> Result<Option<PathBuf>, String> {
|
||||||
Ok(load_records(app_handle)?
|
Ok(load_records(app_handle)?
|
||||||
@@ -130,7 +130,7 @@ pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>
|
|||||||
Ok(paths)
|
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 database = app_handle.state::<crate::db::DbState>();
|
||||||
let connection = database.lock()?;
|
let connection = database.lock()?;
|
||||||
crate::db::load_ownership(&connection).map(|records| {
|
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()
|
if !path.is_absolute()
|
||||||
|| path.components().any(|component| {
|
|| path.components().any(|component| {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -1322,7 +1322,7 @@ fn canonicalize_with_missing_components(path: &std::path::Path) -> Option<std::p
|
|||||||
Some(canonical)
|
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;
|
use tauri::Manager;
|
||||||
|
|
||||||
let mut roots = Vec::new();
|
let mut roots = Vec::new();
|
||||||
@@ -1511,7 +1511,7 @@ pub struct EngineStatusResult {
|
|||||||
pub engines: Vec<EngineStatusItem>,
|
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;
|
use tauri::Manager;
|
||||||
let mut resolved = std::path::PathBuf::from(path);
|
let mut resolved = std::path::PathBuf::from(path);
|
||||||
if let Some(stripped) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
|
if let Some(stripped) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
|
||||||
@@ -2759,6 +2759,13 @@ async fn resume_download(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone);
|
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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
@@ -2884,19 +2891,36 @@ async fn remove_download(
|
|||||||
cleanup_result
|
cleanup_result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_download_assets(
|
pub(crate) async fn remove_download_assets<R: tauri::Runtime>(
|
||||||
primary: &std::path::Path,
|
primary: &std::path::Path,
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !is_safe_path(primary, app_handle) {
|
if !is_safe_path(primary, app_handle) {
|
||||||
return Err("Download asset path is outside an allowed download location".to_string());
|
return Err("Download asset path is outside an allowed download location".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if primary.exists() {
|
if primary.exists() {
|
||||||
if let Err(e) = trash::delete(primary) {
|
let mut retries = 5;
|
||||||
log::warn!("failed to move downloaded file to Trash, attempting hard delete: {}", e);
|
loop {
|
||||||
if let Err(hard_err) = std::fs::remove_file(primary) {
|
let res = if primary.is_dir() {
|
||||||
return Err(format!("failed to move downloaded file to Trash ({e}) and hard delete failed ({hard_err})"));
|
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);
|
candidate_os.push(suffix);
|
||||||
let candidate = std::path::PathBuf::from(candidate_os);
|
let candidate = std::path::PathBuf::from(candidate_os);
|
||||||
if candidate.exists() && is_safe_path(&candidate, app_handle) {
|
if candidate.exists() && is_safe_path(&candidate, app_handle) {
|
||||||
tokio::fs::remove_file(&candidate)
|
let mut retries = 5;
|
||||||
.await
|
loop {
|
||||||
.map_err(|error| format!("failed to remove '{}': {error}", candidate.display()))?;
|
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;
|
self.release_registered_id(id).await;
|
||||||
}
|
}
|
||||||
PendingOutcome::Error(error) => {
|
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.clear_aria2_retry_state(id).await;
|
||||||
self.forget_aria2_gid(id).await;
|
self.forget_aria2_gid(id).await;
|
||||||
self.emit_failed(id, error);
|
self.emit_failed(id, error);
|
||||||
@@ -868,7 +877,12 @@ impl SidecarSpawner for ProductionSpawner {
|
|||||||
options.insert("http-passwd".to_string(), serde_json::json!(pass));
|
options.insert("http-passwd".to_string(), serde_json::json!(pass));
|
||||||
}
|
}
|
||||||
if let Some(chk) = &payload.checksum {
|
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 {
|
if let Some(ua) = &payload.user_agent {
|
||||||
options.insert("user-agent".to_string(), serde_json::json!(ua));
|
options.insert("user-agent".to_string(), serde_json::json!(ua));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use serde_json::{Map, Value};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::{AppHandle, Manager};
|
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 database = app_handle.state::<crate::db::DbState>();
|
||||||
let connection = database.lock()?;
|
let connection = database.lock()?;
|
||||||
let stored = crate::db::load_settings(&connection)?
|
let stored = crate::db::load_settings(&connection)?
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeoutDuration = toast.duration ?? 5000;
|
let timeoutDuration = toast.duration ?? 3000;
|
||||||
if (timeoutDuration < 5000) timeoutDuration = 5000;
|
|
||||||
|
|
||||||
if (isHovered) {
|
if (isHovered) {
|
||||||
return;
|
return;
|
||||||
@@ -136,7 +135,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
height: isVisible && contentHeight !== undefined ? contentHeight : 0,
|
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}`}
|
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={{
|
style={{
|
||||||
opacity: isVisible ? 1 : 0,
|
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',
|
transformOrigin: 'bottom center',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ export async function initDownloadListener() {
|
|||||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||||
if (current) {
|
if (current) {
|
||||||
const status = payload.status as DownloadStatus;
|
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 progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||||
const updates: Partial<DownloadItem> = {
|
const updates: Partial<DownloadItem> = {
|
||||||
status,
|
status,
|
||||||
|
|||||||
Reference in New Issue
Block a user