fix(downloads): harden power management and aria2 recovery

This commit is contained in:
NimBold
2026-07-26 03:04:50 +03:30
parent 931e1f2f1e
commit cf09bfa2e4
17 changed files with 1197 additions and 130 deletions
+2
View File
@@ -378,6 +378,8 @@ pub struct PersistedSettings {
pub ask_where_to_save_each_file: bool, pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool, pub remember_last_used_download_directory: bool,
pub prevents_sleep_while_downloading: bool, pub prevents_sleep_while_downloading: bool,
#[serde(default)]
pub prevents_display_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource, pub media_cookie_source: MediaCookieSource,
pub site_logins: Vec<SiteLogin>, pub site_logins: Vec<SiteLogin>,
pub auto_check_updates: bool, pub auto_check_updates: bool,
+165 -110
View File
@@ -2911,6 +2911,7 @@ pub mod error;
#[allow(dead_code)] #[allow(dead_code)]
pub mod ipc; pub mod ipc;
mod parity; mod parity;
mod power;
mod platform; mod platform;
pub mod queue; pub mod queue;
pub mod process; pub mod process;
@@ -2943,7 +2944,7 @@ pub struct AppState {
pub aria2_port: std::sync::Arc<std::sync::atomic::AtomicU16>, pub aria2_port: std::sync::Arc<std::sync::atomic::AtomicU16>,
pub aria2_secret: String, pub aria2_secret: String,
pub media_semaphore: Arc<tokio::sync::Semaphore>, pub media_semaphore: Arc<tokio::sync::Semaphore>,
pub sleep_preventer: Arc<Mutex<Option<SleepPreventer>>>, pub power_manager: Arc<power::PowerManager>,
pub scheduler_settings: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>, pub scheduler_settings: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
pub queue_manager: Arc<queue::QueueManager>, pub queue_manager: Arc<queue::QueueManager>,
} }
@@ -5150,6 +5151,33 @@ async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) {
gid, gid,
error 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; 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(); let lower = error.to_ascii_lowercase();
lower.contains("gid") && lower.contains("not found") 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")] #[tauri::command]
mod macos_sleep { fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> {
use std::ffi::c_void; state.power_manager.set_system_prevention(prevent)
#[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<SleepPreventer, String> {
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<SleepPreventer, String> {
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] #[tauri::command]
fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> { fn set_power_preferences(
let mut current_preventer = state state: tauri::State<'_, AppState>,
.sleep_preventer prevent_system_sleep: bool,
.lock() prevent_display_sleep: bool,
.unwrap_or_else(|e| e.into_inner()); ) -> Result<(), String> {
if prevent { state
if current_preventer.is_none() { .power_manager
*current_preventer = Some(create_sleep_preventer()?); .set_preferences(prevent_system_sleep, prevent_display_sleep)
}
} else {
*current_preventer = None;
}
Ok(())
} }
pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> { 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)?; crate::db::save_settings(&connection, &merged)?;
let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(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() { if let Ok(mut cached) = app_state.scheduler_settings.write() {
*cached = Some(decoded); *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(()) Ok(())
} }
@@ -7032,6 +6973,60 @@ mod tests {
assert_eq!(observation.healthy_speed_samples, 0); 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] #[test]
fn slow_recovery_requires_a_real_multi_connection_transfer() { fn slow_recovery_requires_a_real_multi_connection_transfer() {
let start = Instant::now(); let start = Instant::now();
@@ -8848,10 +8843,14 @@ fn observe_aria2_connections_with_epoch(
now, now,
} = sample; } = sample;
if observation.gid != gid || observation.control_epoch != control_epoch { 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 { *observation = Aria2ConnectionObservation {
gid: gid.to_string(), gid: gid.to_string(),
control_epoch, control_epoch,
last_completed: completed, last_completed: completed,
recovery_attempts: preserved_recovery_attempts,
..Default::default() ..Default::default()
}; };
} }
@@ -9173,6 +9172,13 @@ pub fn run() {
let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone())); let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone()));
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent)); 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 let initial_global_speed_limit = persisted_settings
.as_ref() .as_ref()
.and_then(|settings| normalize_speed_limit_for_aria2(&settings.global_speed_limit)); .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_port: aria2_port.clone(),
aria2_secret: aria2_secret.clone(), aria2_secret: aria2_secret.clone(),
media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), 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), scheduler_settings: Arc::clone(&scheduler_settings),
queue_manager, 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 // Build the window only after all command state is registered. This
// prevents the frontend from racing startup and invoking IPC before // prevents the frontend from racing startup and invoking IPC before
// the database and portable storage layout are available. // the database and portable storage layout are available.
@@ -9464,6 +9474,7 @@ pub fn run() {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000));
let mut observations: HashMap<String, Aria2ConnectionObservation> = HashMap::new(); let mut observations: HashMap<String, Aria2ConnectionObservation> = HashMap::new();
let mut missing_gid_recovery_at: HashMap<String, Instant> = HashMap::new();
loop { loop {
interval.tick().await; interval.tick().await;
// Terminal cleanup removes a download's GID mapping. Do // Terminal cleanup removes a download's GID mapping. Do
@@ -9475,6 +9486,7 @@ pub fn run() {
.map(|(_, id)| id) .map(|(_, id)| id)
.collect(); .collect();
observations.retain(|id, _| mapped_ids.contains(id)); 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"]]); 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 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() { if let Some(active_arr) = active_list.as_array() {
@@ -9612,6 +9624,49 @@ pub fn run() {
gid, gid,
error 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; continue;
} }
}; };
@@ -9726,7 +9781,7 @@ pub fn run() {
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status, 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, 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, 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, ack_schedule_trigger,
check_automation_permission, request_automation_permission, open_automation_settings, check_automation_permission, request_automation_permission, open_automation_settings,
set_keychain_password, get_keychain_password, delete_keychain_password, set_keychain_password, get_keychain_password, delete_keychain_password,
+419
View File
@@ -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<Sender<Result<(), String>>>),
}
struct PowerWorker {
sender: Option<Sender<PowerCommand>>,
join_handle: Option<JoinHandle<()>>,
}
impl PowerWorker {
fn start(state: Arc<PowerState>) -> Result<Self, String> {
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<PowerState>,
worker: Mutex<Option<PowerWorker>>,
}
impl PowerManager {
pub fn new() -> Arc<Self> {
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<Self>, 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<keepawake::KeepAwake>,
#[cfg(not(windows))]
system: Option<keepawake::KeepAwake>,
#[cfg(not(windows))]
display: Option<keepawake::KeepAwake>,
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<keepawake::KeepAwake>,
) -> Result<keepawake::KeepAwake, String> {
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<PowerState>, receiver: Receiver<PowerCommand>) {
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);
}
}
+294 -2
View File
@@ -1,4 +1,5 @@
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection}; use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
use crate::power::PowerManager;
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES}; use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
use log; use log;
use serde::Deserialize; use serde::Deserialize;
@@ -8,6 +9,7 @@ use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex as StdMutex}; use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore}; use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
use ts_rs::TS; use ts_rs::TS;
@@ -102,6 +104,20 @@ pub enum Aria2RefreshOutcome {
Complete, 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 /// What kind of sidecar a queued task spawns. Drives which runner the
/// dispatcher invokes. /// dispatcher invokes.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -173,6 +189,19 @@ pub trait SidecarSpawner: Send + Sync + 'static {
Err("aria2 connection refresh is unavailable".to_string()) 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<Aria2RecreateOutcome, String> {
Ok(Aria2RecreateOutcome::Refresh)
}
/// Change one active aria2 transfer's runtime download cap. Media /// Change one active aria2 transfer's runtime download cap. Media
/// runners intentionally keep the default implementation: yt-dlp reads /// runners intentionally keep the default implementation: yt-dlp reads
/// its limit only when the process starts. /// its limit only when the process starts.
@@ -267,6 +296,9 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
/// resume tasks capture this and abort when a later pause/remove wins. /// resume tasks capture this and abort when a later pause/remove wins.
aria2_control_epochs: Mutex<HashMap<String, u64>>, aria2_control_epochs: Mutex<HashMap<String, u64>>,
/// Backend-owned power policy and active-transfer accounting.
power_manager: Arc<PowerManager>,
spawner: Arc<dyn SidecarSpawner>, spawner: Arc<dyn SidecarSpawner>,
app_handle: AppHandle<R>, app_handle: AppHandle<R>,
} }
@@ -316,11 +348,39 @@ impl<R: tauri::Runtime> QueueManager<R> {
aria2_control_locks: Arc::new(StdMutex::new(HashMap::new())), aria2_control_locks: Arc::new(StdMutex::new(HashMap::new())),
aria2_gid_state: Mutex::new(()), aria2_gid_state: Mutex::new(()),
aria2_control_epochs: Mutex::new(HashMap::new()), aria2_control_epochs: Mutex::new(HashMap::new()),
power_manager: PowerManager::new(),
spawner, spawner,
app_handle, app_handle,
} }
} }
pub fn power_manager(&self) -> Arc<PowerManager> {
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. /// Current pending order, as id list. Returned by move_in_queue.
pub async fn pending_order(&self, queue_id: Option<&str>) -> Vec<String> { pub async fn pending_order(&self, queue_id: Option<&str>) -> Vec<String> {
self.pending self.pending
@@ -848,6 +908,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
.lock() .lock()
.await .await
.insert(id.to_string(), lifecycle_generation); .insert(id.to_string(), lifecycle_generation);
self.sync_power_activity().await;
true true
} }
@@ -935,6 +996,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
active: true, active: true,
}, },
); );
self.sync_power_activity().await;
} }
/// Park a candidate only when no newer lifecycle has already claimed the /// Park a candidate only when no newer lifecycle has already claimed the
@@ -1014,6 +1076,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
.lock() .lock()
.await .await
.insert(id.to_string(), TaskKind::Aria2); .insert(id.to_string(), TaskKind::Aria2);
self.sync_power_activity().await;
true true
} }
@@ -1081,6 +1144,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
if removed || queue_removed { if removed || queue_removed {
self.notify.notify_waiters(); 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) { async fn release_permit_for_generation(&self, id: &str, generation: u64) {
@@ -1108,6 +1175,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
if removed { if removed {
self.active_kinds.lock().await.remove(id); self.active_kinds.lock().await.remove(id);
self.notify.notify_waiters(); self.notify.notify_waiters();
drop(_admission_gate);
self.sync_power_activity().await;
} }
} }
@@ -1550,6 +1619,42 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.ignore_aria2_gid_locked(gid).await; 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) { async fn ignore_aria2_gid_locked(&self, gid: &str) {
const MAX_IGNORED_GIDS: usize = 1024; const MAX_IGNORED_GIDS: usize = 1024;
let mut ignored = self.aria2_ignored_gids.lock().await; let mut ignored = self.aria2_ignored_gids.lock().await;
@@ -1613,7 +1718,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// persistent connection-pool collapse or a true zero-progress stall. /// persistent connection-pool collapse or a true zero-progress stall.
/// The observed epoch must still own the GID before the refresh can act. /// The observed epoch must still own the GID before the refresh can act.
pub async fn refresh_aria2_connections( pub async fn refresh_aria2_connections(
&self, self: &Arc<Self>,
id: &str, id: &str,
gid: &str, gid: &str,
observed_epoch: u64, observed_epoch: u64,
@@ -1621,13 +1726,90 @@ impl<R: tauri::Runtime> QueueManager<R> {
let _control_guard = self.acquire_aria2_control(id).await; let _control_guard = self.acquire_aria2_control(id).await;
if self.aria2_gid_for_download(id).as_deref() != Some(gid) if self.aria2_gid_for_download(id).as_deref() != Some(gid)
|| !self.is_registered(id).await || !self.is_registered(id).await
|| !self.has_active_permit(id).await
|| self.is_aria2_retry_cancelled(id).await || self.is_aria2_retry_cancelled(id).await
|| !self.is_aria2_control_epoch_current(id, observed_epoch).await || !self.is_aria2_control_epoch_current(id, observed_epoch).await
{ {
return Ok(()); 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 let still_current = self.is_registered(id).await
&& !self.is_aria2_retry_cancelled(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<Aria2RecreateOutcome, String> {
let state = self.app_handle.state::<crate::AppState>();
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<Aria2RefreshOutcome, String> { async fn refresh_uri(&self, gid: &str) -> Result<Aria2RefreshOutcome, String> {
let state = self.app_handle.state::<crate::AppState>(); let state = self.app_handle.state::<crate::AppState>();
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
+1
View File
@@ -486,6 +486,7 @@ fn default_settings() -> PersistedSettings {
ask_where_to_save_each_file: false, ask_where_to_save_each_file: false,
remember_last_used_download_directory: false, remember_last_used_download_directory: false,
prevents_sleep_while_downloading: true, prevents_sleep_while_downloading: true,
prevents_display_sleep_while_downloading: false,
media_cookie_source: MediaCookieSource::default(), media_cookie_source: MediaCookieSource::default(),
site_logins: Vec::new(), site_logins: Vec::new(),
auto_check_updates: true, auto_check_updates: true,
+242 -6
View File
@@ -1,6 +1,6 @@
use firelink_lib::queue::{ use firelink_lib::queue::{
Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind, Aria2RecreateOutcome, Aria2RefreshOutcome, QueueManager, QueuedTask, SidecarSpawner,
MEDIA_RUN_CANCELLED, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
}; };
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -44,6 +44,34 @@ struct RefreshOutcomeSpawner {
refresh_calls: AtomicUsize, 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<Aria2RecreateOutcome>,
}
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 { impl FailFirstAria2Spawner {
fn new() -> Self { fn new() -> Self {
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<String, String> {
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<Aria2RecreateOutcome, String> {
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] #[async_trait::async_trait]
impl firelink_lib::queue::SidecarSpawner for CountingSpawner { impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
async fn add_uri(&self, _id: &str, payload: &SpawnPayload) -> Result<String, String> { async fn add_uri(&self, _id: &str, payload: &SpawnPayload) -> Result<String, String> {
@@ -340,6 +404,25 @@ async fn ensure_aria2_permit_does_not_double_acquire() {
assert_eq!(mgr.available_permits(), 2); 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] #[tokio::test]
async fn live_aria2_speed_limit_updates_the_current_gid_and_payload() { async fn live_aria2_speed_limit_updates_the_current_gid_and_payload() {
let (manager, spawner) = make_manager(1); 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, outcome: Aria2RefreshOutcome::Paused,
refresh_calls: AtomicUsize::new(0), refresh_calls: AtomicUsize::new(0),
}); });
let manager = QueueManager::test_new( let manager = Arc::new(QueueManager::test_new(
app.handle().clone(), app.handle().clone(),
1, 1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>, Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
); ));
manager.push(aria2_task("refresh-paused")).await.unwrap(); manager.push(aria2_task("refresh-paused")).await.unwrap();
assert!(manager.ensure_aria2_permit("refresh-paused").await); assert!(manager.ensure_aria2_permit("refresh-paused").await);
manager 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); 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<dyn SidecarSpawner>,
));
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<dyn SidecarSpawner>,
));
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<dyn SidecarSpawner>,
));
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] #[tokio::test]
async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() { async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
let app = mock_builder() let app = mock_builder()
@@ -1300,11 +1536,11 @@ async fn stale_refresh_observation_cannot_touch_a_newer_control_epoch() {
outcome: Aria2RefreshOutcome::Resumed, outcome: Aria2RefreshOutcome::Resumed,
refresh_calls: AtomicUsize::new(0), refresh_calls: AtomicUsize::new(0),
}); });
let manager = QueueManager::test_new( let manager = Arc::new(QueueManager::test_new(
app.handle().clone(), app.handle().clone(),
1, 1,
Arc::clone(&spawner) as Arc<dyn SidecarSpawner>, Arc::clone(&spawner) as Arc<dyn SidecarSpawner>,
); ));
manager.push(aria2_task("refresh-stale")).await.unwrap(); manager.push(aria2_task("refresh-stale")).await.unwrap();
assert!(manager.ensure_aria2_permit("refresh-stale").await); assert!(manager.ensure_aria2_permit("refresh-stale").await);
manager manager
+32 -11
View File
@@ -101,6 +101,7 @@ const PageLoadingFallback = () => {
let automaticUpdateCheckStarted = false; let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>(); const processingScheduleKeys = new Set<string>();
let powerPreferencesSync: Promise<void> = Promise.resolve();
const waitForSettingsHydration = (): Promise<void> => { const waitForSettingsHydration = (): Promise<void> => {
if (useSettingsStore.persist.hasHydrated()) return Promise.resolve(); if (useSettingsStore.persist.hasHydrated()) return Promise.resolve();
@@ -245,6 +246,9 @@ function App() {
>>([]); >>([]);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
const preventsDisplaySleepWhileDownloading = useSettingsStore(
state => state.preventsDisplaySleepWhileDownloading
);
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast, removeToast } = useToast(); const { addToast, removeToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac'); const isMacUserAgent = navigator.userAgent.includes('Mac');
@@ -716,17 +720,34 @@ function App() {
}, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]); }, [platform.os, showDockBadge, dockBadgeSyncVersion, activeDownloadCount]);
useEffect(() => { useEffect(() => {
invoke('set_prevent_sleep', { const sync = () => {
prevent: preventsSleepWhileDownloading && activeTransferCount > 0 powerPreferencesSync = powerPreferencesSync
}).catch(error => { .catch(() => undefined)
console.error('Failed to update sleep prevention:', error); .then(() => invoke('set_power_preferences', {
addToast({ preventSystemSleep: preventsSleepWhileDownloading,
message: t($ => $.app.sleepPreventionFailed, { detail: String(error) }), preventDisplaySleep: preventsDisplaySleepWhileDownloading
variant: 'error', }))
isActionable: true .catch(error => {
}); console.error('Failed to update power prevention:', error);
}); addToast({
}, [addToast, preventsSleepWhileDownloading, activeTransferCount]); 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(() => { useEffect(() => {
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error); invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
+1 -1
View File
@@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme"; import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle"; 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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, 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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+13
View File
@@ -1475,6 +1475,19 @@ runEngineChecks(false);
<p className="text-text-muted text-xs mt-0.5">{t($ => $.settings.power.preventSleepDescription)}</p> <p className="text-text-muted text-xs mt-0.5">{t($ => $.settings.power.preventSleepDescription)}</p>
</div> </div>
</label> </label>
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.preventsDisplaySleepWhileDownloading}
onChange={(e) => settings.setPreventsDisplaySleepWhileDownloading(e.target.checked)}
className="mt-0.5 rounded accent-accent"
/>
<div>
<p className="font-semibold text-text-primary">{t($ => $.settings.power.preventDisplaySleep)}</p>
<p className="text-text-muted text-xs mt-0.5">{t($ => $.settings.power.preventDisplaySleepDescription)}</p>
</div>
</label>
</div> </div>
)} )}
+2
View File
@@ -738,6 +738,8 @@ const common = {
title: 'Power Management', title: 'Power Management',
preventSleep: 'Prevent system sleep while downloads are active', 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.', 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: { engine: {
title: 'Media Downloader & Engines', title: 'Media Downloader & Engines',
+2
View File
@@ -738,6 +738,8 @@ const fa = {
title: 'مدیریت انرژی', title: 'مدیریت انرژی',
preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند', preventSleep: 'جلوگیری از خواب سیستم در حالی که دانلودها فعال هستند',
preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.', preventSleepDescription: 'نمایشگر همچنان می‌تواند خاموش شود. Firelink تنها به اندازه‌ای دستگاه را بیدار نگه می‌دارد تا انتقال‌های فعال را تکمیل کند.',
preventDisplaySleep: 'روشن نگه داشتن نمایشگر هنگام فعال بودن دانلودها',
preventDisplaySleepDescription: 'این گزینه جدا از جلوگیری از خواب سیستم است و اختیاری است. نمایشگر تا پایان انتقال‌های فعال روشن می‌ماند.',
}, },
engine: { engine: {
title: 'دانلودکننده رسانه و موتورها', title: 'دانلودکننده رسانه و موتورها',
+2
View File
@@ -738,6 +738,8 @@ const he = {
title: 'ניהול צריכת חשמל', title: 'ניהול צריכת חשמל',
preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות', preventSleep: 'מנע שינה של המערכת בזמן שהורדות פעילות',
preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.', preventSleepDescription: 'התצוגה עדיין עשויה להכבות. Firelink משאיר את המכשיר ער רק במידה המספקת להשלמת העברות פעילות.',
preventDisplaySleep: 'השאר את התצוגה פעילה בזמן שהורדות פעילות',
preventDisplaySleepDescription: 'אפשרות זו נפרדת ממניעת שינת המערכת. התצוגה תישאר פעילה עד לסיום ההעברות הפעילות.',
}, },
engine: { engine: {
title: 'מוריד מדיה ומנועים', title: 'מוריד מדיה ומנועים',
+2
View File
@@ -738,6 +738,8 @@ const ru = {
title: 'Управление питанием', title: 'Управление питанием',
preventSleep: 'Запрещать спящий режим системы во время активных загрузок', preventSleep: 'Запрещать спящий режим системы во время активных загрузок',
preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.', preventSleepDescription: 'Экран всё ещё может выключаться. Firelink предотвращает засыпание устройства только настолько, чтобы завершить активные передачи.',
preventDisplaySleep: 'Не выключать экран во время активных загрузок',
preventDisplaySleepDescription: 'Это отдельная необязательная настройка, не связанная с запретом сна системы. Экран останется включённым до завершения передач.',
}, },
engine: { engine: {
title: 'Загрузчик медиа и движки', title: 'Загрузчик медиа и движки',
+2
View File
@@ -738,6 +738,8 @@ const uk = {
title: 'Керування живленням', title: 'Керування живленням',
preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень', preventSleep: 'Запобігати переходу системи в сплячий режим під час активних завантажень',
preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.', preventSleepDescription: 'Дисплей може все одно вимкнутися. Firelink лише не дає пристрою заснути, щоб завершити активні передачі.',
preventDisplaySleep: 'Не вимикати дисплей під час активних завантажень',
preventDisplaySleepDescription: 'Це окреме необов’язкове налаштування, незалежне від запобігання сну системи. Дисплей залишатиметься ввімкненим до завершення передач.',
}, },
engine: { engine: {
title: 'Завантажувач медіа та рушії', title: 'Завантажувач медіа та рушії',
+2
View File
@@ -738,6 +738,8 @@ const zhCN = {
title: '电源管理', title: '电源管理',
preventSleep: '下载活动时阻止系统睡眠', preventSleep: '下载活动时阻止系统睡眠',
preventSleepDescription: '显示器可能仍会关闭。Firelink 仅保持设备唤醒足以完成活动的传输。', preventSleepDescription: '显示器可能仍会关闭。Firelink 仅保持设备唤醒足以完成活动的传输。',
preventDisplaySleep: '下载活动时保持显示器唤醒',
preventDisplaySleepDescription: '这是与阻止系统睡眠分开的可选设置。活动传输完成前,显示器将保持开启。',
}, },
engine: { engine: {
title: '媒体下载器和引擎', title: '媒体下载器和引擎',
+4
View File
@@ -46,6 +46,10 @@ type CommandMap = {
get_platform_info: { args: undefined; result: PlatformInfo }; get_platform_info: { args: undefined; result: PlatformInfo };
approve_download_root: { args: { path: string }; result: string }; approve_download_root: { args: { path: string }; result: string };
set_prevent_sleep: { args: { prevent: boolean }; result: void }; 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 }; perform_system_action: { args: { action: PostQueueAction }; result: void };
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void }; ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; result: void };
set_concurrent_limit: { args: { limit: number }; result: void }; set_concurrent_limit: { args: { limit: number }; result: void };
+12
View File
@@ -237,6 +237,7 @@ export interface SettingsState {
customUserAgent: string; customUserAgent: string;
askWhereToSaveEachFile: boolean; askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean; preventsSleepWhileDownloading: boolean;
preventsDisplaySleepWhileDownloading: boolean;
mediaCookieSource: MediaCookieSource; mediaCookieSource: MediaCookieSource;
siteLogins: SiteLogin[]; siteLogins: SiteLogin[];
extensionPairingToken: string; extensionPairingToken: string;
@@ -286,6 +287,7 @@ export interface SettingsState {
setCustomUserAgent: (userAgent: string) => void; setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void; setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void; setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setPreventsDisplaySleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void; setMediaCookieSource: (source: MediaCookieSource) => void;
setRememberLastUsedDownloadDirectory: (enabled: boolean) => void; setRememberLastUsedDownloadDirectory: (enabled: boolean) => void;
setLastUsedDownloadDirectory: (path: string) => void; setLastUsedDownloadDirectory: (path: string) => void;
@@ -361,6 +363,7 @@ export const useSettingsStore = create<SettingsState>()(
customUserAgent: '', customUserAgent: '',
askWhereToSaveEachFile: false, askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true, preventsSleepWhileDownloading: true,
preventsDisplaySleepWhileDownloading: false,
mediaCookieSource: 'none', mediaCookieSource: 'none',
siteLogins: [], siteLogins: [],
extensionPairingToken: '', extensionPairingToken: '',
@@ -457,6 +460,10 @@ export const useSettingsStore = create<SettingsState>()(
info('Settings updated: preventsSleepWhileDownloading'); info('Settings updated: preventsSleepWhileDownloading');
set({ preventsSleepWhileDownloading }); set({ preventsSleepWhileDownloading });
}, },
setPreventsDisplaySleepWhileDownloading: (preventsDisplaySleepWhileDownloading) => {
info('Settings updated: preventsDisplaySleepWhileDownloading');
set({ preventsDisplaySleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); }, setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => { setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
info('Settings updated: rememberLastUsedDownloadDirectory'); info('Settings updated: rememberLastUsedDownloadDirectory');
@@ -631,6 +638,7 @@ export const useSettingsStore = create<SettingsState>()(
customUserAgent: state.customUserAgent, customUserAgent: state.customUserAgent,
askWhereToSaveEachFile: state.askWhereToSaveEachFile, askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
preventsDisplaySleepWhileDownloading: state.preventsDisplaySleepWhileDownloading,
mediaCookieSource: state.mediaCookieSource, mediaCookieSource: state.mediaCookieSource,
siteLogins: state.siteLogins, siteLogins: state.siteLogins,
keychainAccessGranted: state.keychainAccessGranted, keychainAccessGranted: state.keychainAccessGranted,
@@ -704,6 +712,10 @@ export const useSettingsStore = create<SettingsState>()(
persisted.preventsSleepWhileDownloading, persisted.preventsSleepWhileDownloading,
currentState.preventsSleepWhileDownloading currentState.preventsSleepWhileDownloading
), ),
preventsDisplaySleepWhileDownloading: persistedBoolean(
persisted.preventsDisplaySleepWhileDownloading,
currentState.preventsDisplaySleepWhileDownloading
),
keychainAccessGranted: persistedBoolean( keychainAccessGranted: persistedBoolean(
persisted.keychainAccessGranted, persisted.keychainAccessGranted,
currentState.keychainAccessGranted currentState.keychainAccessGranted