diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 6406830..cfcf3dc 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -378,6 +378,8 @@ pub struct PersistedSettings { pub ask_where_to_save_each_file: bool, pub remember_last_used_download_directory: bool, pub prevents_sleep_while_downloading: bool, + #[serde(default)] + pub prevents_display_sleep_while_downloading: bool, pub media_cookie_source: MediaCookieSource, pub site_logins: Vec, pub auto_check_updates: bool, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7c2a8c..42a25b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2911,6 +2911,7 @@ pub mod error; #[allow(dead_code)] pub mod ipc; mod parity; +mod power; mod platform; pub mod queue; pub mod process; @@ -2943,7 +2944,7 @@ pub struct AppState { pub aria2_port: std::sync::Arc, pub aria2_secret: String, pub media_semaphore: Arc, - pub sleep_preventer: Arc>>, + pub power_manager: Arc, pub scheduler_settings: Arc>>, pub queue_manager: Arc, } @@ -5150,6 +5151,33 @@ async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) { gid, error ); + if aria2_gid_not_found(&error) + && state.queue_manager.has_active_permit(&id).await + && state + .queue_manager + .aria2_gid_mapping(&gid) + .is_some_and(|mapping| mapping.id == id) + { + if let Some(mapping) = state.queue_manager.aria2_gid_mapping(&gid) { + log::warn!( + "aria2 reconnect reconciliation [{}]: mapped gid {} is missing; attempting payload recovery", + id, + gid + ); + if let Err(recovery_error) = state + .queue_manager + .refresh_aria2_connections(&id, &gid, mapping.epoch) + .await + { + log::warn!( + "aria2 reconnect reconciliation [{}]: payload recovery for missing gid {} failed: {}", + id, + gid, + recovery_error + ); + } + } + } continue; } }; @@ -5222,7 +5250,7 @@ fn aria2_daemon_process_exited(app_handle: &tauri::AppHandle) -> bool { } } -fn aria2_gid_not_found(error: &str) -> bool { +pub(crate) fn aria2_gid_not_found(error: &str) -> bool { let lower = error.to_ascii_lowercase(); lower.contains("gid") && lower.contains("not found") } @@ -5339,115 +5367,20 @@ fn get_platform_info(state: tauri::State<'_, AppState>) -> crate::ipc::PlatformI } } -#[cfg(target_os = "macos")] -mod macos_sleep { - use std::ffi::c_void; - #[allow(clippy::duplicated_attributes)] - #[link(name = "IOKit", kind = "framework")] - #[link(name = "CoreFoundation", kind = "framework")] - extern "C" { - pub fn IOPMAssertionCreateWithDescription( - AssertionType: *const c_void, - Name: *const c_void, - Details: *const c_void, - HumanReadableReason: *const c_void, - LocalizationBundlePath: *const c_void, - Timeout: f64, - TimeoutAction: *const c_void, - AssertionID: *mut u32, - ) -> i32; - pub fn IOPMAssertionRelease(AssertionID: u32) -> i32; - pub fn CFStringCreateWithCString( - alloc: *const c_void, - cStr: *const i8, - encoding: u32, - ) -> *const c_void; - pub fn CFRelease(arg: *const c_void); - } -} - -pub enum SleepPreventer { - #[cfg(target_os = "macos")] - Mac { system_sleep_id: u32, network_client_id: u32 }, - #[cfg(not(target_os = "macos"))] - Other(keepawake::KeepAwake), -} - -impl Drop for SleepPreventer { - fn drop(&mut self) { - #[cfg(target_os = "macos")] - { - let SleepPreventer::Mac { system_sleep_id, network_client_id } = self; - unsafe { - macos_sleep::IOPMAssertionRelease(*system_sleep_id); - macos_sleep::IOPMAssertionRelease(*network_client_id); - } - } - } -} - -#[cfg(target_os = "macos")] -fn create_sleep_preventer() -> Result { - use std::ffi::CString; - use std::ptr::null; - unsafe { - let create_cf_string = |s: &str| -> *const std::ffi::c_void { - let cstr = CString::new(s).unwrap(); - macos_sleep::CFStringCreateWithCString(null(), cstr.as_ptr(), 0x08000100) - }; - - let type_sys = create_cf_string("PreventSystemSleep"); - let type_net = create_cf_string("NetworkClientActive"); - let name = create_cf_string("Firelink active download"); - - let mut sys_id: u32 = 0; - let mut net_id: u32 = 0; - - let res1 = macos_sleep::IOPMAssertionCreateWithDescription( - type_sys, name, null(), null(), null(), 0.0, null(), &mut sys_id - ); - let res2 = macos_sleep::IOPMAssertionCreateWithDescription( - type_net, name, null(), null(), null(), 0.0, null(), &mut net_id - ); - - macos_sleep::CFRelease(type_sys); - macos_sleep::CFRelease(type_net); - macos_sleep::CFRelease(name); - - if res1 == 0 && res2 == 0 { - Ok(SleepPreventer::Mac { system_sleep_id: sys_id, network_client_id: net_id }) - } else { - if res1 == 0 { macos_sleep::IOPMAssertionRelease(sys_id); } - if res2 == 0 { macos_sleep::IOPMAssertionRelease(net_id); } - Err("Failed to create macOS sleep assertions".to_string()) - } - } -} - -#[cfg(not(target_os = "macos"))] -fn create_sleep_preventer() -> Result { - keepawake::Builder::default() - .idle(true) - .reason("Firelink active download") - .create() - .map(SleepPreventer::Other) - .map_err(|error| format!("failed to prevent system sleep: {error}")) +#[tauri::command] +fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> { + state.power_manager.set_system_prevention(prevent) } #[tauri::command] -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() { - *current_preventer = Some(create_sleep_preventer()?); - } - } else { - *current_preventer = None; - } - Ok(()) +fn set_power_preferences( + state: tauri::State<'_, AppState>, + prevent_system_sleep: bool, + prevent_display_sleep: bool, +) -> Result<(), String> { + state + .power_manager + .set_preferences(prevent_system_sleep, prevent_display_sleep) } pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> { @@ -6273,9 +6206,17 @@ fn db_save_settings( }; crate::db::save_settings(&connection, &merged)?; let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?; + let prevent_system_sleep = decoded.prevents_sleep_while_downloading; + let prevent_display_sleep = decoded.prevents_display_sleep_while_downloading; if let Ok(mut cached) = app_state.scheduler_settings.write() { *cached = Some(decoded); } + if let Err(error) = app_state + .power_manager + .set_preferences(prevent_system_sleep, prevent_display_sleep) + { + log::warn!("power: saved settings applied with an OS assertion error: {error}"); + } Ok(()) } @@ -7032,6 +6973,60 @@ mod tests { assert_eq!(observation.healthy_speed_samples, 0); } + #[test] + fn aria2_recovery_budget_survives_same_epoch_gid_replacement() { + let mut observation = Aria2ConnectionObservation { + gid: "gid-old".to_string(), + control_epoch: 7, + recovery_attempts: 2, + ..Default::default() + }; + let now = Instant::now(); + + assert_eq!( + observe_aria2_connections_with_epoch( + &mut observation, + Aria2ConnectionSample { + gid: "gid-new", + control_epoch: 7, + status: "active", + total: 100, + completed: 10, + speed_bytes: 1024.0, + active_connections: 16, + requested_connections: 16, + speed_limited: false, + now, + }, + ), + None + ); + assert_eq!( + observation.recovery_attempts, 2, + "a fresh gid in the same lifecycle must not reset the bounded recovery budget" + ); + + observe_aria2_connections_with_epoch( + &mut observation, + Aria2ConnectionSample { + gid: "gid-new-lifecycle", + control_epoch: 8, + status: "active", + total: 100, + completed: 10, + speed_bytes: 1024.0, + active_connections: 16, + requested_connections: 16, + speed_limited: false, + now: now + Duration::from_secs(1), + }, + ); + assert_eq!( + observation.recovery_attempts, 0, + "a genuinely new lifecycle may start with a fresh recovery budget" + ); + } + #[test] fn slow_recovery_requires_a_real_multi_connection_transfer() { let start = Instant::now(); @@ -8848,10 +8843,14 @@ fn observe_aria2_connections_with_epoch( now, } = sample; if observation.gid != gid || observation.control_epoch != control_epoch { + let preserved_recovery_attempts = (observation.control_epoch == control_epoch) + .then_some(observation.recovery_attempts) + .unwrap_or_default(); *observation = Aria2ConnectionObservation { gid: gid.to_string(), control_epoch, last_completed: completed, + recovery_attempts: preserved_recovery_attempts, ..Default::default() }; } @@ -9173,6 +9172,13 @@ pub fn run() { let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone())); let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent)); + let power_manager = queue_manager.power_manager(); + if let Some(settings) = persisted_settings.as_ref() { + let _ = power_manager.set_preferences( + settings.prevents_sleep_while_downloading, + settings.prevents_display_sleep_while_downloading, + ); + } let initial_global_speed_limit = persisted_settings .as_ref() .and_then(|settings| normalize_speed_limit_for_aria2(&settings.global_speed_limit)); @@ -9197,11 +9203,15 @@ pub fn run() { aria2_port: aria2_port.clone(), aria2_secret: aria2_secret.clone(), media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), - sleep_preventer: Arc::new(Mutex::new(None)), + power_manager: Arc::clone(&power_manager), scheduler_settings: Arc::clone(&scheduler_settings), queue_manager, }); + if let Err(error) = power_manager.activate() { + log::error!("power: failed to activate backend power management: {error}"); + } + // Build the window only after all command state is registered. This // prevents the frontend from racing startup and invoking IPC before // the database and portable storage layout are available. @@ -9464,6 +9474,7 @@ pub fn run() { tauri::async_runtime::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); let mut observations: HashMap = HashMap::new(); + let mut missing_gid_recovery_at: HashMap = HashMap::new(); loop { interval.tick().await; // Terminal cleanup removes a download's GID mapping. Do @@ -9475,6 +9486,7 @@ pub fn run() { .map(|(_, id)| id) .collect(); observations.retain(|id, _| mapped_ids.contains(id)); + missing_gid_recovery_at.retain(|id, _| mapped_ids.contains(id)); let params = serde_json::json!([["gid", "status", "totalLength", "completedLength", "downloadSpeed", "connections", "errorMessage"]]); if let Ok(active_list) = rpc_call(poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellActive", params).await { if let Some(active_arr) = active_list.as_array() { @@ -9612,6 +9624,49 @@ pub fn run() { gid, error ); + let recovery_allowed = missing_gid_recovery_at + .get(&id) + .is_none_or(|last_attempt| { + last_attempt.elapsed() + >= Duration::from_secs(5) + }); + if crate::aria2_gid_not_found(&error) + && recovery_allowed + && poll_mgr.has_active_permit(&id).await + { + if let Some(mapping) = poll_mgr + .aria2_gid_mapping(&gid) + .filter(|mapping| mapping.id == id) + { + missing_gid_recovery_at + .insert(id.clone(), Instant::now()); + log::warn!( + "aria2 poller reconciliation [{}]: mapped gid {} is missing; attempting payload recovery", + id, + gid + ); + match poll_mgr + .refresh_aria2_connections( + &id, + &gid, + mapping.epoch, + ) + .await + { + Ok(()) => { + missing_gid_recovery_at.remove(&id); + } + Err(recovery_error) => { + log::warn!( + "aria2 poller reconciliation [{}]: payload recovery for missing gid {} failed: {}", + id, + gid, + recovery_error + ); + } + } + } + } continue; } }; @@ -9726,7 +9781,7 @@ pub fn run() { 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, pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata, - begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action, + begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action, ack_schedule_trigger, check_automation_permission, request_automation_permission, open_automation_settings, set_keychain_password, get_keychain_password, delete_keychain_password, diff --git a/src-tauri/src/power.rs b/src-tauri/src/power.rs new file mode 100644 index 0000000..53775c5 --- /dev/null +++ b/src-tauri/src/power.rs @@ -0,0 +1,419 @@ +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +const ASSERTION_REASON: &str = "Firelink active download"; + +/// Owns the OS power assertions for the whole backend transfer lifecycle. +/// +/// The frontend may request the policy, but it does not own the assertion: +/// queue permits are the source of truth for whether a transfer is actually +/// active. Keeping the two assertions separate also means display sleep can +/// be opted into without changing the system-sleep policy. +struct PowerState { + runtime_enabled: AtomicBool, + prevent_system_sleep: AtomicBool, + prevent_display_sleep: AtomicBool, + active_transfers: AtomicUsize, +} + +enum PowerCommand { + Reconcile(Option>>), +} + +struct PowerWorker { + sender: Option>, + join_handle: Option>, +} + +impl PowerWorker { + fn start(state: Arc) -> Result { + let (sender, receiver) = mpsc::channel(); + let join_handle = thread::Builder::new() + .name("firelink-power".to_string()) + .spawn(move || run_power_worker(state, receiver)) + .map_err(|error| format!("failed to start power-management worker: {error}"))?; + Ok(Self { + sender: Some(sender), + join_handle: Some(join_handle), + }) + } + + fn send(&self, command: PowerCommand) -> Result<(), String> { + self.sender + .as_ref() + .ok_or_else(|| "power-management worker is stopping".to_string())? + .send(command) + .map_err(|_| "power-management worker stopped unexpectedly".to_string()) + } +} + +impl Drop for PowerWorker { + fn drop(&mut self) { + // Closing the sender lets the worker drop its OS assertions on the + // same thread that created them. This matters on Windows, where + // SetThreadExecutionState is thread-local, and avoids calling the + // Linux keepawake D-Bus destructor from an arbitrary queue thread. + self.sender.take(); + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); + } + } +} + +pub struct PowerManager { + state: Arc, + worker: Mutex>, +} + +impl PowerManager { + pub fn new() -> Arc { + Arc::new(Self { + state: Arc::new(PowerState { + runtime_enabled: AtomicBool::new(false), + prevent_system_sleep: AtomicBool::new(true), + prevent_display_sleep: AtomicBool::new(false), + active_transfers: AtomicUsize::new(0), + }), + worker: Mutex::new(None), + }) + } + + /// Enables OS integration after AppState has been registered. This keeps + /// QueueManager tests and pre-window startup work from creating real + /// assertions in the test process or during partial initialization. + pub fn activate(&self) -> Result<(), String> { + self.state.runtime_enabled.store(true, Ordering::Release); + self.reconcile() + } + + pub fn set_preferences( + &self, + prevent_system_sleep: bool, + prevent_display_sleep: bool, + ) -> Result<(), String> { + self.state + .prevent_system_sleep + .store(prevent_system_sleep, Ordering::Release); + self.state + .prevent_display_sleep + .store(prevent_display_sleep, Ordering::Release); + self.reconcile() + } + + pub fn set_system_prevention(&self, enabled: bool) -> Result<(), String> { + self.state + .prevent_system_sleep + .store(enabled, Ordering::Release); + self.reconcile() + } + + pub fn set_display_prevention(&self, enabled: bool) -> Result<(), String> { + self.state + .prevent_display_sleep + .store(enabled, Ordering::Release); + self.reconcile() + } + + pub fn set_active_transfer_count(self: &Arc, count: usize) { + self.state.active_transfers.store(count, Ordering::Release); + if self.state.runtime_enabled.load(Ordering::Acquire) { + if let Err(error) = self.enqueue_reconcile() { + log::error!( + "power: failed to schedule assertion reconciliation for {count} active transfer(s): {error}" + ); + } + } + } + + pub fn active_transfer_count(&self) -> usize { + self.state.active_transfers.load(Ordering::Acquire) + } + + fn reconcile(&self) -> Result<(), String> { + if !self.state.runtime_enabled.load(Ordering::Acquire) { + return Ok(()); + } + + self.ensure_worker()?; + let (response_sender, response_receiver) = mpsc::channel(); + if let Err(error) = self.send_command(PowerCommand::Reconcile(Some(response_sender))) { + self.restart_worker(); + return Err(error); + } + match response_receiver.recv() { + Ok(result) => result, + Err(_) => { + self.restart_worker(); + Err("power-management worker stopped before reporting reconciliation".to_string()) + } + } + } + + fn enqueue_reconcile(&self) -> Result<(), String> { + self.ensure_worker()?; + if let Err(error) = self.send_command(PowerCommand::Reconcile(None)) { + self.restart_worker(); + return Err(error); + } + Ok(()) + } + + fn ensure_worker(&self) -> Result<(), String> { + let mut worker = self + .worker + .lock() + .unwrap_or_else(|error| error.into_inner()); + if worker.is_none() { + *worker = Some(PowerWorker::start(Arc::clone(&self.state))?); + } + Ok(()) + } + + fn send_command(&self, command: PowerCommand) -> Result<(), String> { + let worker = self + .worker + .lock() + .unwrap_or_else(|error| error.into_inner()); + worker + .as_ref() + .ok_or_else(|| "power-management worker is not running".to_string())? + .send(command) + } + + fn restart_worker(&self) { + let mut worker = self + .worker + .lock() + .unwrap_or_else(|error| error.into_inner()); + worker.take(); + } +} + +struct OsPowerAssertions { + #[cfg(windows)] + assertion: Option, + #[cfg(not(windows))] + system: Option, + #[cfg(not(windows))] + display: Option, + system_enabled: bool, + display_enabled: bool, +} + +impl OsPowerAssertions { + fn reconcile(&mut self, state: &PowerState) -> Result<(), String> { + let active = state.active_transfers.load(Ordering::Acquire); + let desired_system = active > 0 && state.prevent_system_sleep.load(Ordering::Acquire); + let desired_display = active > 0 && state.prevent_display_sleep.load(Ordering::Acquire); + + #[cfg(windows)] + { + return self.reconcile_combined(desired_system, desired_display, active); + } + #[cfg(not(windows))] + self.reconcile_independent(desired_system, desired_display, active) + } + + #[cfg(windows)] + fn reconcile_combined( + &mut self, + desired_system: bool, + desired_display: bool, + active: usize, + ) -> Result<(), String> { + if self.system_enabled == desired_system && self.display_enabled == desired_display { + return Ok(()); + } + + if let Some(assertion) = self.assertion.take() { + drop_assertion(assertion, "combined"); + if self.system_enabled { + log::info!("power: system-sleep prevention released"); + } + if self.display_enabled { + log::info!("power: display-sleep prevention released"); + } + self.system_enabled = false; + self.display_enabled = false; + } + + if desired_system || desired_display { + let assertion = create_assertion(|| { + keepawake::Builder::default() + .display(desired_display) + .idle(desired_system) + .sleep(desired_system) + .reason(ASSERTION_REASON) + .create() + }) + .map_err(|error| format!("failed to apply power prevention: {error}"))?; + self.assertion = Some(assertion); + self.system_enabled = desired_system; + self.display_enabled = desired_display; + if desired_system { + log::info!( + "power: system-sleep prevention enabled for {active} active transfer(s)" + ); + } + if desired_display { + log::info!( + "power: display-sleep prevention enabled for {active} active transfer(s)" + ); + } + } + Ok(()) + } + + #[cfg(not(windows))] + fn reconcile_independent( + &mut self, + desired_system: bool, + desired_display: bool, + active: usize, + ) -> Result<(), String> { + let mut first_error = None; + if desired_system { + if self.system.is_none() { + match create_assertion(|| { + keepawake::Builder::default() + .idle(true) + .sleep(true) + .reason(ASSERTION_REASON) + .create() + }) { + Ok(assertion) => { + self.system = Some(assertion); + self.system_enabled = true; + log::info!( + "power: system-sleep prevention enabled for {active} active transfer(s)" + ); + } + Err(error) => { + first_error = Some(format!("failed to prevent system sleep: {error}")) + } + } + } + } else if let Some(assertion) = self.system.take() { + drop_assertion(assertion, "system-sleep"); + self.system_enabled = false; + log::info!("power: system-sleep prevention released"); + } + + if desired_display { + if self.display.is_none() { + match create_assertion(|| { + keepawake::Builder::default() + .display(true) + .reason(ASSERTION_REASON) + .create() + }) { + Ok(assertion) => { + self.display = Some(assertion); + self.display_enabled = true; + log::info!( + "power: display-sleep prevention enabled for {active} active transfer(s)" + ); + } + Err(error) => { + let display_error = format!("failed to prevent display sleep: {error}"); + if first_error.is_none() { + first_error = Some(display_error); + } else { + log::error!("power: {display_error}"); + } + } + } + } + } else if let Some(assertion) = self.display.take() { + drop_assertion(assertion, "display-sleep"); + self.display_enabled = false; + log::info!("power: display-sleep prevention released"); + } + + first_error.map_or(Ok(()), Err) + } +} + +fn create_assertion( + create: impl FnOnce() -> keepawake::Result, +) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(create)) + .map_err(|_| "keepawake assertion creation panicked".to_string())? + .map_err(|error| error.to_string()) +} + +fn drop_assertion(assertion: keepawake::KeepAwake, kind: &str) { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| drop(assertion))).is_err() { + log::error!("power: keepawake {kind} assertion cleanup panicked"); + } +} + +fn run_power_worker(state: Arc, receiver: Receiver) { + let mut assertions = OsPowerAssertions { + #[cfg(windows)] + assertion: None, + #[cfg(not(windows))] + system: None, + #[cfg(not(windows))] + display: None, + system_enabled: false, + display_enabled: false, + }; + while let Ok(PowerCommand::Reconcile(response)) = receiver.recv() { + let result = assertions.reconcile(&state); + if let Some(response) = response { + let _ = response.send(result); + } else if let Err(error) = result { + let active = state.active_transfers.load(Ordering::Acquire); + log::error!( + "power: failed to reconcile assertions for {active} active transfer(s): {error}" + ); + } + } + #[cfg(windows)] + if let Some(assertion) = assertions.assertion.take() { + drop_assertion(assertion, "combined"); + } + #[cfg(not(windows))] + { + if let Some(assertion) = assertions.display.take() { + drop_assertion(assertion, "display-sleep"); + } + if let Some(assertion) = assertions.system.take() { + drop_assertion(assertion, "system-sleep"); + } + } +} + +#[cfg(test)] +mod tests { + use super::PowerManager; + + #[test] + fn tracks_transfer_activity_before_runtime_activation() { + let manager = PowerManager::new(); + manager.set_active_transfer_count(2); + assert_eq!(manager.active_transfer_count(), 2); + + manager.set_active_transfer_count(0); + assert_eq!(manager.active_transfer_count(), 0); + } + + #[test] + fn preference_changes_are_safe_before_runtime_activation() { + let manager = PowerManager::new(); + assert!(manager.set_preferences(false, true).is_ok()); + assert!(manager.set_system_prevention(true).is_ok()); + assert!(manager.set_display_prevention(false).is_ok()); + } + + #[test] + fn runtime_worker_can_activate_without_active_transfers() { + let manager = PowerManager::new(); + assert!(manager.activate().is_ok()); + manager.set_active_transfer_count(0); + assert_eq!(manager.active_transfer_count(), 0); + } +} diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 23d0851..243d4a5 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -1,4 +1,5 @@ use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection}; +use crate::power::PowerManager; use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES}; use log; use serde::Deserialize; @@ -8,6 +9,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; use tauri::{AppHandle, Manager}; use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore}; use ts_rs::TS; @@ -102,6 +104,20 @@ pub enum Aria2RefreshOutcome { Complete, } +/// Result of rebuilding an aria2 job while retaining its partial file and +/// queue permit. `Refresh` is the compatibility path for test/alternate +/// spawners that do not own aria2's addUri options. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Aria2RecreateOutcome { + NewGid(String), + Complete, + Refresh, + /// The old daemon job is gone and the replacement could not be created. + /// The queue manager must retire the stale mapping and surface a paused + /// lifecycle so the next user resume can enqueue a fresh job. + Unavailable(String), +} + /// What kind of sidecar a queued task spawns. Drives which runner the /// dispatcher invokes. #[derive(Debug, Clone)] @@ -173,6 +189,19 @@ pub trait SidecarSpawner: Send + Sync + 'static { Err("aria2 connection refresh is unavailable".to_string()) } + /// Rebuild an active aria2 job with the current payload/options. The + /// partial file remains resumable because the production addUri path uses + /// continue + always-resume. Alternate spawners may return `Refresh` to + /// retain the older same-GID pause/unpause behavior. + async fn recreate_uri( + &self, + _id: &str, + _gid: &str, + _payload: &SpawnPayload, + ) -> Result { + Ok(Aria2RecreateOutcome::Refresh) + } + /// Change one active aria2 transfer's runtime download cap. Media /// runners intentionally keep the default implementation: yt-dlp reads /// its limit only when the process starts. @@ -267,6 +296,9 @@ pub struct QueueManager { /// resume tasks capture this and abort when a later pause/remove wins. aria2_control_epochs: Mutex>, + /// Backend-owned power policy and active-transfer accounting. + power_manager: Arc, + spawner: Arc, app_handle: AppHandle, } @@ -316,11 +348,39 @@ impl QueueManager { aria2_control_locks: Arc::new(StdMutex::new(HashMap::new())), aria2_gid_state: Mutex::new(()), aria2_control_epochs: Mutex::new(HashMap::new()), + power_manager: PowerManager::new(), spawner, app_handle, } } + pub fn power_manager(&self) -> Arc { + Arc::clone(&self.power_manager) + } + + pub fn activate_power_management(&self) -> Result<(), String> { + self.power_manager.activate() + } + + pub fn set_power_preferences( + &self, + prevent_system_sleep: bool, + prevent_display_sleep: bool, + ) -> Result<(), String> { + self.power_manager + .set_preferences(prevent_system_sleep, prevent_display_sleep) + } + + pub fn set_system_sleep_prevention(&self, enabled: bool) -> Result<(), String> { + self.power_manager.set_system_prevention(enabled) + } + + async fn sync_power_activity(&self) { + let active_transfers = self.active_permits.lock().await.len(); + self.power_manager + .set_active_transfer_count(active_transfers); + } + /// Current pending order, as id list. Returned by move_in_queue. pub async fn pending_order(&self, queue_id: Option<&str>) -> Vec { self.pending @@ -848,6 +908,7 @@ impl QueueManager { .lock() .await .insert(id.to_string(), lifecycle_generation); + self.sync_power_activity().await; true } @@ -935,6 +996,7 @@ impl QueueManager { active: true, }, ); + self.sync_power_activity().await; } /// Park a candidate only when no newer lifecycle has already claimed the @@ -1014,6 +1076,7 @@ impl QueueManager { .lock() .await .insert(id.to_string(), TaskKind::Aria2); + self.sync_power_activity().await; true } @@ -1081,6 +1144,10 @@ impl QueueManager { if removed || queue_removed { self.notify.notify_waiters(); } + drop(_admission_gate); + if removed { + self.sync_power_activity().await; + } } async fn release_permit_for_generation(&self, id: &str, generation: u64) { @@ -1108,6 +1175,8 @@ impl QueueManager { if removed { self.active_kinds.lock().await.remove(id); self.notify.notify_waiters(); + drop(_admission_gate); + self.sync_power_activity().await; } } @@ -1550,6 +1619,42 @@ impl QueueManager { self.ignore_aria2_gid_locked(gid).await; } + async fn remove_stale_aria2_gid(&self, id: &str, gid: &str) { + const MAX_ATTEMPTS: usize = 3; + for attempt in 1..=MAX_ATTEMPTS { + match self.spawner.remove_uri(gid).await { + Ok(()) => { + log::info!( + "aria2 lifecycle cleanup [{}]: removed stale replacement gid {} on attempt {}", + id, + gid, + attempt + ); + return; + } + Err(error) if attempt < MAX_ATTEMPTS => { + log::warn!( + "aria2 lifecycle cleanup [{}]: failed to remove stale replacement gid {} on attempt {}: {}; retrying", + id, + gid, + attempt, + error + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(error) => { + log::error!( + "aria2 lifecycle cleanup [{}]: stale replacement gid {} could not be removed after {} attempts: {}", + id, + gid, + MAX_ATTEMPTS, + error + ); + } + } + } + } + async fn ignore_aria2_gid_locked(&self, gid: &str) { const MAX_IGNORED_GIDS: usize = 1024; let mut ignored = self.aria2_ignored_gids.lock().await; @@ -1613,7 +1718,7 @@ impl QueueManager { /// persistent connection-pool collapse or a true zero-progress stall. /// The observed epoch must still own the GID before the refresh can act. pub async fn refresh_aria2_connections( - &self, + self: &Arc, id: &str, gid: &str, observed_epoch: u64, @@ -1621,13 +1726,90 @@ impl QueueManager { let _control_guard = self.acquire_aria2_control(id).await; if self.aria2_gid_for_download(id).as_deref() != Some(gid) || !self.is_registered(id).await + || !self.has_active_permit(id).await || self.is_aria2_retry_cancelled(id).await || !self.is_aria2_control_epoch_current(id, observed_epoch).await { return Ok(()); } - let outcome = self.spawner.refresh_uri(gid).await?; + let payload = self.aria2_payloads.lock().await.get(id).cloned(); + let recreation = if let Some(payload) = payload.as_ref() { + self.spawner.recreate_uri(id, gid, payload).await? + } else { + // Older persisted rows may briefly reach recovery before their + // payload has been rebuilt. Keep the current lifecycle intact and + // use the non-destructive fallback until the payload is present. + Aria2RecreateOutcome::Refresh + }; + + if let Aria2RecreateOutcome::NewGid(new_gid) = recreation { + if new_gid.trim().is_empty() || new_gid == gid { + return Err(format!( + "aria2 connection recovery returned an invalid replacement gid for {gid}" + )); + } + + let still_current = self.is_registered(id).await + && !self.is_aria2_retry_cancelled(id).await + && self.is_aria2_control_epoch_current(id, observed_epoch).await + && self.aria2_gid_for_download(id).as_deref() == Some(gid); + if !still_current { + self.ignore_aria2_gid(&new_gid).await; + drop(_control_guard); + self.remove_stale_aria2_gid(id, &new_gid).await; + log::info!( + "aria2 connection recovery [{}]: replacement gid {} became stale before rebind", + id, + new_gid + ); + return Ok(()); + } + + let buffered_outcome = self.remember_gid(id.to_string(), new_gid.clone()).await; + log::info!( + "aria2 connection recovery [{}]: recreated gid {} from {} while retaining the lifecycle permit", + id, + new_gid, + gid + ); + drop(_control_guard); + if let Some(outcome) = buffered_outcome { + self.handle_aria2_event(&new_gid, outcome).await; + } + return Ok(()); + } + + let outcome = match recreation { + Aria2RecreateOutcome::Complete => Aria2RefreshOutcome::Complete, + Aria2RecreateOutcome::Refresh => self.spawner.refresh_uri(gid).await?, + Aria2RecreateOutcome::Unavailable(error) => { + let still_current = self.is_registered(id).await + && self.has_active_permit(id).await + && !self.is_aria2_retry_cancelled(id).await + && self.is_aria2_control_epoch_current(id, observed_epoch).await + && self.aria2_gid_for_download(id).as_deref() == Some(gid); + if !still_current { + return Ok(()); + } + + self.next_aria2_control_epoch(id).await; + self.cancel_aria2_retries(id).await; + self.clear_aria2_retry_state(id).await; + self.forget_aria2_gid(id).await; + self.release_registered_id(id).await; + self.release_permit(id).await; + self.emit_state(id, DownloadStatus::Paused); + log::warn!( + "aria2 connection recovery [{}]: replacement job unavailable; retired stale gid {} and paused the download: {}", + id, + gid, + error + ); + return Ok(()); + } + Aria2RecreateOutcome::NewGid(_) => unreachable!("replacement gid handled above"), + }; let still_current = self.is_registered(id).await && !self.is_aria2_retry_cancelled(id).await @@ -2595,6 +2777,116 @@ impl SidecarSpawner for ProductionSpawner { } } + async fn recreate_uri( + &self, + id: &str, + gid: &str, + payload: &SpawnPayload, + ) -> Result { + let state = self.app_handle.state::(); + let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); + let secret = &state.aria2_secret; + + let status = match crate::aria2_download_status(port, secret, gid).await { + Ok(status) => Some(status), + Err(error) if crate::aria2_gid_not_found(&error) => { + log::warn!( + "aria2 connection recovery [{}]: gid {} is already absent; rebuilding from the saved payload", + id, + gid + ); + None + } + Err(error) => return Err(error), + }; + + if let Some(status) = status { + match status.as_str() { + "complete" => return Ok(Aria2RecreateOutcome::Complete), + "active" | "waiting" => { + let pause_result = crate::rpc_call( + port, + secret, + "aria2.forcePause", + serde_json::json!([gid]), + ) + .await; + if let Err(error) = pause_result { + match crate::aria2_download_status(port, secret, gid).await { + Ok(status) if status == "paused" => { + log::warn!( + "aria2 connection recovery [{}]: forcePause for gid {} returned an error after the daemon paused it: {}", + id, + gid, + error + ); + } + Ok(status) if status == "complete" => { + return Ok(Aria2RecreateOutcome::Complete); + } + Ok(status) => { + return Err(format!( + "failed to pause aria2 gid {gid} before recreation: {error}; daemon reports {status}" + )); + } + Err(status_error) => { + return Err(format!( + "failed to pause aria2 gid {gid} before recreation: {error}; status verification failed: {status_error}" + )); + } + } + } + } + "paused" => {} + "removed" => {} + other => { + return Err(format!( + "cannot recreate aria2 gid {gid} from daemon state {other}" + )); + } + } + } + + let remove_result = crate::rpc_call( + port, + secret, + "aria2.forceRemove", + serde_json::json!([gid]), + ) + .await; + let remove_error = match remove_result { + Ok(result) => crate::ensure_aria2_gid_result("forceRemove", gid, &result) + .err() + .map(|error| error.to_string()), + Err(error) if crate::aria2_gid_not_found(&error) => None, + Err(error) => Some(error.to_string()), + }; + if let Some(error) = remove_error { + match crate::aria2_download_status(port, secret, gid).await { + Ok(status) if status == "complete" => { + return Ok(Aria2RecreateOutcome::Complete); + } + Ok(status) if status == "removed" => {} + Ok(status) => { + return Err(format!( + "failed to remove aria2 gid {gid} before recreation: {error}; daemon reports {status}" + )); + } + Err(status_error) if crate::aria2_gid_not_found(&status_error) => {} + Err(status_error) => { + return Err(format!( + "failed to remove aria2 gid {gid} before recreation: {error}; status verification failed: {status_error}" + )); + } + } + } + + match self.add_uri(id, payload).await { + Ok(new_gid) => Ok(Aria2RecreateOutcome::NewGid(new_gid)), + Err(error) => Ok(Aria2RecreateOutcome::Unavailable(error)), + } + } + async fn refresh_uri(&self, gid: &str) -> Result { let state = self.app_handle.state::(); let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index fcc5b0e..ec6a52c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -486,6 +486,7 @@ fn default_settings() -> PersistedSettings { ask_where_to_save_each_file: false, remember_last_used_download_directory: false, prevents_sleep_while_downloading: true, + prevents_display_sleep_while_downloading: false, media_cookie_source: MediaCookieSource::default(), site_logins: Vec::new(), auto_check_updates: true, diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index c505ec8..d43ee05 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -1,6 +1,6 @@ use firelink_lib::queue::{ - Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind, - MEDIA_RUN_CANCELLED, + Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, + SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED, }; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -44,6 +44,34 @@ struct RefreshOutcomeSpawner { refresh_calls: AtomicUsize, } +struct RecreateSpawner { + recreate_calls: AtomicUsize, + remove_calls: AtomicUsize, + recreate_started: tokio::sync::Notify, + release_recreate: tokio::sync::Notify, + block_recreate: std::sync::atomic::AtomicBool, + outcome: std::sync::Mutex, +} + +impl RecreateSpawner { + fn new(block_recreate: bool) -> Self { + Self { + recreate_calls: AtomicUsize::new(0), + remove_calls: AtomicUsize::new(0), + recreate_started: tokio::sync::Notify::new(), + release_recreate: tokio::sync::Notify::new(), + block_recreate: std::sync::atomic::AtomicBool::new(block_recreate), + outcome: std::sync::Mutex::new(Aria2RecreateOutcome::NewGid( + "gid-recreate-new".to_string(), + )), + } + } + + fn set_outcome(&self, outcome: Aria2RecreateOutcome) { + *self.outcome.lock().unwrap() = outcome; + } +} + impl FailFirstAria2Spawner { fn new() -> Self { Self { @@ -193,6 +221,42 @@ impl SidecarSpawner for RefreshOutcomeSpawner { } } +#[async_trait::async_trait] +impl SidecarSpawner for RecreateSpawner { + async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result { + Ok("gid-recreate-old".to_string()) + } + + async fn remove_uri(&self, gid: &str) -> Result<(), String> { + assert_eq!(gid, "gid-recreate-new"); + self.remove_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + async fn recreate_uri( + &self, + _id: &str, + _gid: &str, + _payload: &SpawnPayload, + ) -> Result { + self.recreate_calls.fetch_add(1, Ordering::SeqCst); + self.recreate_started.notify_one(); + if self.block_recreate.load(Ordering::SeqCst) { + self.release_recreate.notified().await; + } + Ok(self.outcome.lock().unwrap().clone()) + } + + async fn run_media( + &self, + _id: &str, + _payload: &SpawnPayload, + _generation: u64, + ) -> Result<(), String> { + unreachable!("media is not used by recreation tests") + } +} + #[async_trait::async_trait] impl firelink_lib::queue::SidecarSpawner for CountingSpawner { async fn add_uri(&self, _id: &str, payload: &SpawnPayload) -> Result { @@ -340,6 +404,25 @@ async fn ensure_aria2_permit_does_not_double_acquire() { assert_eq!(mgr.available_permits(), 2); } +#[tokio::test] +async fn backend_power_activity_follows_active_permits_not_queue_reservations() { + let (mgr, _spawner) = make_manager(2); + assert_eq!(mgr.power_manager().active_transfer_count(), 0); + + let reservation = mgr + .reserve_enqueue_generation("queued", 1) + .await + .expect("queue reservation should succeed"); + assert_eq!(mgr.power_manager().active_transfer_count(), 0); + mgr.rollback_enqueue_reservation("queued", 1, reservation) + .await; + + assert!(mgr.ensure_aria2_permit("active").await); + assert_eq!(mgr.power_manager().active_transfer_count(), 1); + mgr.release_permit("active").await; + assert_eq!(mgr.power_manager().active_transfer_count(), 0); +} + #[tokio::test] async fn live_aria2_speed_limit_updates_the_current_gid_and_payload() { let (manager, spawner) = make_manager(1); @@ -1264,11 +1347,11 @@ async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_ outcome: Aria2RefreshOutcome::Paused, refresh_calls: AtomicUsize::new(0), }); - let manager = QueueManager::test_new( + let manager = Arc::new(QueueManager::test_new( app.handle().clone(), 1, Arc::clone(&spawner) as Arc, - ); + )); manager.push(aria2_task("refresh-paused")).await.unwrap(); assert!(manager.ensure_aria2_permit("refresh-paused").await); manager @@ -1291,6 +1374,159 @@ async fn failed_refresh_that_leaves_gid_paused_releases_permit_but_keeps_resume_ assert!(manager.is_registered("refresh-paused").await); } +#[tokio::test] +async fn connection_recovery_recreates_gid_without_releasing_transfer_permit() { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("mock app"); + let spawner = Arc::new(RecreateSpawner::new(false)); + let manager = Arc::new(QueueManager::test_new( + app.handle().clone(), + 1, + Arc::clone(&spawner) as Arc, + )); + manager.push(aria2_task("recreate")).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("recreate").as_deref() == Some("gid-recreate-old") { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("initial gid should be mapped"); + + let epoch = manager.current_aria2_control_epoch("recreate").await; + manager + .refresh_aria2_connections("recreate", "gid-recreate-old", epoch) + .await + .unwrap(); + + assert_eq!(spawner.recreate_calls.load(Ordering::SeqCst), 1); + assert_eq!( + manager.aria2_gid_for_download("recreate").as_deref(), + Some("gid-recreate-new") + ); + assert!(manager.has_active_permit("recreate").await); + assert_eq!(manager.available_permits(), 0); + + manager.release_permit("recreate").await; + dispatcher.abort(); +} + +#[tokio::test] +async fn stale_recreated_gid_is_removed_without_rebinding_or_leaking_permit() { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("mock app"); + let spawner = Arc::new(RecreateSpawner::new(true)); + let manager = Arc::new(QueueManager::test_new( + app.handle().clone(), + 1, + Arc::clone(&spawner) as Arc, + )); + manager.push(aria2_task("recreate-stale")).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("recreate-stale").as_deref() + == Some("gid-recreate-old") + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("initial gid should be mapped"); + + let epoch = manager.current_aria2_control_epoch("recreate-stale").await; + let refresh = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { + manager + .refresh_aria2_connections("recreate-stale", "gid-recreate-old", epoch) + .await + }) + }; + timeout(Duration::from_secs(1), spawner.recreate_started.notified()) + .await + .expect("recreation should reach its async boundary"); + manager.next_aria2_control_epoch("recreate-stale").await; + spawner.release_recreate.notify_one(); + refresh.await.unwrap().unwrap(); + + assert_eq!(spawner.remove_calls.load(Ordering::SeqCst), 1); + assert_eq!( + manager.aria2_gid_for_download("recreate-stale").as_deref(), + Some("gid-recreate-old") + ); + assert!(manager.has_active_permit("recreate-stale").await); + assert_eq!(manager.available_permits(), 0); + + manager.release_permit("recreate-stale").await; + dispatcher.abort(); +} + +#[tokio::test] +async fn unavailable_recreated_gid_retires_stale_lifecycle_for_manual_resume() { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("mock app"); + let spawner = Arc::new(RecreateSpawner::new(false)); + spawner.set_outcome(Aria2RecreateOutcome::Unavailable( + "aria2.addUri unavailable".to_string(), + )); + let manager = Arc::new(QueueManager::test_new( + app.handle().clone(), + 1, + Arc::clone(&spawner) as Arc, + )); + manager.push(aria2_task("recreate-unavailable")).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("recreate-unavailable") + .as_deref() + == Some("gid-recreate-old") + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("initial gid should be mapped"); + + let epoch = manager.current_aria2_control_epoch("recreate-unavailable").await; + manager + .refresh_aria2_connections("recreate-unavailable", "gid-recreate-old", epoch) + .await + .unwrap(); + + assert_eq!(manager.aria2_gid_for_download("recreate-unavailable"), None); + assert!(!manager.has_active_permit("recreate-unavailable").await); + assert!(!manager.is_registered("recreate-unavailable").await); + assert_eq!(manager.available_permits(), 1); + + dispatcher.abort(); +} + #[tokio::test] async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() { let app = mock_builder() @@ -1300,11 +1536,11 @@ async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() { outcome: Aria2RefreshOutcome::Resumed, refresh_calls: AtomicUsize::new(0), }); - let manager = QueueManager::test_new( + let manager = Arc::new(QueueManager::test_new( app.handle().clone(), 1, Arc::clone(&spawner) as Arc, - ); + )); manager.push(aria2_task("refresh-stale")).await.unwrap(); assert!(manager.ensure_aria2_permit("refresh-stale").await); manager diff --git a/src/App.tsx b/src/App.tsx index 25b1e0f..5b8d12f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -101,6 +101,7 @@ const PageLoadingFallback = () => { let automaticUpdateCheckStarted = false; const processingScheduleKeys = new Set(); +let powerPreferencesSync: Promise = Promise.resolve(); const waitForSettingsHydration = (): Promise => { if (useSettingsStore.persist.hasHydrated()) return Promise.resolve(); @@ -245,6 +246,9 @@ function App() { >>([]); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); + 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'); @@ -716,17 +720,34 @@ function App() { }, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]); useEffect(() => { - invoke('set_prevent_sleep', { - prevent: preventsSleepWhileDownloading && activeTransferCount > 0 - }).catch(error => { - console.error('Failed to update sleep prevention:', error); - addToast({ - message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }), - variant: 'error', - isActionable: true - }); - }); - }, [addToast, preventsSleepWhileDownloading, activeTransferCount]); + const sync = () => { + powerPreferencesSync = powerPreferencesSync + .catch(() => undefined) + .then(() => invoke('set_power_preferences', { + preventSystemSleep: preventsSleepWhileDownloading, + preventDisplaySleep: preventsDisplaySleepWhileDownloading + })) + .catch(error => { + console.error('Failed to update power prevention:', error); + addToast({ + message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }), + variant: 'error', + isActionable: true + }); + }); + }; + + if (useSettingsStore.persist.hasHydrated()) { + sync(); + return; + } + return useSettingsStore.persist.onFinishHydration(sync); + }, [ + addToast, + preventsDisplaySleepWhileDownloading, + preventsSleepWhileDownloading, + t + ]); useEffect(() => { invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error); diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 11fa5a9..7f0ecd3 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -11,4 +11,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, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: 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, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 9953f54..a5b3ef3 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -1475,6 +1475,19 @@ runEngineChecks(false);

{t($ => $.settings.power.preventSleepDescription)}

+ + )} diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 40c7818..3cf3ccd 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -738,6 +738,8 @@ const common = { title: 'Power Management', preventSleep: 'Prevent system sleep while downloads are active', preventSleepDescription: 'The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.', + preventDisplaySleep: 'Keep the display awake while downloads are active', + preventDisplaySleepDescription: 'Optional and separate from system-sleep prevention. The display will remain on until active transfers finish.', }, engine: { title: 'Media Downloader & Engines', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 5aea08b..d2d5857 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -738,6 +738,8 @@ const fa = { title: 'مدیریت انرژی', preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند', preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.', + preventDisplaySleep: 'روشن نگه داشتن نمایشگر هنگام فعال بودن دانلودها', + preventDisplaySleepDescription: 'این گزینه جدا از جلوگیری از خواب سیستم است و اختیاری است. نمایشگر تا پایان انتقال‌های فعال روشن می‌ماند.', }, engine: { title: 'دانلودکننده رسانه و موتورها', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index fa4ed05..1ef4afe 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -738,6 +738,8 @@ const he = { title: 'ניהול צריכת חשמל', preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות', preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.', + preventDisplaySleep: 'השאר את התצוגה פעילה בזמן שהורדות פעילות', + preventDisplaySleepDescription: 'אפשרות זו נפרדת ממניעת שינת המערכת. התצוגה תישאר פעילה עד לסיום ההעברות הפעילות.', }, engine: { title: 'מוריד מדיה ומנועים', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 8e7d27a..9902ae8 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -738,6 +738,8 @@ const ru = { title: 'Управление питанием', preventSleep: 'Запрещать спящий режим системы во время активных загрузок', preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.', + preventDisplaySleep: 'Не выключать экран во время активных загрузок', + preventDisplaySleepDescription: 'Это отдельная необязательная настройка, не связанная с запретом сна системы. Экран останется включённым до завершения передач.', }, engine: { title: 'Загрузчик медиа и движки', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index d308be4..fbbcdff 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -738,6 +738,8 @@ const uk = { title: 'Керування живленням', preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень', preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.', + preventDisplaySleep: 'Не вимикати дисплей під час активних завантажень', + preventDisplaySleepDescription: 'Це окреме необов’язкове налаштування, незалежне від запобігання сну системи. Дисплей залишатиметься ввімкненим до завершення передач.', }, engine: { title: 'Завантажувач медіа та рушії', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index a598ef6..08998c0 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -738,6 +738,8 @@ const zhCN = { title: '电源管理', preventSleep: '下载活动时阻止系统睡眠', preventSleepDescription: '显示器可能仍会关闭。Firelink 仅保持设备唤醒足以完成活动的传输。', + preventDisplaySleep: '下载活动时保持显示器唤醒', + preventDisplaySleepDescription: '这是与阻止系统睡眠分开的可选设置。活动传输完成前,显示器将保持开启。', }, engine: { title: '媒体下载器和引擎', diff --git a/src/ipc.ts b/src/ipc.ts index 1397cf7..5400e88 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -46,6 +46,10 @@ type CommandMap = { get_platform_info: { args: undefined; result: PlatformInfo }; approve_download_root: { args: { path: string }; result: string }; set_prevent_sleep: { args: { prevent: boolean }; result: void }; + set_power_preferences: { + args: { preventSystemSleep: boolean; preventDisplaySleep: boolean }; + result: void; + }; perform_system_action: { args: { action: PostQueueAction }; result: void }; ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void }; set_concurrent_limit: { args: { limit: number }; result: void }; diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 8bcec3c..66da803 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -237,6 +237,7 @@ export interface SettingsState { customUserAgent: string; askWhereToSaveEachFile: boolean; preventsSleepWhileDownloading: boolean; + preventsDisplaySleepWhileDownloading: boolean; mediaCookieSource: MediaCookieSource; siteLogins: SiteLogin[]; extensionPairingToken: string; @@ -286,6 +287,7 @@ export interface SettingsState { setCustomUserAgent: (userAgent: string) => void; setAskWhereToSaveEachFile: (ask: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void; + setPreventsDisplaySleepWhileDownloading: (prevent: boolean) => void; setMediaCookieSource: (source: MediaCookieSource) => void; setRememberLastUsedDownloadDirectory: (enabled: boolean) => void; setLastUsedDownloadDirectory: (path: string) => void; @@ -361,6 +363,7 @@ export const useSettingsStore = create()( customUserAgent: '', askWhereToSaveEachFile: false, preventsSleepWhileDownloading: true, + preventsDisplaySleepWhileDownloading: false, mediaCookieSource: 'none', siteLogins: [], extensionPairingToken: '', @@ -457,6 +460,10 @@ export const useSettingsStore = create()( info('Settings updated: preventsSleepWhileDownloading'); set({ preventsSleepWhileDownloading }); }, + setPreventsDisplaySleepWhileDownloading: (preventsDisplaySleepWhileDownloading) => { + info('Settings updated: preventsDisplaySleepWhileDownloading'); + set({ preventsDisplaySleepWhileDownloading }); + }, setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); }, setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => { info('Settings updated: rememberLastUsedDownloadDirectory'); @@ -631,6 +638,7 @@ export const useSettingsStore = create()( customUserAgent: state.customUserAgent, askWhereToSaveEachFile: state.askWhereToSaveEachFile, preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, + preventsDisplaySleepWhileDownloading: state.preventsDisplaySleepWhileDownloading, mediaCookieSource: state.mediaCookieSource, siteLogins: state.siteLogins, keychainAccessGranted: state.keychainAccessGranted, @@ -704,6 +712,10 @@ export const useSettingsStore = create()( persisted.preventsSleepWhileDownloading, currentState.preventsSleepWhileDownloading ), + preventsDisplaySleepWhileDownloading: persistedBoolean( + persisted.preventsDisplaySleepWhileDownloading, + currentState.preventsDisplaySleepWhileDownloading + ), keychainAccessGranted: persistedBoolean( persisted.keychainAccessGranted, currentState.keychainAccessGranted