From 7c835b022fea21b67eb38c5b5429fc0d1624b16a Mon Sep 17 00:00:00 2001 From: NimBold Date: Mon, 22 Jun 2026 18:05:04 +0330 Subject: [PATCH] fix: harden scheduler, permissions, and download safety - Implement scheduler hydration barrier to prevent premature triggers - Track scheduler exact runs using keys to avoid false stops - Use 'System Events' for accurate macOS automation permissions - Prevent system-sleep via proper idle assertions - Ensure download pauses use channel acknowledgements (PauseWithAck) - Require Firelink ownership before replacing files in add/conflict UI - Retain partial download assets when removing entries without deletion - Clear progress state in store when downloads complete or pause to reduce churn - Handle empty/invalid queue selections gracefully --- src-tauri/src/ipc.rs | 2 + src-tauri/src/lib.rs | 213 +++++++++++--------- src-tauri/src/scheduler.rs | 74 ++++++- src-tauri/src/settings.rs | 56 ++++- src-tauri/tests/download_engine.rs | 13 +- src/App.tsx | 128 +++++++++--- src/bindings/PersistedSettings.ts | 2 +- src/components/AddDownloadsModal.tsx | 36 ++-- src/components/DuplicateResolutionModal.tsx | 3 +- src/components/PropertiesModal.tsx | 23 ++- src/components/SchedulerView.tsx | 35 ++-- src/ipc.ts | 2 +- src/store/downloadStore.test.ts | 23 +++ src/store/downloadStore.ts | 26 ++- src/store/useDownloadStore.test.ts | 48 +++++ src/store/useDownloadStore.ts | 23 ++- src/store/useSettingsStore.ts | 3 +- 17 files changed, 529 insertions(+), 181 deletions(-) create mode 100644 src/store/downloadStore.test.ts diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index e0d462a..1899eee 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -251,6 +251,8 @@ pub struct PersistedSettings { pub is_sidebar_visible: bool, pub active_settings_tab: SettingsTab, pub scheduler: SchedulerSettings, + pub scheduler_running: bool, + pub scheduler_active_download_ids: Vec, pub scheduler_last_start_key: String, pub scheduler_last_stop_key: String, pub last_custom_speed_limit_ki_b: u32, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c62fc47..fe1ef48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -668,10 +668,6 @@ async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) { cleanup_media_artifacts(out_path, true).await; } -async fn cleanup_media_sidecars(out_path: &std::path::Path) { - cleanup_media_artifacts(out_path, false).await; -} - async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: bool) { let Some(parent) = out_path.parent() else { return; @@ -1126,12 +1122,34 @@ async fn test_deno(app_handle: tauri::AppHandle) -> Result { } pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle) -> bool { - if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if !path.is_absolute() + || path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + { return false; } - if !path.is_absolute() { - return true; + let mut existing = path; + let mut missing = Vec::new(); + while !existing.exists() { + let Some(name) = existing.file_name() else { + return false; + }; + missing.push(name.to_owned()); + let Some(parent) = existing.parent() else { + return false; + }; + existing = parent; + } + let Ok(mut canonical_path) = std::fs::canonicalize(existing) else { + return false; + }; + for component in missing.iter().rev() { + canonical_path.push(component); } let mut allowed_prefixes = Vec::new(); @@ -1147,7 +1165,8 @@ pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle allowed_prefixes.push(std::path::PathBuf::from("/Volumes")); for prefix in allowed_prefixes { - if path.starts_with(&prefix) { + let canonical_prefix = std::fs::canonicalize(&prefix).unwrap_or(prefix); + if canonical_path.starts_with(&canonical_prefix) { return true; } } @@ -1155,32 +1174,6 @@ pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle false } -#[tauri::command] -async fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> { - use tauri_plugin_opener::OpenerExt; - - let resolved_dest = resolve_path(&path, &app); - - if !is_safe_path(&resolved_dest, &app) { - return Err("Path traversal blocked".to_string()); - } - - app.opener().open_path(resolved_dest.to_string_lossy().as_ref(), None::).map_err(|e| format!("Failed to open file: {}", e)) -} - -#[tauri::command] -async fn show_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> { - use tauri_plugin_opener::OpenerExt; - - let resolved_dest = resolve_path(&path, &app); - - if !is_safe_path(&resolved_dest, &app) { - return Err("Path traversal blocked".to_string()); - } - - app.opener().reveal_item_in_dir(resolved_dest.to_string_lossy().as_ref()).map_err(|e| format!("Failed to reveal in folder: {}", e)) -} - use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -1242,6 +1235,7 @@ pub struct AppState { pub aria2_secret: String, pub media_semaphore: Arc, pub sleep_preventer: Arc>>, + pub scheduler_settings: Arc>>, pub queue_manager: Arc, } @@ -2362,23 +2356,32 @@ async fn pause_download( return Ok(()); } - use tauri::Emitter; - let _ = app_handle.emit( - "download-state", - crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), - ); - - if let Ok(download_id) = Uuid::parse_str(&id) { - let _ = state + let (tx, rx) = tokio::sync::oneshot::channel(); + if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { + state .download_coordinator - .send(download::DownloadCmd::Pause(download_id)) - .await; + .pause_media_with_ack(id.clone(), tx) + .await?; + } else if let Ok(download_id) = Uuid::parse_str(&id) { + state + .download_coordinator + .send(download::DownloadCmd::PauseWithAck(download_id, tx)) + .await?; + } else { + let _ = tx.send(()); } - let media_result = state.download_coordinator.pause_media(id.clone()).await; + rx.await + .map_err(|_| "download worker stopped without acknowledging pause".to_string())?; + if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) { state.queue_manager.release_permit(&id).await; } - media_result + use tauri::Emitter; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused), + ); + Ok(()) } #[tauri::command] @@ -2498,10 +2501,12 @@ async fn remove_download( if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { state.download_coordinator.pause_media_with_ack(id.clone(), tx).await?; } else if let Ok(download_id) = Uuid::parse_str(&id) { - state - .download_coordinator - .send(download::DownloadCmd::CancelWithAck(download_id, tx)) - .await?; + let command = if delete_assets { + download::DownloadCmd::CancelWithAck(download_id, tx) + } else { + download::DownloadCmd::PauseWithAck(download_id, tx) + }; + state.download_coordinator.send(command).await?; } else { let _ = tx.send(()); } @@ -2524,8 +2529,6 @@ async fn remove_download( if let Some(path) = primary_path.as_deref() { remove_download_assets(path, &app_handle).await?; } - } else if let Some(path) = primary_path.as_deref() { - remove_partial_download_assets(path, &app_handle).await?; } crate::download_ownership::remove(&app_handle, &id)?; @@ -2559,25 +2562,6 @@ async fn remove_download_assets( Ok(()) } -async fn remove_partial_download_assets( - primary: &std::path::Path, - 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()); - } - for suffix in [".aria2", ".part", ".ytdl"] { - let candidate = std::path::PathBuf::from(format!("{}{}", primary.display(), suffix)); - 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()))?; - } - } - cleanup_media_sidecars(primary).await; - Ok(()) -} - #[tauri::command] async fn detach_download_for_reconfigure( app_handle: tauri::AppHandle, @@ -2741,17 +2725,21 @@ fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) { } #[tauri::command] -fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) { +fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> { let mut current_preventer = state.sleep_preventer.lock().unwrap_or_else(|e| e.into_inner()); if prevent { if current_preventer.is_none() { - if let Ok(keepawake) = keepawake::Builder::default().display(true).reason("Downloading files").create() { - *current_preventer = Some(keepawake); - } + let keepawake = keepawake::Builder::default() + .idle(true) + .reason("Firelink active download") + .create() + .map_err(|error| format!("failed to prevent system sleep: {error}"))?; + *current_preventer = Some(keepawake); } } else { *current_preventer = None; } + Ok(()) } pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> { @@ -2777,6 +2765,7 @@ fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), Stri #[tauri::command] fn ack_schedule_trigger( app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, action: String, key: String, ) -> Result<(), String> { @@ -2790,7 +2779,18 @@ fn ack_schedule_trigger( _ => {} })?; match action.as_str() { - "start" | "stop" => Ok(()), + "start" | "stop" => { + if let Ok(mut cached) = state.scheduler_settings.write() { + if let Some(settings) = cached.as_mut() { + if action == "start" { + settings.scheduler_last_start_key = key; + } else { + settings.scheduler_last_stop_key = key; + } + } + } + Ok(()) + } _ => Err("Unknown scheduler trigger action".to_string()), } } @@ -2931,16 +2931,17 @@ async fn set_global_speed_limit(state: tauri::State<'_, AppState>, limit: Option } #[tauri::command] -fn request_automation_permission() -> Result<(), String> { +fn check_automation_permission() -> Result<(), String> { #[cfg(target_os = "macos")] { + use cocoa::base::{id, nil}; use cocoa::foundation::NSString; - use cocoa::base::{nil, id}; - use objc::{msg_send, sel, sel_impl, class}; + use objc::{class, msg_send, sel, sel_impl}; unsafe { objc::rc::autoreleasepool(|| { - let script_str = NSString::alloc(nil).init_str("tell application \"Finder\" to get name"); + let script_str = + NSString::alloc(nil).init_str("tell application \"System Events\" to get name"); let ns_apple_script: id = msg_send![class!(NSAppleScript), alloc]; let ns_apple_script: id = msg_send![ns_apple_script, initWithSource: script_str]; let mut error_dict: id = nil; @@ -2957,6 +2958,18 @@ fn request_automation_permission() -> Result<(), String> { Ok(()) } +#[tauri::command] +fn request_automation_permission() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + system_shutdown::request_permission_dialog() + .map_err(|error| format!("Automation permission was not granted: {error}")) + } + + #[cfg(not(target_os = "macos"))] + Ok(()) +} + #[tauri::command] fn open_automation_settings(app_handle: tauri::AppHandle) -> Result<(), String> { #[cfg(target_os = "macos")] @@ -3049,9 +3062,20 @@ fn acknowledge_pairing_token_change( } #[tauri::command] -fn db_save_settings(state: tauri::State<'_, crate::db::DbState>, data: String) -> Result<(), String> { +fn db_save_settings( + state: tauri::State<'_, crate::db::DbState>, + app_state: tauri::State<'_, AppState>, + data: String, +) -> Result<(), String> { let connection = state.lock()?; - crate::db::save_settings(&connection, &data) + let existing = crate::db::load_settings(&connection)?; + let merged = crate::settings::preserve_scheduler_runtime_keys(existing.as_deref(), &data)?; + crate::db::save_settings(&connection, &merged)?; + let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?; + if let Ok(mut cached) = app_state.scheduler_settings.write() { + *cached = Some(decoded); + } + Ok(()) } #[tauri::command] @@ -3105,19 +3129,6 @@ fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool { resolved_dest.exists() } -#[tauri::command] -fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> { - let resolved_dest = resolve_path(&path, &app_handle); - if !is_safe_path(&resolved_dest, &app_handle) { - return Err("Path traversal blocked".to_string()); - } - if resolved_dest.exists() { - std::fs::remove_file(resolved_dest).map_err(|e| e.to_string()) - } else { - Ok(()) - } -} - async fn log_files(app_handle: &tauri::AppHandle) -> Result, String> { use tauri::Manager; let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?; @@ -3757,6 +3768,9 @@ pub fn run() { .map(|settings| settings.max_concurrent_downloads) .unwrap_or(crate::queue::DEFAULT_MAX_CONCURRENT) }; + let scheduler_settings = Arc::new(RwLock::new( + crate::settings::load_settings(app.handle()).ok(), + )); let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent)); let dispatcher_mgr = Arc::clone(&queue_manager); @@ -3777,6 +3791,7 @@ pub fn run() { aria2_secret: aria2_secret.clone(), media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), sleep_preventer: Arc::new(Mutex::new(None)), + scheduler_settings: Arc::clone(&scheduler_settings), queue_manager, }); @@ -3822,7 +3837,7 @@ pub fn run() { Ok(None) => {} Err(error) => eprintln!("Failed to read startup deep link: {error}"), } - crate::scheduler::spawn_scheduler(app.handle().clone()); + crate::scheduler::spawn_scheduler(app.handle().clone(), scheduler_settings); let global_speed_limit = crate::settings::load_settings(app.handle()) .map(|settings| settings.global_speed_limit) @@ -4061,14 +4076,14 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status, - get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder, + get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, pause_download, resume_download, fetch_metadata, fetch_media_metadata, update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action, ack_schedule_trigger, - request_automation_permission, open_automation_settings, + check_automation_permission, request_automation_permission, open_automation_settings, set_keychain_password, get_keychain_password, delete_keychain_password, hydrate_extension_pairing_token, acknowledge_pairing_token_change, - check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token, + check_file_exists, toggle_tray_icon, set_extension_pairing_token, get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download, detach_download_for_reconfigure, enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order, diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs index 013be92..69d8171 100644 --- a/src-tauri/src/scheduler.rs +++ b/src-tauri/src/scheduler.rs @@ -1,5 +1,6 @@ use chrono::{Datelike, Local, Timelike}; use std::collections::HashMap; +use std::sync::{Arc, RwLock}; use std::time::Duration; use tauri::Emitter; @@ -10,15 +11,44 @@ fn minute_of_day(value: &str) -> Option { (hour < 24 && minute < 60).then_some(hour * 60 + minute) } -pub fn spawn_scheduler(app_handle: tauri::AppHandle) { +fn stop_is_due( + stop_time_enabled: bool, + stop_minute: Option, + current_minute: u32, + last_start_key: &str, + start_key: &str, + last_stop_key: &str, + stop_key: &str, +) -> bool { + stop_time_enabled + && stop_minute.is_some_and(|stop| current_minute >= stop) + && last_start_key == start_key + && last_stop_key != stop_key +} + +pub fn spawn_scheduler( + app_handle: tauri::AppHandle, + settings_cache: Arc>>, +) { tauri::async_runtime::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(1)); let mut last_emit: HashMap<&'static str, std::time::Instant> = HashMap::new(); loop { interval.tick().await; - if let Ok(settings) = crate::settings::load_settings(&app_handle) { - let scheduler = settings.scheduler; + let settings = settings_cache + .read() + .ok() + .and_then(|settings| { + settings.as_ref().map(|settings| { + ( + settings.scheduler.clone(), + settings.scheduler_last_start_key.clone(), + settings.scheduler_last_stop_key.clone(), + ) + }) + }); + if let Some((scheduler, scheduler_last_start_key, scheduler_last_stop_key)) = settings { if !scheduler.enabled { continue; } @@ -43,7 +73,7 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) { if start_minute.is_some_and(|start| current_minute >= start) && before_stop - && settings.scheduler_last_start_key != start_key + && scheduler_last_start_key != start_key && last_emit .get("start") .is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5)) @@ -55,9 +85,15 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) { last_emit.insert("start", std::time::Instant::now()); } - if scheduler.stop_time_enabled - && stop_minute.is_some_and(|stop| current_minute >= stop) - && settings.scheduler_last_stop_key != stop_key + if stop_is_due( + scheduler.stop_time_enabled, + stop_minute, + current_minute, + &scheduler_last_start_key, + &start_key, + &scheduler_last_stop_key, + &stop_key, + ) && last_emit .get("stop") .is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5)) @@ -75,7 +111,7 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) { #[cfg(test)] mod tests { - use super::minute_of_day; + use super::{minute_of_day, stop_is_due}; #[test] fn parses_valid_scheduler_times() { @@ -90,4 +126,26 @@ mod tests { assert_eq!(minute_of_day("12:60"), None); assert_eq!(minute_of_day("bad"), None); } + + #[test] + fn stop_requires_same_day_acknowledged_start() { + assert!(!stop_is_due( + true, + Some(480), + 600, + "", + "2026-06-22-start", + "", + "2026-06-22-stop", + )); + assert!(stop_is_due( + true, + Some(480), + 600, + "2026-06-22-start", + "2026-06-22-start", + "", + "2026-06-22-stop", + )); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index acf856a..8c4b13f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -43,6 +43,26 @@ pub fn update_settings_state( crate::db::save_settings(&connection, &stored) } +pub fn preserve_scheduler_runtime_keys( + existing: Option<&str>, + incoming: &str, +) -> Result { + let Some(existing) = existing else { + return Ok(incoming.to_string()); + }; + let existing_document = decode_document(&Value::String(existing.to_string()))?; + let existing_state = settings_state(&existing_document)?; + let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?; + let incoming_state = settings_state_mut(&mut incoming_document)?; + for key in ["schedulerLastStartKey", "schedulerLastStopKey"] { + if let Some(value) = existing_state.get(key) { + incoming_state.insert(key.to_string(), value.clone()); + } + } + serde_json::to_string(&incoming_document) + .map_err(|error| format!("failed to encode persisted settings: {error}")) +} + fn decode_document(stored: &Value) -> Result { match stored { Value::String(text) => serde_json::from_str(text) @@ -246,6 +266,8 @@ fn default_settings() -> PersistedSettings { selected_queue_ids: vec!["00000000-0000-0000-0000-000000000001".to_string()], post_queue_action: PostQueueAction::None, }, + scheduler_running: false, + scheduler_active_download_ids: Vec::new(), scheduler_last_start_key: String::new(), scheduler_last_stop_key: String::new(), last_custom_speed_limit_ki_b: 1024, @@ -271,9 +293,41 @@ fn default_settings() -> PersistedSettings { #[cfg(test)] mod tests { - use super::decode_stored_settings; + use super::{decode_stored_settings, preserve_scheduler_runtime_keys}; use serde_json::{json, Value}; + #[test] + fn frontend_settings_save_preserves_backend_scheduler_keys() { + let existing = json!({ + "state": { + "schedulerLastStartKey": "2026-06-22-start", + "schedulerLastStopKey": "2026-06-22-stop" + }, + "version": 3 + }) + .to_string(); + let incoming = json!({ + "state": { + "schedulerLastStartKey": "", + "schedulerLastStopKey": "", + "theme": "system" + }, + "version": 3 + }) + .to_string(); + + let merged = preserve_scheduler_runtime_keys(Some(&existing), &incoming).unwrap(); + let merged: Value = serde_json::from_str(&merged).unwrap(); + assert_eq!( + merged["state"]["schedulerLastStartKey"], + "2026-06-22-start" + ); + assert_eq!( + merged["state"]["schedulerLastStopKey"], + "2026-06-22-stop" + ); + } + #[test] fn decodes_zustand_envelope_and_preserves_non_default_startup_settings() { let stored = json!({ diff --git a/src-tauri/tests/download_engine.rs b/src-tauri/tests/download_engine.rs index 15a6a0f..c3e5eb0 100644 --- a/src-tauri/tests/download_engine.rs +++ b/src-tauri/tests/download_engine.rs @@ -21,7 +21,7 @@ use std::{ }; use firelink_lib::download::{DownloadCmd, DownloadCoordinator, DownloadEvent, DownloadPayload}; use tempfile::TempDir; -use tokio::{net::TcpListener, sync::mpsc, task::JoinHandle}; +use tokio::{net::TcpListener, sync::{mpsc, oneshot}, task::JoinHandle}; use uuid::Uuid; const TEST_TIMEOUT: Duration = Duration::from_secs(10); @@ -288,12 +288,19 @@ async fn pause_then_resume_uses_range_and_preserves_integrity() { .await .unwrap(); wait_for_progress(&mut events, id, 256 * 1024).await; - coordinator.send(DownloadCmd::Pause(id)).await.unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; + let (pause_tx, pause_rx) = oneshot::channel(); + coordinator + .send(DownloadCmd::PauseWithAck(id, pause_tx)) + .await + .unwrap(); + pause_rx.await.unwrap(); let paused_len = tokio::fs::metadata(&output_path).await.unwrap().len(); + tokio::time::sleep(Duration::from_millis(100)).await; + let stable_paused_len = tokio::fs::metadata(&output_path).await.unwrap().len(); assert!(paused_len >= 256 * 1024); assert!(paused_len < expected.len() as u64); + assert_eq!(stable_paused_len, paused_len); coordinator .send(DownloadCmd::Start(Box::new(payload( diff --git a/src/App.tsx b/src/App.tsx index b47095d..f16d810 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,16 +21,27 @@ import { openUrl } from '@tauri-apps/plugin-opener'; let automaticUpdateCheckStarted = false; const processingScheduleKeys = new Set(); +const waitForSettingsHydration = (): Promise => { + if (useSettingsStore.persist.hasHydrated()) return Promise.resolve(); + return new Promise(resolve => { + const unsubscribe = useSettingsStore.persist.onFinishHydration(() => { + unsubscribe(); + resolve(); + }); + }); +}; + const getScheduledQueueIds = () => { const downloadState = useDownloadStore.getState(); const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id)); const selectedQueueIds = useSettingsStore.getState().scheduler.selectedQueueIds .filter(queueId => availableQueueIds.has(queueId)); - return selectedQueueIds.length > 0 ? selectedQueueIds : [MAIN_QUEUE_ID]; + return selectedQueueIds; }; function App() { const [filter, setFilter] = useState('all'); + const [coreReady, setCoreReady] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const stored = Number(window.localStorage.getItem('firelink-sidebar-width')); @@ -58,6 +69,12 @@ function App() { const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit); const previousSpeedLimit = useRef(null); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); + const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); + const activeTransferCount = downloads.filter(download => + download.status === 'downloading' || + download.status === 'processing' || + download.status === 'retrying' + ).length; const acknowledgePairingTokenChange = () => { invoke('acknowledge_pairing_token_change').catch(error => { @@ -94,9 +111,24 @@ function App() { const { addToast } = useToast(); useEffect(() => { - useDownloadStore.getState().initDB(); - useSettingsStore.getState().hydratePairingToken() - .then(changed => { + let active = true; + const initialize = async () => { + try { + await waitForSettingsHydration(); + await useDownloadStore.getState().initDB(); + if (active) setCoreReady(true); + } catch (error) { + console.error('Failed to initialize Firelink state:', error); + addToast({ + message: `Could not initialize saved downloads: ${String(error)}`, + variant: 'error', + isActionable: true + }); + return; + } + + try { + const changed = await useSettingsStore.getState().hydratePairingToken(); if (changed) { addToast({ variant: 'warning', @@ -143,10 +175,14 @@ function App() { ) }); } - }) - .catch(error => { + } catch (error) { console.error('Failed to hydrate extension pairing token:', error); - }); + } + }; + void initialize(); + return () => { + active = false; + }; }, [addToast]); useEffect(() => { @@ -205,6 +241,19 @@ function App() { invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {}); }, [showDockBadge, activeDownloadCount]); + useEffect(() => { + invoke('set_prevent_sleep', { + prevent: preventsSleepWhileDownloading && activeTransferCount > 0 + }).catch(error => { + console.error('Failed to update sleep prevention:', error); + addToast({ + message: `Could not update sleep prevention: ${String(error)}`, + variant: 'error', + isActionable: true + }); + }); + }, [addToast, preventsSleepWhileDownloading, activeTransferCount]); + useEffect(() => { invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error); }, [showMenuBarIcon]); @@ -228,6 +277,7 @@ function App() { }, [globalSpeedLimit]); useEffect(() => { + if (!coreReady) return; const unlisten = listen('schedule-trigger', async (event) => { const state = useSettingsStore.getState(); const payload = event.payload; @@ -235,17 +285,48 @@ function App() { processingScheduleKeys.add(payload.key); try { if (payload.action === 'start') { + const scheduledQueueIds = getScheduledQueueIds(); + if (scheduledQueueIds.length === 0) { + state.setSchedulerActiveDownloadIds([]); + state.setSchedulerRunning(false); + addToast({ + message: 'Scheduler has no valid queues selected. Update Scheduler settings.', + variant: 'warning', + isActionable: true + }); + await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); + return; + } const startedResults = await Promise.all( - getScheduledQueueIds().map(queueId => useDownloadStore.getState().startQueue(queueId)) + scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId)) ); const acceptedIds = startedResults.flat(); - state.setSchedulerActiveDownloadIds(acceptedIds); - state.setSchedulerRunning(acceptedIds.length > 0); + const scheduledQueueSet = new Set(scheduledQueueIds); + const trackedIds = useDownloadStore.getState().downloads + .filter(download => + scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) && + isActiveDownloadStatus(download.status) + ) + .map(download => download.id); + const activeIds = [...new Set([...acceptedIds, ...trackedIds])]; + state.setSchedulerActiveDownloadIds(activeIds); + state.setSchedulerRunning(activeIds.length > 0); await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); } else if (payload.action === 'stop') { - await Promise.all( - getScheduledQueueIds().map(queueId => useDownloadStore.getState().pauseQueue(queueId)) - ); + const trackedIds = state.schedulerActiveDownloadIds; + if (trackedIds.length > 0) { + const pauseResults = await Promise.allSettled( + trackedIds.map(id => invoke('pause_download', { id })) + ); + const failedPauses = pauseResults.filter(result => result.status === 'rejected').length; + if (failedPauses > 0) { + addToast({ + message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`, + variant: 'error', + isActionable: true + }); + } + } state.setSchedulerActiveDownloadIds([]); state.setSchedulerRunning(false); await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key }); @@ -258,33 +339,34 @@ function App() { return () => { unlisten.then(f => f()).catch(console.error); }; - }, []); + }, [addToast, coreReady]); useEffect(() => { if (!schedulerRunning) return; if (schedulerActiveDownloadIds.length === 0) return; - const scheduledIds = new Set(schedulerActiveDownloadIds); - const hasPendingScheduledWork = downloads.some(download => - scheduledIds.has(download.id) && isActiveDownloadStatus(download.status) - ); - if (hasPendingScheduledWork) return; - const settings = useSettingsStore.getState(); const scheduledItems = schedulerActiveDownloadIds.map(id => downloads.find(download => download.id === id) ); - const hasFailures = scheduledItems.some(item => !item || item.status === 'failed'); + if (scheduledItems.some(item => item && isActiveDownloadStatus(item.status))) return; + + const allCompleted = scheduledItems.every(item => item?.status === 'completed'); settings.setSchedulerActiveDownloadIds([]); settings.setSchedulerRunning(false); - if (hasFailures) { + if (!allCompleted) { addToast({ - message: 'Scheduled downloads finished with failures. The post-queue system action was skipped.', + message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.', variant: 'warning', isActionable: true }); } else if (settings.scheduler.postQueueAction !== 'none') { invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => { console.error('Scheduled post action failed:', error); + addToast({ + message: `Scheduled system action failed: ${String(error)}`, + variant: 'error', + isActionable: true + }); }); } }, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]); diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index a848053..85439e3 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab"; import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; -export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, }; +export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 828edd1..8b23dfd 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -414,9 +414,20 @@ export const AddDownloadsModal = () => { }); } catch (e) {} - if (fileExistsInStore || fileExistsOnDisk) { - newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: 'File exists at destination' }, resolution: 'rename' }); - } + if (fileExistsInStore || fileExistsOnDisk) { + newConflicts.push({ + id: i.toString(), + fileName: finalFile, + reason: { + type: 'file', + msg: fileExistsInStore + ? 'Existing Firelink download uses this destination' + : 'File exists on disk; rename or skip to avoid deleting unrelated data' + }, + resolution: 'rename', + replaceAllowed: fileExistsInStore + }); + } } } @@ -503,7 +514,7 @@ export const AddDownloadsModal = () => { itemsToAdd[idx] = { ...item, file: newName }; } else if (res.resolution === 'replace') { - if (conflict?.reason.type !== 'file') { + if (conflict?.reason.type !== 'file' || !conflict.replaceAllowed) { itemsToAdd[idx] = null; continue; } @@ -516,8 +527,6 @@ export const AddDownloadsModal = () => { const itemLocation = useSharedDestination ? finalLocation : destinationOverrides[idx] || await categoryLocationForFile(finalFile); - const fullPath = await resolveDownloadFilePath(itemLocation, finalFile); - const store = useDownloadStore.getState(); let existingItem; const currentSettings = useSettingsStore.getState(); @@ -525,8 +534,8 @@ export const AddDownloadsModal = () => { const destination = download.destination || await resolveCategoryDestination(currentSettings, download.category); if ( - (download.url === item.downloadUrl || - (destination === itemLocation && download.fileName === finalFile)) && + destination === itemLocation && + download.fileName === finalFile && download.status !== 'failed' ) { existingItem = download; @@ -538,10 +547,10 @@ export const AddDownloadsModal = () => { throw new Error(`Pause ${existingItem.fileName} before replacing it.`); } - await invoke('delete_file', { path: fullPath }); - if (existingItem) { - await store.removeDownload(existingItem.id); + if (!existingItem) { + throw new Error(`Cannot replace ${finalFile}: file is not owned by a Firelink download.`); } + await store.removeDownload(existingItem.id, true); } } } @@ -565,7 +574,7 @@ export const AddDownloadsModal = () => { } const category = categoryForFileName(finalFile); - await addDownload({ + const added = await addDownload({ id, url: item.downloadUrl, fileName: finalFile, @@ -588,6 +597,9 @@ export const AddDownloadsModal = () => { mediaFormatSelector: formatSelector, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined) }, action); + if (!added) { + throw new Error('Backend rejected download start.'); + } addedCount += 1; } catch (e) { console.error("Invalid URL or failed to add:", e); diff --git a/src/components/DuplicateResolutionModal.tsx b/src/components/DuplicateResolutionModal.tsx index 96e2859..aeb7d26 100644 --- a/src/components/DuplicateResolutionModal.tsx +++ b/src/components/DuplicateResolutionModal.tsx @@ -8,6 +8,7 @@ export interface DuplicateConflict { fileName: string; reason: DuplicateReason; resolution: DuplicateResolution; + replaceAllowed?: boolean; } interface Props { @@ -44,7 +45,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir className="app-control w-24 shrink-0 px-2 py-1 text-xs" > - {conflict.reason.type === 'file' && } + {conflict.reason.type === 'file' && conflict.replaceAllowed && } diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index bdbe324..686211e 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { useDownloadStore, DownloadItem } from '../store/useDownloadStore'; +import { useDownloadProgressStore } from '../store/downloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; @@ -19,6 +20,11 @@ export const PropertiesModal = () => { ? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null : null ); + const liveProgress = useDownloadProgressStore(state => + selectedPropertiesDownloadId + ? state.progressMap[selectedPropertiesDownloadId] + : undefined + ); const { baseDownloadFolder, perServerConnections } = useSettingsStore(); @@ -152,6 +158,15 @@ export const PropertiesModal = () => { const identityLocked = getIdentityLocked(item.status); const transferLocked = getTransferLocked(item.status); + const displayedFraction = item.status === 'completed' + ? 1 + : liveProgress?.fraction ?? item.fraction ?? 0; + const displayedSpeed = item.status === 'completed' + ? '-' + : liveProgress?.speed ?? item.speed ?? '-'; + const displayedEta = item.status === 'completed' + ? '-' + : liveProgress?.eta ?? item.eta ?? '-'; let statusColor = 'text-text-secondary'; let StatusIcon = Info; @@ -176,14 +191,14 @@ export const PropertiesModal = () => {
-
+
-
Progress{item.status === 'completed' ? '100%' : ((item.fraction || 0) * 100).toFixed(0) + '%'}
+
Progress{`${(displayedFraction * 100).toFixed(0)}%`}
Size{item.size || '-'}
-
Speed{item.status === 'completed' ? '-' : item.speed || '-'}
-
ETA{item.status === 'completed' ? '-' : item.eta || '-'}
+
Speed{displayedSpeed}
+
ETA{displayedEta}
Connections{item.connections || perServerConnections || '-'}
Speed cap{item.speedLimit || '-'}
diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index 304a4fd..0be6d48 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -5,7 +5,7 @@ import { Pause, Play, Power, RotateCcw, Save } from 'lucide-react'; import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore'; -import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore'; +import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore'; import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; @@ -89,9 +89,7 @@ export default function SchedulerView() { const availableQueueIds = new Set(queues.map(queue => queue.id)); const selectedQueueIds = draft.selectedQueueIds.filter(queueId => availableQueueIds.has(queueId)); - const effectiveSelectedQueueIds = selectedQueueIds.length > 0 - ? selectedQueueIds - : [MAIN_QUEUE_ID]; + const effectiveSelectedQueueIds = selectedQueueIds; const toggleQueue = (queueId: string) => { setDraft(current => { @@ -114,6 +112,10 @@ export default function SchedulerView() { addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true }); return; } + if (effectiveSelectedQueueIds.length === 0) { + addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true }); + return; + } if (draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) { addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true }); return; @@ -132,12 +134,19 @@ export default function SchedulerView() { const results = await Promise.all( effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId)) ); - const count = results.reduce((total, ids) => total + ids.length, 0); const acceptedIds = results.flat(); - if (count > 0) { + const selectedQueueSet = new Set(effectiveSelectedQueueIds); + const trackedIds = useDownloadStore.getState().downloads + .filter(download => + selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) && + ['queued', 'downloading', 'processing', 'retrying'].includes(download.status) + ) + .map(download => download.id); + const activeIds = [...new Set([...acceptedIds, ...trackedIds])]; + if (activeIds.length > 0) { useSettingsStore.getState().setSchedulerRunning(true); - useSettingsStore.getState().setSchedulerActiveDownloadIds(acceptedIds); - addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' }); + useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds); + addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' }); } else { addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' }); } @@ -157,7 +166,7 @@ export default function SchedulerView() { if (!isMac) return; try { - await invoke('request_automation_permission'); + await invoke('check_automation_permission'); setAutomationPermissionGranted(true); if (showMessage) { setPermissionMessage('Automation permission is available.'); @@ -165,7 +174,7 @@ export default function SchedulerView() { } catch { setAutomationPermissionGranted(false); if (showMessage) { - setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for Finder in System Settings.'); + setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.'); } } }, [isMac]); @@ -204,7 +213,7 @@ export default function SchedulerView() { const handlePermissionAction = async () => { if (automationPermissionGranted) { - await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke it in System Settings, then return to Firelink.'); + await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.'); return; } @@ -215,7 +224,7 @@ export default function SchedulerView() { setPermissionMessage('Automation permission is available.'); } catch { setAutomationPermissionGranted(false); - await openAutomationSettings('Enable Firelink under Automation for Finder in System Settings, then return to Firelink.'); + await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.'); } }; @@ -361,7 +370,7 @@ export default function SchedulerView() {
System Permissions
-

Sleep, restart, and shut down require macOS Automation permission for Finder.

+

Sleep, restart, and shut down require macOS Automation permission for System Events.

{automationPermissionGranted ? ( <> diff --git a/src/ipc.ts b/src/ipc.ts index 662f991..2a6fbdb 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -40,13 +40,13 @@ type CommandMap = { set_concurrent_limit: { args: { limit: number }; result: void }; set_global_speed_limit: { args: { limit: string | null }; result: void }; request_automation_permission: { args: undefined; result: void }; + check_automation_permission: { args: undefined; result: void }; open_automation_settings: { args: undefined; result: void }; get_free_space: { args: { path: string }; result: string }; set_keychain_password: { args: { id: string; password: string }; result: void }; get_keychain_password: { args: { id: string }; result: string }; delete_keychain_password: { args: { id: string }; result: void }; check_file_exists: { args: { path: string }; result: boolean }; - delete_file: { args: { path: string }; result: void }; toggle_tray_icon: { args: { show: boolean }; result: void }; set_extension_pairing_token: { args: { token: string }; result: void }; get_extension_server_port: { args: undefined; result: number | null }; diff --git a/src/store/downloadStore.test.ts b/src/store/downloadStore.test.ts new file mode 100644 index 0000000..88b6bf8 --- /dev/null +++ b/src/store/downloadStore.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useDownloadProgressStore } from './downloadStore'; + +describe('useDownloadProgressStore', () => { + beforeEach(() => { + useDownloadProgressStore.setState({ progressMap: {} }); + }); + + it('prunes terminal progress entries', () => { + useDownloadProgressStore.getState().updateDownloadProgress('download-1', { + id: 'download-1', + fraction: 0.5, + speed: '1 MB/s', + eta: '10s', + size: '2 MB', + size_is_final: false + }); + + useDownloadProgressStore.getState().clearDownloadProgress('download-1'); + + expect(useDownloadProgressStore.getState().progressMap).toEqual({}); + }); +}); diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 28ce82b..731681a 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -7,6 +7,7 @@ import { listenEvent as listen } from '../ipc'; interface DownloadProgressState { progressMap: Record; updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void; + clearDownloadProgress: (id: string) => void; } import { useDownloadStore } from './useDownloadStore'; @@ -20,6 +21,13 @@ export const useDownloadProgressStore = create((set) => ( [id]: payload, }, })), + clearDownloadProgress: (id) => + set((state) => { + if (!(id in state.progressMap)) return state; + const next = { ...state.progressMap }; + delete next[id]; + return { progressMap: next }; + }), })); let unlistenProgress: UnlistenFn | null = null; @@ -36,12 +44,9 @@ export async function initDownloadListener() { const current = mainStore.downloads.find(d => d.id === payload.id); if (current) { const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final)); - mainStore.updateDownload(payload.id, { - fraction: payload.fraction, - speed: payload.speed, - eta: payload.eta, - ...(shouldUpdateSize ? { size: payload.size! } : {}), - }); + if (shouldUpdateSize && current.size !== payload.size) { + mainStore.updateDownload(payload.id, { size: payload.size! }); + } } }); @@ -52,7 +57,11 @@ export async function initDownloadListener() { const current = mainStore.downloads.find(d => d.id === payload.id); if (current) { const status = payload.status as DownloadStatus; - const updates: Partial = { status }; + const progress = useDownloadProgressStore.getState().progressMap[payload.id]; + const updates: Partial = { + status, + ...(progress ? { fraction: progress.fraction } : {}) + }; if (status !== 'downloading') { updates.speed = '-'; updates.eta = '-'; @@ -72,6 +81,9 @@ export async function initDownloadListener() { } else if (status === 'completed' || status === 'failed') { mainStore.unregisterBackendIds([payload.id]); } + if (status === 'completed' || status === 'failed' || status === 'paused') { + useDownloadProgressStore.getState().clearDownloadProgress(payload.id); + } } }); } diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index e079fa4..d5e0fa0 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useDownloadStore } from './useDownloadStore'; +import { useSettingsStore } from './useSettingsStore'; import * as ipc from '../ipc'; vi.mock('../ipc', () => ({ @@ -172,6 +173,26 @@ describe('useDownloadStore', () => { ); }); + it('reports a rejected immediate start instead of claiming success', async () => { + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { + if (command === 'enqueue_download') { + throw new Error('backend unavailable'); + } + return undefined; + }); + + const added = await useDownloadStore.getState().addDownload({ + id: 'rejected-start', + url: 'https://example.com/rejected.bin', + fileName: 'rejected.bin', + category: 'Other', + dateAdded: '' + }, { type: 'start-now' }); + + expect(added).toBe(false); + expect(useDownloadStore.getState().downloads[0].status).toBe('failed'); + }); + it('redownloads fallback media without requiring a format selector', async () => { useDownloadStore.setState({ downloads: [{ @@ -280,6 +301,33 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old'); }); + it('disables scheduler when its last selected queue is deleted', async () => { + const originalSettings = useSettingsStore.getState(); + const setScheduler = vi.fn(); + vi.mocked(useSettingsStore.getState).mockReturnValue({ + scheduler: { + enabled: true, + selectedQueueIds: ['queue-a'] + }, + setScheduler + } as any); + useDownloadStore.setState({ + queues: [ + { id: '00000000-0000-0000-0000-000000000001', name: 'Main Queue', isMain: true }, + { id: 'queue-a', name: 'Scheduled', isMain: false } + ], + downloads: [] + }); + + await useDownloadStore.getState().removeQueue('queue-a'); + + expect(setScheduler).toHaveBeenCalledWith(expect.objectContaining({ + enabled: false, + selectedQueueIds: [] + })); + vi.mocked(useSettingsStore.getState).mockReturnValue(originalSettings); + }); + it('retains the UI item when backend removal fails', async () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 61a8c36..4bd4a96 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -125,11 +125,6 @@ const syncSystemIntegrations = () => { const settings = useSettingsStore.getState(); const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length; invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {}); - if (settings.preventsSleepWhileDownloading) { - invoke('set_prevent_sleep', { prevent: activeCount > 0 }).catch(() => {}); - } else { - invoke('set_prevent_sleep', { prevent: false }).catch(() => {}); - } }; const effectiveDestinationForItem = async ( @@ -198,7 +193,7 @@ interface DownloadState { openDeleteModal: (downloadIds?: string | string[]) => void; closeDeleteModal: () => void; setSelectedPropertiesDownloadId: (id: string | null) => void; - addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise; + addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise; updateDownload: (id: string, updates: Partial) => void; removeDownload: (id: string, deleteFile?: boolean) => Promise; redownload: (id: string) => Promise; @@ -335,12 +330,16 @@ export const useDownloadStore = create((set, get) => ({ if (action.type === 'add-to-queue') { info(`Download ${item.id} added to queue ${action.queueId}`); + return true; } else if (action.type === 'start-now') { if (await dispatchItem(item.id)) { get().updateDownload(item.id, { hasBeenDispatched: true }); + info(`Download ${item.id} started`); + return true; } - info(`Download ${item.id} started`); + return false; } + return false; }, applyProperties: async (id, updates) => { const state = get(); @@ -676,6 +675,15 @@ export const useDownloadStore = create((set, get) => ({ d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d ) })); + const settings = useSettingsStore.getState(); + if (settings.scheduler.selectedQueueIds.includes(id)) { + const selectedQueueIds = settings.scheduler.selectedQueueIds.filter(queueId => queueId !== id); + settings.setScheduler({ + ...settings.scheduler, + enabled: selectedQueueIds.length > 0 ? settings.scheduler.enabled : false, + selectedQueueIds + }); + } }, initDB: async () => { try { @@ -764,6 +772,7 @@ export const useDownloadStore = create((set, get) => ({ } } catch (e) { console.error("Failed to init DB", e); + throw e; } } })); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 2ae797f..ade0a7a 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -261,7 +261,6 @@ export const useSettingsStore = create()( setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => { info('Settings updated: preventsSleepWhileDownloading'); set({ preventsSleepWhileDownloading }); - if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error); }, setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); }, setCategorySubfolder: (category, subfolder) => { @@ -344,6 +343,8 @@ export const useSettingsStore = create()( isSidebarVisible: state.isSidebarVisible, activeSettingsTab: state.activeSettingsTab, scheduler: state.scheduler, + schedulerRunning: state.schedulerRunning, + schedulerActiveDownloadIds: state.schedulerActiveDownloadIds, schedulerLastStartKey: state.schedulerLastStartKey, schedulerLastStopKey: state.schedulerLastStopKey, lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,