diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index c1296b3..c55df92 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -698,6 +698,9 @@ pub struct PersistedSettings { pub scheduler_running: bool, pub scheduler_active_download_ids: Vec, pub scheduler_last_start_key: String, + #[serde(default)] + #[ts(optional)] + pub scheduler_triggered_start_key: Option, pub scheduler_last_stop_key: String, pub last_custom_speed_limit_ki_b: u32, #[serde(default = "default_speed_limit_unit")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f6b4fe8..bec60c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6305,12 +6305,17 @@ pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Resu } #[tauri::command] -fn perform_system_action( +async fn perform_system_action( caller: tauri::WebviewWindow, + state: tauri::State<'_, AppState>, action: crate::ipc::PostQueueAction, + force: bool, ) -> Result<(), String> { properties_window::ensure_main_window(&caller)?; - execute_system_action(action) + state.queue_manager.begin_system_action(force).await?; + let result = execute_system_action(action); + state.queue_manager.end_system_action(); + result } #[tauri::command] @@ -6325,6 +6330,7 @@ fn ack_schedule_trigger( crate::settings::update_settings_state(&app_handle, |state| match action.as_str() { "start" => { state.insert("schedulerLastStartKey".to_string(), serde_json::json!(key)); + state.insert("schedulerTriggeredStartKey".to_string(), serde_json::json!("")); } "stop" => { state.insert("schedulerLastStopKey".to_string(), serde_json::json!(key)); @@ -6337,6 +6343,7 @@ fn ack_schedule_trigger( if let Some(settings) = cached.as_mut() { if action == "start" { settings.scheduler_last_start_key = key; + settings.scheduler_triggered_start_key = None; } else { settings.scheduler_last_stop_key = key; } @@ -8593,11 +8600,15 @@ async fn move_torrent_data( } if let Some(session_id) = properties_session_id.as_deref() { properties.with_current_session(caller.label(), session_id, || { - state.queue_manager.begin_torrent_move(&id); Ok(()) })?; - } else { - state.queue_manager.begin_torrent_move(&id); + } + state.queue_manager.begin_torrent_move(&id).await?; + if let Some(session_id) = properties_session_id.as_deref() { + if let Err(error) = properties.with_current_session(caller.label(), session_id, || Ok(())) { + state.queue_manager.finish_torrent_move(&id); + return Err(error); + } } if let Err(error) = write_torrent_move_journal( &journal, @@ -8693,6 +8704,7 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit("download-state", move_restore_event()); return Err("Torrent data changed during relocation".to_string()); } @@ -8702,6 +8714,7 @@ async fn move_torrent_data( cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id); let _ = app_handle.emit("download-state", move_restore_event()); return Err("Torrent data changed during relocation".to_string()); } @@ -9610,9 +9623,13 @@ async fn set_global_speed_limit( limit: Option, ) -> Result<(), String> { properties_window::ensure_main_window(&caller)?; - let normalized_limit = limit - .as_deref() - .and_then(normalize_speed_limit_for_aria2); + let normalized_limit = match limit.as_deref().map(str::trim) { + None | Some("") => None, + Some(raw) => Some( + normalize_speed_limit_for_aria2(raw) + .ok_or_else(|| "Global speed limit is invalid".to_string())?, + ), + }; let limit_str = normalized_limit.clone().unwrap_or_else(|| "0".to_string()); rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index e206914..8d7cf9c 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -52,6 +52,19 @@ pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M"; pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024; pub const MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB: u32 = 1_048_576; +/// A per-download zero is different from an empty/global limit: Aria2 uses +/// `0` to remove the item cap, which intentionally lets an item override the +/// daemon-wide limit. Keep that sentinel through payloads, retries, and live +/// GID updates instead of normalizing it to `None`. +fn normalize_download_speed_limit(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed == "0" { + Some("0".to_string()) + } else { + crate::normalize_speed_limit_for_aria2(trimmed) + } +} + pub fn normalize_minimum_normal_download_speed_kib(value: u32) -> Result { if value > MAX_MINIMUM_NORMAL_DOWNLOAD_SPEED_KIB { return Err(format!( @@ -928,6 +941,10 @@ pub struct QueueManager { /// Serializes queue-slot selection with global permit acquisition and /// ownership transitions. admission_gate: Mutex<()>, + /// Prevents new enqueue/admission work after a system action has passed + /// its final safety check. The flag is set while holding admission_gate so + /// the check and the fence are one state transition. + system_action_pending: AtomicBool, /// Last queue selected by the dispatcher. Selection starts after this /// queue when multiple queues have eligible work. dispatch_cursor: Mutex>, @@ -942,6 +959,7 @@ pub struct QueueManager { /// are scoped to the current GID and control epoch and never leave this /// process as durable state. torrent_telemetry: Mutex>, + torrent_moves: StdMutex>, torrent_move_cancellations: StdMutex>, /// aria2 gid -> download id map (shared with the WS poller). @@ -1034,6 +1052,7 @@ impl QueueManager { queue_limits: Mutex::new(HashMap::new()), queue_permit_ownership: Mutex::new(HashMap::new()), admission_gate: Mutex::new(()), + system_action_pending: AtomicBool::new(false), dispatch_cursor: Mutex::new(None), target_capacity: AtomicUsize::new(capacity), slots_to_retire: AtomicUsize::new(0), @@ -1045,6 +1064,7 @@ impl QueueManager { }), seed_budgets: StdMutex::new(HashMap::new()), torrent_telemetry: Mutex::new(HashMap::new()), + torrent_moves: StdMutex::new(HashSet::new()), torrent_move_cancellations: StdMutex::new(HashSet::new()), aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())), pending_completion: Arc::new(Mutex::new(HashMap::new())), @@ -1135,11 +1155,20 @@ impl QueueManager { true } - pub fn begin_torrent_move(&self, id: &str) { + pub async fn begin_torrent_move(&self, id: &str) -> Result<(), String> { + let _admission_gate = self.admission_gate.lock().await; + if self.system_action_pending.load(Ordering::Acquire) { + return Err("System action is already being performed".to_string()); + } + self.torrent_moves + .lock() + .expect("Torrent move lock poisoned") + .insert(id.to_string()); self.torrent_move_cancellations .lock() .expect("Torrent move cancellation lock poisoned") .remove(id); + Ok(()) } pub fn cancel_torrent_move(&self, id: &str) { @@ -1157,12 +1186,24 @@ impl QueueManager { } pub fn finish_torrent_move(&self, id: &str) { + self.torrent_moves + .lock() + .expect("Torrent move lock poisoned") + .remove(id); self.torrent_move_cancellations .lock() .expect("Torrent move cancellation lock poisoned") .remove(id); } + pub fn has_torrent_moves(&self) -> bool { + !self + .torrent_moves + .lock() + .expect("Torrent move lock poisoned") + .is_empty() + } + /// Drop counters after terminal cleanup/removal. Persisted lifetime /// totals remain owned by the DownloadItem row; this only removes raw /// process-local lifecycle state. @@ -1737,6 +1778,10 @@ impl QueueManager { mut task: QueuedTask, generation: u64, ) -> Result<(), String> { + let _admission_gate = self.admission_gate.lock().await; + if self.system_action_pending.load(Ordering::Acquire) { + return Err("System action is already being performed".to_string()); + } let id = task.id.clone(); let cancellations = self.enqueue_cancellations.lock().await; if cancellations @@ -2391,22 +2436,22 @@ impl QueueManager { } pub async fn aria2_speed_limited(&self, id: &str) -> bool { - if self - .aria2_global_speed_limit - .lock() - .unwrap_or_else(|error| error.into_inner()) - .is_some() - { - return true; - } - - self.aria2_payloads + let item_limit = self + .aria2_payloads .lock() .await .get(id) .and_then(|payload| payload.speed_limit.as_deref()) - .and_then(crate::normalize_speed_limit_for_aria2) - .is_some() + .and_then(normalize_download_speed_limit); + if item_limit.as_deref() == Some("0") { + return false; + } + item_limit.is_some() + || self + .aria2_global_speed_limit + .lock() + .unwrap_or_else(|error| error.into_inner()) + .is_some() } /// Change an active aria2 transfer's speed cap without replacing its GID @@ -2421,7 +2466,7 @@ impl QueueManager { let normalized_limit = match limit.as_deref().map(str::trim) { None | Some("") => None, Some(raw) => Some( - crate::normalize_speed_limit_for_aria2(raw) + normalize_download_speed_limit(raw) .ok_or_else(|| "invalid download speed limit".to_string())?, ), }; @@ -3004,6 +3049,9 @@ impl QueueManager { lifecycle_generation: u64, ) -> Option { let _admission_gate = self.admission_gate.lock().await; + if self.system_action_pending.load(Ordering::Acquire) { + return None; + } let mut ownership = self.queue_permit_ownership.lock().await; if ownership.contains_key(id) || self.active_permits.lock().await.contains_key(id) @@ -3063,6 +3111,21 @@ impl QueueManager { permit: OwnedSemaphorePermit, ) -> bool { let _admission_gate = self.admission_gate.lock().await; + if self.system_action_pending.load(Ordering::Acquire) { + let removed = self + .queue_permit_ownership + .lock() + .await + .get(id) + .is_some_and(|entry| { + entry.lifecycle_generation == lifecycle_generation && !entry.active + }); + if removed { + self.queue_permit_ownership.lock().await.remove(id); + self.notify.notify_waiters(); + } + return false; + } let mut ownership = self.queue_permit_ownership.lock().await; let owned = ownership .get(id) @@ -3090,6 +3153,9 @@ impl QueueManager { async fn try_admit_next_task(&self) -> Option<(OwnedSemaphorePermit, QueuedTask)> { let _admission_gate = self.admission_gate.lock().await; + if self.system_action_pending.load(Ordering::Acquire) { + return None; + } let mut pending = self.pending.lock().await; if pending.is_empty() { return None; @@ -3160,6 +3226,9 @@ impl QueueManager { permit: OwnedSemaphorePermit, ) { let _admission_gate = self.admission_gate.lock().await; + if self.system_action_pending.load(Ordering::Acquire) { + return; + } self.active_permits .lock() .await @@ -3203,6 +3272,18 @@ impl QueueManager { .get(id) .copied(); let mut ownership = self.queue_permit_ownership.lock().await; + if self.system_action_pending.load(Ordering::Acquire) { + let remove_reservation = ownership.get(id).is_some_and(|entry| { + entry.queue_id == queue_id + && entry.lifecycle_generation == lifecycle_generation + && !entry.active + }); + if remove_reservation { + ownership.remove(id); + self.notify.notify_waiters(); + } + return false; + } let has_matching_reservation = ownership.get(id).is_some_and(|entry| { entry.queue_id == queue_id && entry.lifecycle_generation == lifecycle_generation @@ -3362,6 +3443,31 @@ impl QueueManager { self.active_permits.lock().await.contains_key(id) } + /// Atomically fence new transfer admission after checking all backend-owned + /// work. The frontend performs the same check for a useful user message, + /// but this backend transition closes the check-to-action race. + pub async fn begin_system_action(&self, force: bool) -> Result<(), String> { + let _admission_gate = self.admission_gate.lock().await; + if self.system_action_pending.load(Ordering::Acquire) { + return Err("Another system action is already pending".to_string()); + } + if !force + && (!self.pending.lock().await.is_empty() + || !self.queue_permit_ownership.lock().await.is_empty() + || !self.active_permits.lock().await.is_empty() + || self.has_torrent_moves()) + { + return Err("System action was skipped because downloads are still active or queued".to_string()); + } + self.system_action_pending.store(true, Ordering::Release); + Ok(()) + } + + pub fn end_system_action(&self) { + self.system_action_pending.store(false, Ordering::Release); + self.notify.notify_waiters(); + } + /// Clear all permits belonging to aria2. Useful when aria2 WS connection drops. pub async fn clear_aria2_permits(&self) { let ids_to_fail: Vec = { @@ -6532,7 +6638,7 @@ impl SidecarSpawner for ProductionSpawner { if let Some(speed) = payload .speed_limit .as_deref() - .and_then(crate::normalize_speed_limit_for_aria2) + .and_then(normalize_download_speed_limit) { options.insert("max-download-limit".to_string(), serde_json::json!(speed)); } diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs index f003867..871b902 100644 --- a/src-tauri/src/scheduler.rs +++ b/src-tauri/src/scheduler.rs @@ -16,13 +16,14 @@ fn stop_is_due( stop_minute: Option, current_minute: u32, last_start_key: &str, + triggered_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_start_key == start_key || triggered_start_key == start_key) && last_stop_key != stop_key } @@ -33,6 +34,7 @@ struct OvernightStopCheck<'a> { current_minute: u32, previous_day_allowed: bool, last_start_key: &'a str, + triggered_start_key: &'a str, previous_start_key: &'a str, last_stop_key: &'a str, stop_key: &'a str, @@ -46,6 +48,7 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool { current_minute, previous_day_allowed, last_start_key, + triggered_start_key, previous_start_key, last_stop_key, stop_key, @@ -55,10 +58,31 @@ fn overnight_stop_is_due(check: OvernightStopCheck<'_>) -> bool { && start_minute.zip(stop_minute).is_some_and(|(start, stop)| { stop < start && current_minute >= stop && current_minute < start }) - && last_start_key == previous_start_key + && (last_start_key == previous_start_key || triggered_start_key == previous_start_key) && last_stop_key != stop_key } +fn persist_scheduler_start_trigger( + app_handle: &tauri::AppHandle, + settings_cache: &Arc>>, + key: &str, +) { + if let Err(error) = crate::settings::update_settings_state(app_handle, |state| { + state.insert( + "schedulerTriggeredStartKey".to_string(), + serde_json::json!(key), + ); + }) { + log::warn!("Failed to persist scheduler start trigger: {error}"); + } + + if let Ok(mut settings) = settings_cache.write() { + if let Some(settings) = settings.as_mut() { + settings.scheduler_triggered_start_key = Some(key.to_string()); + } + } +} + pub fn spawn_scheduler( app_handle: tauri::AppHandle, settings_cache: Arc>>, @@ -66,6 +90,11 @@ pub fn spawn_scheduler( 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(); + // Renderer acknowledgement remains the durable completion record, but + // a native dispatch marker also survives a closed/unmounted webview so + // an overnight stop does not become permanently ineligible. The + // process-local start key also covers the same-loop event/stop check. + let mut triggered_start_key = String::new(); loop { interval.tick().await; @@ -74,11 +103,21 @@ pub fn spawn_scheduler( ( settings.scheduler.clone(), settings.scheduler_last_start_key.clone(), + settings + .scheduler_triggered_start_key + .clone() + .unwrap_or_default(), settings.scheduler_last_stop_key.clone(), ) }) }); - if let Some((scheduler, scheduler_last_start_key, scheduler_last_stop_key)) = settings { + if let Some(( + scheduler, + scheduler_last_start_key, + persisted_triggered_start_key, + scheduler_last_stop_key, + )) = settings + { if !scheduler.enabled { continue; } @@ -108,13 +147,29 @@ pub fn spawn_scheduler( .get("start") .is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5)) { - let _ = app_handle.emit( + if persisted_triggered_start_key != start_key + && triggered_start_key != start_key + { + // Record the dispatch intent before emitting so a + // crash between the native event and renderer ack + // still makes an overnight stop eligible. Start + // events remain retryable until the renderer acks + // them, which covers startup/listener races. + persist_scheduler_start_trigger( + &app_handle, + &settings_cache, + &start_key, + ); + } + if app_handle.emit( "schedule-trigger", serde_json::json!({ "action": "start", "key": start_key }), - ); + ).is_ok() { + triggered_start_key = start_key.clone(); + } last_emit.insert("start", std::time::Instant::now()); } @@ -125,6 +180,13 @@ pub fn spawn_scheduler( stop_minute, current_minute, &scheduler_last_start_key, + if triggered_start_key == start_key { + start_key.as_str() + } else if persisted_triggered_start_key == start_key { + start_key.as_str() + } else { + "" + }, &start_key, &scheduler_last_stop_key, &stop_key, @@ -146,6 +208,13 @@ pub fn spawn_scheduler( current_minute, previous_day_allowed, last_start_key: &scheduler_last_start_key, + triggered_start_key: if triggered_start_key == previous_start_key { + previous_start_key.as_str() + } else if persisted_triggered_start_key == previous_start_key { + previous_start_key.as_str() + } else { + "" + }, previous_start_key: &previous_start_key, last_stop_key: &scheduler_last_stop_key, stop_key: &stop_key, @@ -195,6 +264,7 @@ mod tests { Some(480), 600, "", + "", "2026-06-22-start", "", "2026-06-22-stop", @@ -204,12 +274,43 @@ mod tests { Some(480), 600, "2026-06-22-start", + "", "2026-06-22-start", "", "2026-06-22-stop", )); } + #[test] + fn stop_accepts_process_local_start_when_renderer_ack_is_missing() { + assert!(stop_is_due( + true, + Some(480), + 600, + "", + "2026-06-22-start", + "2026-06-22-start", + "", + "2026-06-22-stop", + )); + } + + #[test] + fn overnight_stop_accepts_persisted_start_trigger_when_app_restarts() { + assert!(overnight_stop_is_due(OvernightStopCheck { + stop_time_enabled: true, + start_minute: Some(1320), + stop_minute: Some(360), + current_minute: 420, + previous_day_allowed: true, + last_start_key: "", + triggered_start_key: "2026-06-22-start", + previous_start_key: "2026-06-22-start", + last_stop_key: "", + stop_key: "2026-06-23-stop", + })); + } + #[test] fn overnight_stop_uses_the_previous_day_start() { assert!(overnight_stop_is_due(OvernightStopCheck { @@ -219,6 +320,7 @@ mod tests { current_minute: 420, previous_day_allowed: true, last_start_key: "2026-06-22-start", + triggered_start_key: "", previous_start_key: "2026-06-22-start", last_stop_key: "", stop_key: "2026-06-23-stop", @@ -230,6 +332,7 @@ mod tests { current_minute: 1380, previous_day_allowed: true, last_start_key: "2026-06-22-start", + triggered_start_key: "", previous_start_key: "2026-06-22-start", last_stop_key: "", stop_key: "2026-06-22-stop", @@ -241,6 +344,7 @@ mod tests { current_minute: 420, previous_day_allowed: false, last_start_key: "2026-06-22-start", + triggered_start_key: "", previous_start_key: "2026-06-22-start", last_stop_key: "", stop_key: "2026-06-23-stop", diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 43a456d..5873f82 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -271,7 +271,11 @@ pub fn preserve_scheduler_runtime_keys( }; 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"] { + for key in [ + "schedulerLastStartKey", + "schedulerTriggeredStartKey", + "schedulerLastStopKey", + ] { if let Some(value) = existing_state.get(key) { incoming_state.insert(key.to_string(), value.clone()); } @@ -606,6 +610,8 @@ fn validate_settings(settings: &mut PersistedSettings) { settings.minimum_normal_download_speed_ki_b, ) .unwrap_or_default(); + settings.global_speed_limit = crate::normalize_speed_limit_for_aria2(&settings.global_speed_limit) + .unwrap_or_default(); settings.torrent_overall_upload_limit = crate::normalize_speed_limit_for_aria2( &settings.torrent_overall_upload_limit, ) @@ -875,6 +881,7 @@ fn default_settings() -> PersistedSettings { scheduler_running: false, scheduler_active_download_ids: Vec::new(), scheduler_last_start_key: String::new(), + scheduler_triggered_start_key: None, scheduler_last_stop_key: String::new(), last_custom_speed_limit_ki_b: 1024, last_custom_speed_limit_unit: "MB/s".to_string(), @@ -940,6 +947,7 @@ mod tests { let existing = json!({ "state": { "schedulerLastStartKey": "2026-06-22-start", + "schedulerTriggeredStartKey": "2026-06-22-start", "schedulerLastStopKey": "2026-06-22-stop" }, "version": 3 @@ -948,6 +956,7 @@ mod tests { let incoming = json!({ "state": { "schedulerLastStartKey": "", + "schedulerTriggeredStartKey": "", "schedulerLastStopKey": "", "theme": "system" }, @@ -958,6 +967,10 @@ mod tests { 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"]["schedulerTriggeredStartKey"], + "2026-06-22-start" + ); assert_eq!(merged["state"]["schedulerLastStopKey"], "2026-06-22-stop"); } @@ -1051,6 +1064,19 @@ mod tests { assert!(settings.torrent_overall_upload_limit.is_empty()); } + #[test] + fn normalizes_invalid_global_speed_limit_to_unlimited() { + let stored = json!({ + "state": { + "globalSpeedLimit": "not-a-rate" + } + }); + + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + + assert!(settings.global_speed_limit.is_empty()); + } + #[test] fn migrates_legacy_location_settings_and_preserves_custom_overrides() { let stored = json!({ diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index f8cef83..58afe46 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -558,6 +558,81 @@ async fn live_aria2_speed_limit_updates_the_current_gid_and_payload() { dispatcher.abort(); } +#[tokio::test] +async fn explicit_zero_download_limit_overrides_the_global_limit_and_survives_admission() { + let (manager, spawner) = make_manager(1); + let manager = Arc::new(manager); + manager.set_aria2_global_speed_limit(Some("2M".to_string())); + let mut task = aria2_task("speed-unlimited-override"); + task.payload.speed_limit = Some("0".to_string()); + manager.push(task).await.unwrap(); + + let dispatcher = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { manager.run_dispatcher().await }) + }; + timeout(Duration::from_secs(1), async { + loop { + if manager + .aria2_gid_for_download("speed-unlimited-override") + .is_some() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("aria2 dispatch should register a gid"); + + assert_eq!( + spawner.add_speed_limits.lock().unwrap().as_slice(), + &[Some("0".to_string())] + ); + assert!(!manager + .aria2_speed_limited("speed-unlimited-override") + .await); + + manager + .apply_completion( + "speed-unlimited-override", + firelink_lib::queue::PendingOutcome::Complete, + ) + .await; + dispatcher.abort(); +} + +#[tokio::test] +async fn system_action_fence_rejects_new_work_and_force_bypasses_only_firelink_safety() { + let (manager, _spawner) = make_manager(1); + let mut queued = aria2_task("queued-before-action"); + manager.push(queued.clone()).await.unwrap(); + assert!(manager.begin_system_action(false).await.is_err()); + assert!(manager.remove_from_pending("queued-before-action").await); + + assert!(manager.ensure_aria2_permit("active-before-action").await); + assert!(manager.begin_system_action(false).await.is_err()); + manager.release_permit("active-before-action").await; + + manager + .begin_torrent_move("moving-before-action") + .await + .unwrap(); + assert!(manager.begin_system_action(false).await.is_err()); + manager.finish_torrent_move("moving-before-action"); + + manager.begin_system_action(true).await.unwrap(); + let candidate = manager.acquire_aria2_permit_candidate().await.unwrap(); + assert!(!manager + .park_aria2_permit_if_missing("candidate-during-action", candidate) + .await); + assert!(!manager.has_active_permit("candidate-during-action").await); + queued.id = "queued-during-action".to_string(); + assert!(manager.push(queued).await.is_err()); + manager.end_system_action(); + manager.push(aria2_task("queued-after-action")).await.unwrap(); +} + #[tokio::test] async fn live_aria2_speed_limit_rejects_invalid_and_non_active_requests() { let (manager, spawner) = make_manager(1); diff --git a/src/App.tsx b/src/App.tsx index c61f193..cf4e33d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import { formatDownloadBytes } from './utils/downloadProgress'; import { synchronizeDocumentAppearance } from './utils/documentAppearance'; import { createMainWindowSizePersistence } from './utils/mainWindowState'; import type { MainWindowSize } from './bindings/MainWindowSize'; +import { beginSchedulerControl, isSchedulerControlCurrent } from './utils/schedulerControl'; const loadSettingsView = () => import('./components/SettingsView'); const loadSchedulerView = () => import('./components/SchedulerView'); @@ -106,7 +107,6 @@ const PageLoadingFallback = () => { }; let automaticUpdateCheckStarted = false; -const processingScheduleKeys = new Set(); let powerPreferencesSync: Promise = Promise.resolve(); const waitForSettingsHydration = (): Promise => { @@ -264,7 +264,6 @@ function App() { const preventsDisplaySleepWhileDownloading = useSettingsStore( state => state.preventsDisplaySleepWhileDownloading ); - const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; const { addToast, removeToast } = useToast(); const isMacUserAgent = navigator.userAgent.includes('Mac'); const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent); @@ -314,6 +313,55 @@ function App() { const actionLabel = t($ => $.scheduler.postActions[action]); let timerId: number | null = null; let toastId: string | null = null; + const showForceActionToast = () => { + let forceToastId: string | null = null; + const proceed = () => { + if (forceToastId !== null) { + removeToast(forceToastId); + forceToastId = null; + } + invoke('perform_system_action', { action, force: true }).catch(error => { + console.error('Forced scheduled post action failed:', error); + addToast({ + message: t($ => $.app.systemActionFailed, { detail: String(error) }), + variant: 'error', + isActionable: true + }); + }); + }; + forceToastId = addToast({ + variant: 'warning', + isActionable: true, + duration: 0, + message: ( +
+ {t($ => $.app.systemActionCancelled)} + +
+ ) + }); + }; + const perform = (force: boolean) => { + invoke('perform_system_action', { action, force }).catch(error => { + const detail = String(error); + if (!force && detail.includes('active or queued')) { + showForceActionToast(); + return; + } + console.error('Scheduled post action failed:', error); + addToast({ + message: t($ => $.app.systemActionFailed, { detail }), + variant: 'error', + isActionable: true + }); + }); + }; const cancel = () => { clearPendingPostActionTimer(); timerId = null; @@ -355,24 +403,13 @@ function App() { isActiveDownloadStatus(download.status) ); if (activeTransfers) { - addToast({ - message: t($ => $.app.systemActionCancelled), - variant: 'warning', - isActionable: true - }); + showForceActionToast(); return; } - invoke('perform_system_action', { action }).catch(error => { - console.error('Scheduled post action failed:', error); - addToast({ - message: t($ => $.app.systemActionFailed, { detail: String(error) }), - variant: 'error', - isActionable: true - }); - }); + perform(false); }, 10_000); pendingPostActionTimer.current = timerId; - }, [addToast, clearPendingPostActionTimer, removeToast]); + }, [addToast, clearPendingPostActionTimer, removeToast, t]); const startSidebarResize = (event: React.PointerEvent) => { event.preventDefault(); @@ -402,12 +439,6 @@ function App() { return clearPendingPostActionTimer; }, [clearPendingPostActionTimer]); - useEffect(() => { - if (activeTransferCount > 0) { - clearPendingPostActionTimer(); - } - }, [activeTransferCount, clearPendingPostActionTimer]); - useEffect(() => { initMediaDomains(); window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth)); @@ -841,6 +872,11 @@ function App() { useEffect(() => { if (!coreReady) return; + // Scope duplicate suppression to this listener instance. A module-level + // set can retain a key across a webview/listener restart while the old + // async handler is still unwinding, causing the replacement listener to + // drop the only retry for that scheduled action. + const processingScheduleKeys = new Set(); const unlisten = listen('schedule-trigger', async (event) => { const state = useSettingsStore.getState(); const payload = event.payload; @@ -848,6 +884,7 @@ function App() { processingScheduleKeys.add(payload.key); try { if (payload.action === 'start') { + const generation = beginSchedulerControl(); clearPendingPostActionTimer(); const scheduledQueueIds = getScheduledQueueIds(); if (scheduledQueueIds.length === 0) { @@ -866,6 +903,13 @@ function App() { scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId)) ); const acceptedIds = startedResults.flat(); + if (!isSchedulerControlCurrent(generation)) { + await Promise.allSettled( + acceptedIds.map(id => useDownloadStore.getState().pauseDownload(id)) + ); + await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); + return; + } const scheduledQueueSet = new Set(scheduledQueueIds); const trackedIds = useDownloadStore.getState().downloads .filter(download => @@ -879,14 +923,18 @@ function App() { state.setSchedulerRunning(activeIds.length > 0); await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); } else if (payload.action === 'stop') { + const generation = beginSchedulerControl(); + // A stop event can race with the completion effect's post-action + // countdown after it has already cleared the tracked IDs. Always + // cancel that pending action before applying the stop transition. + clearPendingPostActionTimer(); const trackedIds = state.schedulerActiveDownloadIds; if (trackedIds.length > 0) { - clearPendingPostActionTimer(); const pauseResults = await Promise.allSettled( trackedIds.map(id => useDownloadStore.getState().pauseDownload(id)) ); const failedPauses = pauseResults.filter(result => result.status === 'rejected').length; - if (failedPauses > 0) { + if (failedPauses > 0 && isSchedulerControlCurrent(generation)) { addToast({ message: failedPauses === 1 ? t($ => $.app.schedulerPauseOneFailed) @@ -896,8 +944,10 @@ function App() { }); } } - state.setSchedulerActiveDownloadIds([]); - state.setSchedulerRunning(false); + if (isSchedulerControlCurrent(generation)) { + state.setSchedulerActiveDownloadIds([]); + state.setSchedulerRunning(false); + } await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key }); } } finally { @@ -906,6 +956,7 @@ function App() { }); return () => { + beginSchedulerControl(); unlisten.then(f => f()).catch(console.error); }; }, [addToast, clearPendingPostActionTimer, coreReady]); @@ -929,15 +980,7 @@ function App() { isActionable: true }); } else if (settings.scheduler.postQueueAction !== 'none') { - if (downloads.some(download => isActiveDownloadStatus(download.status))) { - addToast({ - message: t($ => $.app.scheduledActionSkippedActive), - variant: 'warning', - isActionable: true - }); - } else { - schedulePostQueueAction(settings.scheduler.postQueueAction); - } + schedulePostQueueAction(settings.scheduler.postQueueAction); } }, [ addToast, diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 838e41c..72e7479 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -12,4 +12,4 @@ import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; import type { WindowControlStyle } from "./WindowControlStyle"; -export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerTriggeredStartKey?: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index 67c58ad..99a4595 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -12,6 +12,7 @@ import { useToast } from '../contexts/ToastContext'; import { usePlatformInfo } from '../utils/platform'; import { useTranslation } from 'react-i18next'; import { formatDateTime } from '../utils/dateTime'; +import { beginSchedulerControl, isSchedulerControlCurrent } from '../utils/schedulerControl'; const days = [ { value: 0, key: 'su' }, @@ -30,7 +31,8 @@ const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [ { value: 'shutdown', icon: Power }, ]; -const minuteOfDay = (value: string) => { +const minuteOfDay = (value: string): number | null => { + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) return null; const [hour, minute] = value.split(':').map(Number); return hour * 60 + minute; }; @@ -38,7 +40,10 @@ const minuteOfDay = (value: string) => { function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' { if (!settings.enabled) return 'disabled'; - const [hour, minute] = settings.startTime.split(':').map(Number); + const startMinute = minuteOfDay(settings.startTime); + if (startMinute === null) return 'none'; + const hour = Math.floor(startMinute / 60); + const minute = startMinute % 60; const now = new Date(); for (let offset = 0; offset < 8; offset += 1) { @@ -136,7 +141,13 @@ export default function SchedulerView() { addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true }); return; } - if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) { + const startMinute = minuteOfDay(draft.startTime); + const stopMinute = minuteOfDay(draft.stopTime); + if (draft.enabled && (startMinute === null || (draft.stopTimeEnabled && stopMinute === null))) { + addToast({ message: t($ => $.scheduler.validationTime), variant: 'error', isActionable: true }); + return; + } + if (draft.enabled && draft.stopTimeEnabled && stopMinute === startMinute) { addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true }); return; } @@ -151,11 +162,18 @@ export default function SchedulerView() { }; const runNow = async () => { + const generation = beginSchedulerControl(); const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds); const results = await Promise.all( effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId)) ); const acceptedIds = results.flat(); + if (!isSchedulerControlCurrent(generation)) { + await Promise.allSettled( + acceptedIds.map(id => useDownloadStore.getState().pauseDownload(id)) + ); + return; + } const selectedQueueSet = new Set(effectiveSelectedQueueIds); const trackedIds = useDownloadStore.getState().downloads .filter(download => @@ -180,9 +198,11 @@ export default function SchedulerView() { }; const pauseNow = async () => { + const generation = beginSchedulerControl(); const counts = await Promise.all( effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId)) ); + if (!isSchedulerControlCurrent(generation)) return; const count = counts.reduce((total, queueCount) => total + queueCount, 0); useSettingsStore.getState().setSchedulerRunning(false); useSettingsStore.getState().setSchedulerActiveDownloadIds([]); diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 4e35e59..e54f9a3 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -196,6 +196,7 @@ const common = { linuxActionsDescription: 'Sleep, restart, and shut down use your Linux desktop and system policy. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.', validationDay: 'Select at least one day for the scheduler', validationQueue: 'Select at least one queue for the scheduler', + validationTime: 'Enter valid times in HH:MM format', validationStopTime: 'Stop time must be later than start time', saved: 'Scheduler settings saved', trackingOne: 'Tracking 1 scheduled download', @@ -513,6 +514,7 @@ const common = { settingsSaveFailed: 'Could not save settings. Check storage permissions and try again.', systemActionCountdown: '{{action}} in 10 seconds.', systemActionCancelled: 'System action cancelled because another download is active or queued.', + systemActionProceedAnyway: 'Proceed anyway', systemActionFailed: 'Scheduled system action failed: {{detail}}', downloadCompleteTitle: 'Download Complete', downloadCompleteBody: '{{fileName}} has finished downloading.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 8b1c09a..69a381d 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -196,6 +196,7 @@ const fa = { linuxActionsDescription: 'خوابیدن، راه‌اندازی مجدد و خاموش کردن از دسکتاپ و سیاست‌های سیستم Linux استفاده می‌کنند. Firelink هنگام اجرا هر اقدام ردشده‌ای را گزارش می‌دهد؛ هیچ مجوز دائمی از قبل درخواست نمی‌شود.', validationDay: 'حداقل یک روز برای زمان‌بند انتخاب کنید', validationQueue: 'حداقل یک صف برای زمان‌بند انتخاب کنید', + validationTime: 'زمان‌ها را با قالب معتبر HH:MM وارد کنید', validationStopTime: 'زمان پایان باید بعد از زمان شروع باشد', saved: 'تنظیمات زمان‌بند ذخیره شد', trackingOne: 'در حال پیگیری ۱ دانلود زمان‌بندی‌شده', @@ -513,6 +514,7 @@ const fa = { settingsSaveFailed: 'تنظیمات ذخیره نشدند. دسترسی‌های ذخیره‌سازی را بررسی کرده و دوباره امتحان کنید.', systemActionCountdown: '{{action}} در ۱۰ ثانیه.', systemActionCancelled: 'اقدام سیستم لغو شد زیرا دانلود دیگری فعال یا در صف است.', + systemActionProceedAnyway: 'ادامه دادن به هر حال', systemActionFailed: 'اقدام سیستم زمان‌بندی‌شده ناموفق بود: {{detail}}', downloadCompleteTitle: 'دانلود تکمیل‌شده', downloadCompleteBody: 'دانلود {{fileName}} به پایان رسید.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 68b5b8a..9193194 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -196,6 +196,7 @@ const he = { linuxActionsDescription: 'שינה, הפעלה מחדש וכיבוי משתמשים בשולחן העבודה ובמדיניות המערכת של Linux. Firelink מדווח על כל פעולה שנדחתה בעת ביצועה; לא נדרשת הרשאה קבועה מראש.', validationDay: 'יש לבחור לפחות יום אחד למתזמן', validationQueue: 'יש לבחור לפחות תור אחד למתזמן', + validationTime: 'יש להזין שעות תקינות בתבנית HH:MM', validationStopTime: 'שעת הסיום חייבת להיות מאוחרת משעת ההתחלה', saved: 'הגדרות המתזמן נשמרו', trackingOne: 'עוקב אחר הורדה מתוזמנת אחת', @@ -513,6 +514,7 @@ const he = { settingsSaveFailed: 'לא ניתן לשמור הגדרות. בדוק הרשאות אחסון ונסה שוב.', systemActionCountdown: '{{action}} בעוד 10 שניות.', systemActionCancelled: 'פעולת המערכת בוטלה מכיוון שהורדה אחרת פעילה או בתור.', + systemActionProceedAnyway: 'להמשיך בכל זאת', systemActionFailed: 'פעולת מערכת מתוזמנת נכשלה: {{detail}}', downloadCompleteTitle: 'ההורדה הושלמה', downloadCompleteBody: 'הורדת {{fileName}} הסתיימה.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 134683b..54f79a0 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -196,6 +196,7 @@ const ru = { linuxActionsDescription: 'Спящий режим, перезагрузка и выключение используют системную политику и рабочий стол Linux. Firelink сообщит о любых отклонённых действиях при их запуске; постоянное разрешение заранее не запрашивается.', validationDay: 'Выберите хотя бы один день для планировщика', validationQueue: 'Выберите хотя бы одну очередь для планировщика', + validationTime: 'Введите корректное время в формате HH:MM', validationStopTime: 'Время окончания должно быть позже времени начала', saved: 'Настройки планировщика сохранены', trackingOne: 'Отслеживается 1 запланированная загрузка', @@ -513,6 +514,7 @@ const ru = { settingsSaveFailed: 'Не удалось сохранить настройки. Проверьте разрешения хранилища и попробуйте снова.', systemActionCountdown: '{{action}} через 10 секунд.', systemActionCancelled: 'Системное действие отменено, так как есть активная или запланированная загрузка.', + systemActionProceedAnyway: 'Всё равно продолжить', systemActionFailed: 'Не удалось выполнить запланированное системное действие: {{detail}}', downloadCompleteTitle: 'Загрузка завершена', downloadCompleteBody: 'Загрузка {{fileName}} завершена.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 9ff1e02..8d2164a 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -196,6 +196,7 @@ const uk = { linuxActionsDescription: 'Сон, перезавантаження та вимкнення використовують ваш робочий стіл Linux та системну політику. Firelink повідомляє про будь-яку відхилену дію під час її виконання; жодні постійні дозволи заздалегідь не вимагаються.', validationDay: 'Виберіть принаймні один день для планувальника', validationQueue: 'Виберіть принаймні одну чергу для планувальника', + validationTime: 'Введіть коректний час у форматі HH:MM', validationStopTime: 'Час зупинки має бути пізнішим за час початку', saved: 'Налаштування планувальника збережено', trackingOne: 'Відстеження 1 запланованого завантаження', @@ -513,6 +514,7 @@ const uk = { settingsSaveFailed: 'Не вдалося зберегти налаштування. Перевірте дозволи сховища та спробуйте ще раз.', systemActionCountdown: '{{action}} через 10 секунд.', systemActionCancelled: 'Системна дія скасована, оскільки активне або в черзі інше завантаження.', + systemActionProceedAnyway: 'Продовжити попри це', systemActionFailed: 'Не вдалося виконати заплановану системну дію: {{detail}}', downloadCompleteTitle: 'Завантаження завершено', downloadCompleteBody: 'Завантаження {{fileName}} завершено.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index b0b289c..5bd31de 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -196,6 +196,7 @@ const zhCN = { linuxActionsDescription: '睡眠、重启和关机使用您的 Linux 桌面和系统策略。Firelink 在运行时会报告任何被拒绝的操作;不会提前声明任何永久权限。', validationDay: '至少为计划任务选择一天', validationQueue: '至少为计划任务选择一个队列', + validationTime: '请输入 HH:MM 格式的有效时间', validationStopTime: '停止时间必须晚于开始时间', saved: '计划任务设置已保存', trackingOne: '正在跟踪 1 个计划的下载', @@ -513,6 +514,7 @@ const zhCN = { settingsSaveFailed: '无法保存设置。请检查存储权限并重试。', systemActionCountdown: '10 秒后{{action}}。', systemActionCancelled: '系统操作已取消,因为有其他下载正在进行或已排队。', + systemActionProceedAnyway: '仍然继续', systemActionFailed: '计划的系统操作失败:{{detail}}', downloadCompleteTitle: '下载完成', downloadCompleteBody: '{{fileName}} 已下载完成。', diff --git a/src/ipc.ts b/src/ipc.ts index a7c6df5..6471d5e 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -83,7 +83,7 @@ type CommandMap = { args: { preventSystemSleep: boolean; preventDisplaySleep: boolean }; result: void; }; - perform_system_action: { args: { action: PostQueueAction }; result: void }; + perform_system_action: { args: { action: PostQueueAction; force: boolean }; result: void }; ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void }; set_concurrent_limit: { args: { limit: number }; result: void }; set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void }; diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index c518c51..f258650 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -250,6 +250,17 @@ describe('useSettingsStore global speed limit persistence', () => { expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M'); expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' }); }); + + it('rejects malformed limits before changing native or local state', async () => { + await expect(useSettingsStore.getState().setGlobalSpeedLimit('not-a-rate')) + .rejects.toThrow('Global speed limit is invalid'); + + expect(ipc.invokeCommand).not.toHaveBeenCalledWith( + 'set_global_speed_limit', + expect.anything() + ); + expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M'); + }); }); describe('useSettingsStore Torrent overall upload limit persistence', () => { diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 53ac572..199c235 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -517,11 +517,15 @@ export const useSettingsStore = create()( }); }, setGlobalSpeedLimit: async (limit) => { + const normalized = normalizeSpeedLimitForBackend(limit); + if (limit.trim() && !normalized) { + return Promise.reject(new Error('Global speed limit is invalid')); + } await invoke('set_global_speed_limit', { - limit: normalizeSpeedLimitForBackend(limit) + limit: normalized }); info('Settings updated: globalSpeedLimit'); - set({ globalSpeedLimit: limit }); + set({ globalSpeedLimit: normalized ?? '' }); }, setTorrentOverallUploadLimit: (limit) => { const normalizedLimit = normalizeSpeedLimitForBackend(limit); @@ -1026,6 +1030,9 @@ export const useSettingsStore = create()( torrentOverallUploadLimit: typeof persisted.torrentOverallUploadLimit === 'string' ? normalizeSpeedLimitForBackend(persisted.torrentOverallUploadLimit) ?? '' : currentState.torrentOverallUploadLimit, + globalSpeedLimit: typeof persisted.globalSpeedLimit === 'string' + ? normalizeSpeedLimitForBackend(persisted.globalSpeedLimit) ?? '' + : currentState.globalSpeedLimit, perServerConnections: clampSettingInteger( persisted.perServerConnections, 1, diff --git a/src/utils/schedulerCompletion.test.ts b/src/utils/schedulerCompletion.test.ts index 21074cf..f2cf204 100644 --- a/src/utils/schedulerCompletion.test.ts +++ b/src/utils/schedulerCompletion.test.ts @@ -15,6 +15,10 @@ const download = (id: string, status: DownloadItem['status']): DownloadItem => ( }); describe('schedulerCompletionState', () => { + it('does not treat an empty scheduler set as completed', () => { + expect(schedulerCompletionState([], [])).toBe('incomplete'); + }); + it('stays active while any tracked scheduler download can still progress', () => { expect(schedulerCompletionState([ download('a', 'completed'), diff --git a/src/utils/schedulerCompletion.ts b/src/utils/schedulerCompletion.ts index 190890e..4485613 100644 --- a/src/utils/schedulerCompletion.ts +++ b/src/utils/schedulerCompletion.ts @@ -7,6 +7,8 @@ export const schedulerCompletionState = ( downloads: DownloadItem[], schedulerActiveDownloadIds: string[], ): SchedulerCompletionState => { + if (schedulerActiveDownloadIds.length === 0) return 'incomplete'; + const scheduledItems = schedulerActiveDownloadIds.map(id => downloads.find(download => download.id === id) ); diff --git a/src/utils/schedulerControl.test.ts b/src/utils/schedulerControl.test.ts new file mode 100644 index 0000000..ed3c8af --- /dev/null +++ b/src/utils/schedulerControl.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { beginSchedulerControl, isSchedulerControlCurrent } from './schedulerControl'; + +describe('scheduler control generation', () => { + it('invalidates an older asynchronous scheduler operation', () => { + const first = beginSchedulerControl(); + expect(isSchedulerControlCurrent(first)).toBe(true); + + const second = beginSchedulerControl(); + expect(isSchedulerControlCurrent(first)).toBe(false); + expect(isSchedulerControlCurrent(second)).toBe(true); + }); +}); diff --git a/src/utils/schedulerControl.ts b/src/utils/schedulerControl.ts new file mode 100644 index 0000000..fc6c8ac --- /dev/null +++ b/src/utils/schedulerControl.ts @@ -0,0 +1,14 @@ +let schedulerControlGeneration = 0; + +/** + * Start a new scheduler control lifecycle. A later manual pause or scheduler + * event invalidates earlier asynchronous queue operations before they can + * publish stale running state. + */ +export const beginSchedulerControl = (): number => { + schedulerControlGeneration += 1; + return schedulerControlGeneration; +}; + +export const isSchedulerControlCurrent = (generation: number): boolean => + schedulerControlGeneration === generation;