mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
fix: harden scheduler, permissions, and download safety
- Implement scheduler hydration barrier to prevent premature triggers - Track scheduler exact runs using keys to avoid false stops - Use 'System Events' for accurate macOS automation permissions - Prevent system-sleep via proper idle assertions - Ensure download pauses use channel acknowledgements (PauseWithAck) - Require Firelink ownership before replacing files in add/conflict UI - Retain partial download assets when removing entries without deletion - Clear progress state in store when downloads complete or pause to reduce churn - Handle empty/invalid queue selections gracefully
This commit is contained in:
@@ -251,6 +251,8 @@ pub struct PersistedSettings {
|
||||
pub is_sidebar_visible: bool,
|
||||
pub active_settings_tab: SettingsTab,
|
||||
pub scheduler: SchedulerSettings,
|
||||
pub scheduler_running: bool,
|
||||
pub scheduler_active_download_ids: Vec<String>,
|
||||
pub scheduler_last_start_key: String,
|
||||
pub scheduler_last_stop_key: String,
|
||||
pub last_custom_speed_limit_ki_b: u32,
|
||||
|
||||
+114
-99
@@ -668,10 +668,6 @@ async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
|
||||
cleanup_media_artifacts(out_path, true).await;
|
||||
}
|
||||
|
||||
async fn cleanup_media_sidecars(out_path: &std::path::Path) {
|
||||
cleanup_media_artifacts(out_path, false).await;
|
||||
}
|
||||
|
||||
async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: bool) {
|
||||
let Some(parent) = out_path.parent() else {
|
||||
return;
|
||||
@@ -1126,12 +1122,34 @@ async fn test_deno(app_handle: tauri::AppHandle) -> Result<String, String> {
|
||||
}
|
||||
|
||||
pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle) -> bool {
|
||||
if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
|
||||
if !path.is_absolute()
|
||||
|| path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
std::path::Component::ParentDir | std::path::Component::CurDir
|
||||
)
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !path.is_absolute() {
|
||||
return true;
|
||||
let mut existing = path;
|
||||
let mut missing = Vec::new();
|
||||
while !existing.exists() {
|
||||
let Some(name) = existing.file_name() else {
|
||||
return false;
|
||||
};
|
||||
missing.push(name.to_owned());
|
||||
let Some(parent) = existing.parent() else {
|
||||
return false;
|
||||
};
|
||||
existing = parent;
|
||||
}
|
||||
let Ok(mut canonical_path) = std::fs::canonicalize(existing) else {
|
||||
return false;
|
||||
};
|
||||
for component in missing.iter().rev() {
|
||||
canonical_path.push(component);
|
||||
}
|
||||
|
||||
let mut allowed_prefixes = Vec::new();
|
||||
@@ -1147,7 +1165,8 @@ pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle
|
||||
allowed_prefixes.push(std::path::PathBuf::from("/Volumes"));
|
||||
|
||||
for prefix in allowed_prefixes {
|
||||
if path.starts_with(&prefix) {
|
||||
let canonical_prefix = std::fs::canonicalize(&prefix).unwrap_or(prefix);
|
||||
if canonical_path.starts_with(&canonical_prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1155,32 +1174,6 @@ pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
let resolved_dest = resolve_path(&path, &app);
|
||||
|
||||
if !is_safe_path(&resolved_dest, &app) {
|
||||
return Err("Path traversal blocked".to_string());
|
||||
}
|
||||
|
||||
app.opener().open_path(resolved_dest.to_string_lossy().as_ref(), None::<String>).map_err(|e| format!("Failed to open file: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
let resolved_dest = resolve_path(&path, &app);
|
||||
|
||||
if !is_safe_path(&resolved_dest, &app) {
|
||||
return Err("Path traversal blocked".to_string());
|
||||
}
|
||||
|
||||
app.opener().reveal_item_in_dir(resolved_dest.to_string_lossy().as_ref()).map_err(|e| format!("Failed to reveal in folder: {}", e))
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
@@ -1242,6 +1235,7 @@ pub struct AppState {
|
||||
pub aria2_secret: String,
|
||||
pub media_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
pub sleep_preventer: Arc<Mutex<Option<keepawake::KeepAwake>>>,
|
||||
pub scheduler_settings: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||
pub queue_manager: Arc<queue::QueueManager>,
|
||||
}
|
||||
|
||||
@@ -2362,23 +2356,32 @@ async fn pause_download(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
|
||||
);
|
||||
|
||||
if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
let _ = state
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::Pause(download_id))
|
||||
.await;
|
||||
.pause_media_with_ack(id.clone(), tx)
|
||||
.await?;
|
||||
} else if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::PauseWithAck(download_id, tx))
|
||||
.await?;
|
||||
} else {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
let media_result = state.download_coordinator.pause_media(id.clone()).await;
|
||||
rx.await
|
||||
.map_err(|_| "download worker stopped without acknowledging pause".to_string())?;
|
||||
|
||||
if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
state.queue_manager.release_permit(&id).await;
|
||||
}
|
||||
media_result
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2498,10 +2501,12 @@ async fn remove_download(
|
||||
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
||||
state.download_coordinator.pause_media_with_ack(id.clone(), tx).await?;
|
||||
} else if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::CancelWithAck(download_id, tx))
|
||||
.await?;
|
||||
let command = if delete_assets {
|
||||
download::DownloadCmd::CancelWithAck(download_id, tx)
|
||||
} else {
|
||||
download::DownloadCmd::PauseWithAck(download_id, tx)
|
||||
};
|
||||
state.download_coordinator.send(command).await?;
|
||||
} else {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
@@ -2524,8 +2529,6 @@ async fn remove_download(
|
||||
if let Some(path) = primary_path.as_deref() {
|
||||
remove_download_assets(path, &app_handle).await?;
|
||||
}
|
||||
} else if let Some(path) = primary_path.as_deref() {
|
||||
remove_partial_download_assets(path, &app_handle).await?;
|
||||
}
|
||||
|
||||
crate::download_ownership::remove(&app_handle, &id)?;
|
||||
@@ -2559,25 +2562,6 @@ async fn remove_download_assets(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_partial_download_assets(
|
||||
primary: &std::path::Path,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if !is_safe_path(primary, app_handle) {
|
||||
return Err("Download asset path is outside an allowed download location".to_string());
|
||||
}
|
||||
for suffix in [".aria2", ".part", ".ytdl"] {
|
||||
let candidate = std::path::PathBuf::from(format!("{}{}", primary.display(), suffix));
|
||||
if candidate.exists() && is_safe_path(&candidate, app_handle) {
|
||||
tokio::fs::remove_file(&candidate)
|
||||
.await
|
||||
.map_err(|error| format!("failed to remove '{}': {error}", candidate.display()))?;
|
||||
}
|
||||
}
|
||||
cleanup_media_sidecars(primary).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn detach_download_for_reconfigure(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -2741,17 +2725,21 @@ fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) {
|
||||
fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> {
|
||||
let mut current_preventer = state.sleep_preventer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if prevent {
|
||||
if current_preventer.is_none() {
|
||||
if let Ok(keepawake) = keepawake::Builder::default().display(true).reason("Downloading files").create() {
|
||||
*current_preventer = Some(keepawake);
|
||||
}
|
||||
let keepawake = keepawake::Builder::default()
|
||||
.idle(true)
|
||||
.reason("Firelink active download")
|
||||
.create()
|
||||
.map_err(|error| format!("failed to prevent system sleep: {error}"))?;
|
||||
*current_preventer = Some(keepawake);
|
||||
}
|
||||
} else {
|
||||
*current_preventer = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> {
|
||||
@@ -2777,6 +2765,7 @@ fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), Stri
|
||||
#[tauri::command]
|
||||
fn ack_schedule_trigger(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
action: String,
|
||||
key: String,
|
||||
) -> Result<(), String> {
|
||||
@@ -2790,7 +2779,18 @@ fn ack_schedule_trigger(
|
||||
_ => {}
|
||||
})?;
|
||||
match action.as_str() {
|
||||
"start" | "stop" => Ok(()),
|
||||
"start" | "stop" => {
|
||||
if let Ok(mut cached) = state.scheduler_settings.write() {
|
||||
if let Some(settings) = cached.as_mut() {
|
||||
if action == "start" {
|
||||
settings.scheduler_last_start_key = key;
|
||||
} else {
|
||||
settings.scheduler_last_stop_key = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("Unknown scheduler trigger action".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -2931,16 +2931,17 @@ async fn set_global_speed_limit(state: tauri::State<'_, AppState>, limit: Option
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn request_automation_permission() -> Result<(), String> {
|
||||
fn check_automation_permission() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use cocoa::base::{id, nil};
|
||||
use cocoa::foundation::NSString;
|
||||
use cocoa::base::{nil, id};
|
||||
use objc::{msg_send, sel, sel_impl, class};
|
||||
use objc::{class, msg_send, sel, sel_impl};
|
||||
|
||||
unsafe {
|
||||
objc::rc::autoreleasepool(|| {
|
||||
let script_str = NSString::alloc(nil).init_str("tell application \"Finder\" to get name");
|
||||
let script_str =
|
||||
NSString::alloc(nil).init_str("tell application \"System Events\" to get name");
|
||||
let ns_apple_script: id = msg_send![class!(NSAppleScript), alloc];
|
||||
let ns_apple_script: id = msg_send![ns_apple_script, initWithSource: script_str];
|
||||
let mut error_dict: id = nil;
|
||||
@@ -2957,6 +2958,18 @@ fn request_automation_permission() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn request_automation_permission() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
system_shutdown::request_permission_dialog()
|
||||
.map_err(|error| format!("Automation permission was not granted: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_automation_settings(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -3049,9 +3062,20 @@ fn acknowledge_pairing_token_change(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn db_save_settings(state: tauri::State<'_, crate::db::DbState>, data: String) -> Result<(), String> {
|
||||
fn db_save_settings(
|
||||
state: tauri::State<'_, crate::db::DbState>,
|
||||
app_state: tauri::State<'_, AppState>,
|
||||
data: String,
|
||||
) -> Result<(), String> {
|
||||
let connection = state.lock()?;
|
||||
crate::db::save_settings(&connection, &data)
|
||||
let existing = crate::db::load_settings(&connection)?;
|
||||
let merged = crate::settings::preserve_scheduler_runtime_keys(existing.as_deref(), &data)?;
|
||||
crate::db::save_settings(&connection, &merged)?;
|
||||
let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?;
|
||||
if let Ok(mut cached) = app_state.scheduler_settings.write() {
|
||||
*cached = Some(decoded);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -3105,19 +3129,6 @@ fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool {
|
||||
resolved_dest.exists()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
let resolved_dest = resolve_path(&path, &app_handle);
|
||||
if !is_safe_path(&resolved_dest, &app_handle) {
|
||||
return Err("Path traversal blocked".to_string());
|
||||
}
|
||||
if resolved_dest.exists() {
|
||||
std::fs::remove_file(resolved_dest).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, String> {
|
||||
use tauri::Manager;
|
||||
let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?;
|
||||
@@ -3757,6 +3768,9 @@ pub fn run() {
|
||||
.map(|settings| settings.max_concurrent_downloads)
|
||||
.unwrap_or(crate::queue::DEFAULT_MAX_CONCURRENT)
|
||||
};
|
||||
let scheduler_settings = Arc::new(RwLock::new(
|
||||
crate::settings::load_settings(app.handle()).ok(),
|
||||
));
|
||||
|
||||
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent));
|
||||
let dispatcher_mgr = Arc::clone(&queue_manager);
|
||||
@@ -3777,6 +3791,7 @@ pub fn run() {
|
||||
aria2_secret: aria2_secret.clone(),
|
||||
media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)),
|
||||
sleep_preventer: Arc::new(Mutex::new(None)),
|
||||
scheduler_settings: Arc::clone(&scheduler_settings),
|
||||
queue_manager,
|
||||
});
|
||||
|
||||
@@ -3822,7 +3837,7 @@ pub fn run() {
|
||||
Ok(None) => {}
|
||||
Err(error) => eprintln!("Failed to read startup deep link: {error}"),
|
||||
}
|
||||
crate::scheduler::spawn_scheduler(app.handle().clone());
|
||||
crate::scheduler::spawn_scheduler(app.handle().clone(), scheduler_settings);
|
||||
|
||||
let global_speed_limit = crate::settings::load_settings(app.handle())
|
||||
.map(|settings| settings.global_speed_limit)
|
||||
@@ -4061,14 +4076,14 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
|
||||
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder,
|
||||
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
|
||||
pause_download, resume_download, fetch_metadata, fetch_media_metadata,
|
||||
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
|
||||
ack_schedule_trigger,
|
||||
request_automation_permission, open_automation_settings,
|
||||
check_automation_permission, request_automation_permission, open_automation_settings,
|
||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||
hydrate_extension_pairing_token, acknowledge_pairing_token_change,
|
||||
check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token,
|
||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
|
||||
detach_download_for_reconfigure,
|
||||
enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use chrono::{Datelike, Local, Timelike};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
use tauri::Emitter;
|
||||
|
||||
@@ -10,15 +11,44 @@ fn minute_of_day(value: &str) -> Option<u32> {
|
||||
(hour < 24 && minute < 60).then_some(hour * 60 + minute)
|
||||
}
|
||||
|
||||
pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
|
||||
fn stop_is_due(
|
||||
stop_time_enabled: bool,
|
||||
stop_minute: Option<u32>,
|
||||
current_minute: u32,
|
||||
last_start_key: &str,
|
||||
start_key: &str,
|
||||
last_stop_key: &str,
|
||||
stop_key: &str,
|
||||
) -> bool {
|
||||
stop_time_enabled
|
||||
&& stop_minute.is_some_and(|stop| current_minute >= stop)
|
||||
&& last_start_key == start_key
|
||||
&& last_stop_key != stop_key
|
||||
}
|
||||
|
||||
pub fn spawn_scheduler(
|
||||
app_handle: tauri::AppHandle,
|
||||
settings_cache: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
|
||||
) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
let mut last_emit: HashMap<&'static str, std::time::Instant> = HashMap::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
if let Ok(settings) = crate::settings::load_settings(&app_handle) {
|
||||
let scheduler = settings.scheduler;
|
||||
let settings = settings_cache
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|settings| {
|
||||
settings.as_ref().map(|settings| {
|
||||
(
|
||||
settings.scheduler.clone(),
|
||||
settings.scheduler_last_start_key.clone(),
|
||||
settings.scheduler_last_stop_key.clone(),
|
||||
)
|
||||
})
|
||||
});
|
||||
if let Some((scheduler, scheduler_last_start_key, scheduler_last_stop_key)) = settings {
|
||||
if !scheduler.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -43,7 +73,7 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
|
||||
|
||||
if start_minute.is_some_and(|start| current_minute >= start)
|
||||
&& before_stop
|
||||
&& settings.scheduler_last_start_key != start_key
|
||||
&& scheduler_last_start_key != start_key
|
||||
&& last_emit
|
||||
.get("start")
|
||||
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
|
||||
@@ -55,9 +85,15 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
|
||||
last_emit.insert("start", std::time::Instant::now());
|
||||
}
|
||||
|
||||
if scheduler.stop_time_enabled
|
||||
&& stop_minute.is_some_and(|stop| current_minute >= stop)
|
||||
&& settings.scheduler_last_stop_key != stop_key
|
||||
if stop_is_due(
|
||||
scheduler.stop_time_enabled,
|
||||
stop_minute,
|
||||
current_minute,
|
||||
&scheduler_last_start_key,
|
||||
&start_key,
|
||||
&scheduler_last_stop_key,
|
||||
&stop_key,
|
||||
)
|
||||
&& last_emit
|
||||
.get("stop")
|
||||
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
|
||||
@@ -75,7 +111,7 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::minute_of_day;
|
||||
use super::{minute_of_day, stop_is_due};
|
||||
|
||||
#[test]
|
||||
fn parses_valid_scheduler_times() {
|
||||
@@ -90,4 +126,26 @@ mod tests {
|
||||
assert_eq!(minute_of_day("12:60"), None);
|
||||
assert_eq!(minute_of_day("bad"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_requires_same_day_acknowledged_start() {
|
||||
assert!(!stop_is_due(
|
||||
true,
|
||||
Some(480),
|
||||
600,
|
||||
"",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
));
|
||||
assert!(stop_is_due(
|
||||
true,
|
||||
Some(480),
|
||||
600,
|
||||
"2026-06-22-start",
|
||||
"2026-06-22-start",
|
||||
"",
|
||||
"2026-06-22-stop",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,26 @@ pub fn update_settings_state(
|
||||
crate::db::save_settings(&connection, &stored)
|
||||
}
|
||||
|
||||
pub fn preserve_scheduler_runtime_keys(
|
||||
existing: Option<&str>,
|
||||
incoming: &str,
|
||||
) -> Result<String, String> {
|
||||
let Some(existing) = existing else {
|
||||
return Ok(incoming.to_string());
|
||||
};
|
||||
let existing_document = decode_document(&Value::String(existing.to_string()))?;
|
||||
let existing_state = settings_state(&existing_document)?;
|
||||
let mut incoming_document = decode_document(&Value::String(incoming.to_string()))?;
|
||||
let incoming_state = settings_state_mut(&mut incoming_document)?;
|
||||
for key in ["schedulerLastStartKey", "schedulerLastStopKey"] {
|
||||
if let Some(value) = existing_state.get(key) {
|
||||
incoming_state.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&incoming_document)
|
||||
.map_err(|error| format!("failed to encode persisted settings: {error}"))
|
||||
}
|
||||
|
||||
fn decode_document(stored: &Value) -> Result<Value, String> {
|
||||
match stored {
|
||||
Value::String(text) => serde_json::from_str(text)
|
||||
@@ -246,6 +266,8 @@ fn default_settings() -> PersistedSettings {
|
||||
selected_queue_ids: vec!["00000000-0000-0000-0000-000000000001".to_string()],
|
||||
post_queue_action: PostQueueAction::None,
|
||||
},
|
||||
scheduler_running: false,
|
||||
scheduler_active_download_ids: Vec::new(),
|
||||
scheduler_last_start_key: String::new(),
|
||||
scheduler_last_stop_key: String::new(),
|
||||
last_custom_speed_limit_ki_b: 1024,
|
||||
@@ -271,9 +293,41 @@ fn default_settings() -> PersistedSettings {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::decode_stored_settings;
|
||||
use super::{decode_stored_settings, preserve_scheduler_runtime_keys};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
fn frontend_settings_save_preserves_backend_scheduler_keys() {
|
||||
let existing = json!({
|
||||
"state": {
|
||||
"schedulerLastStartKey": "2026-06-22-start",
|
||||
"schedulerLastStopKey": "2026-06-22-stop"
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
let incoming = json!({
|
||||
"state": {
|
||||
"schedulerLastStartKey": "",
|
||||
"schedulerLastStopKey": "",
|
||||
"theme": "system"
|
||||
},
|
||||
"version": 3
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let merged = preserve_scheduler_runtime_keys(Some(&existing), &incoming).unwrap();
|
||||
let merged: Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(
|
||||
merged["state"]["schedulerLastStartKey"],
|
||||
"2026-06-22-start"
|
||||
);
|
||||
assert_eq!(
|
||||
merged["state"]["schedulerLastStopKey"],
|
||||
"2026-06-22-stop"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_zustand_envelope_and_preserves_non_default_startup_settings() {
|
||||
let stored = json!({
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::{
|
||||
};
|
||||
use firelink_lib::download::{DownloadCmd, DownloadCoordinator, DownloadEvent, DownloadPayload};
|
||||
use tempfile::TempDir;
|
||||
use tokio::{net::TcpListener, sync::mpsc, task::JoinHandle};
|
||||
use tokio::{net::TcpListener, sync::{mpsc, oneshot}, task::JoinHandle};
|
||||
use uuid::Uuid;
|
||||
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
@@ -288,12 +288,19 @@ async fn pause_then_resume_uses_range_and_preserves_integrity() {
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_progress(&mut events, id, 256 * 1024).await;
|
||||
coordinator.send(DownloadCmd::Pause(id)).await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let (pause_tx, pause_rx) = oneshot::channel();
|
||||
coordinator
|
||||
.send(DownloadCmd::PauseWithAck(id, pause_tx))
|
||||
.await
|
||||
.unwrap();
|
||||
pause_rx.await.unwrap();
|
||||
|
||||
let paused_len = tokio::fs::metadata(&output_path).await.unwrap().len();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let stable_paused_len = tokio::fs::metadata(&output_path).await.unwrap().len();
|
||||
assert!(paused_len >= 256 * 1024);
|
||||
assert!(paused_len < expected.len() as u64);
|
||||
assert_eq!(stable_paused_len, paused_len);
|
||||
|
||||
coordinator
|
||||
.send(DownloadCmd::Start(Box::new(payload(
|
||||
|
||||
+105
-23
@@ -21,16 +21,27 @@ import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
let automaticUpdateCheckStarted = false;
|
||||
const processingScheduleKeys = new Set<string>();
|
||||
|
||||
const waitForSettingsHydration = (): Promise<void> => {
|
||||
if (useSettingsStore.persist.hasHydrated()) return Promise.resolve();
|
||||
return new Promise(resolve => {
|
||||
const unsubscribe = useSettingsStore.persist.onFinishHydration(() => {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getScheduledQueueIds = () => {
|
||||
const downloadState = useDownloadStore.getState();
|
||||
const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id));
|
||||
const selectedQueueIds = useSettingsStore.getState().scheduler.selectedQueueIds
|
||||
.filter(queueId => availableQueueIds.has(queueId));
|
||||
return selectedQueueIds.length > 0 ? selectedQueueIds : [MAIN_QUEUE_ID];
|
||||
return selectedQueueIds;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [filter, setFilter] = useState<SidebarFilter>('all');
|
||||
const [coreReady, setCoreReady] = useState(false);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
|
||||
@@ -58,6 +69,12 @@ function App() {
|
||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||
const previousSpeedLimit = useRef<string | null>(null);
|
||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||
const activeTransferCount = downloads.filter(download =>
|
||||
download.status === 'downloading' ||
|
||||
download.status === 'processing' ||
|
||||
download.status === 'retrying'
|
||||
).length;
|
||||
|
||||
const acknowledgePairingTokenChange = () => {
|
||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
||||
@@ -94,9 +111,24 @@ function App() {
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
useDownloadStore.getState().initDB();
|
||||
useSettingsStore.getState().hydratePairingToken()
|
||||
.then(changed => {
|
||||
let active = true;
|
||||
const initialize = async () => {
|
||||
try {
|
||||
await waitForSettingsHydration();
|
||||
await useDownloadStore.getState().initDB();
|
||||
if (active) setCoreReady(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Firelink state:', error);
|
||||
addToast({
|
||||
message: `Could not initialize saved downloads: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const changed = await useSettingsStore.getState().hydratePairingToken();
|
||||
if (changed) {
|
||||
addToast({
|
||||
variant: 'warning',
|
||||
@@ -143,10 +175,14 @@ function App() {
|
||||
)
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
} catch (error) {
|
||||
console.error('Failed to hydrate extension pairing token:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
void initialize();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -205,6 +241,19 @@ function App() {
|
||||
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
|
||||
}, [showDockBadge, activeDownloadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('set_prevent_sleep', {
|
||||
prevent: preventsSleepWhileDownloading && activeTransferCount > 0
|
||||
}).catch(error => {
|
||||
console.error('Failed to update sleep prevention:', error);
|
||||
addToast({
|
||||
message: `Could not update sleep prevention: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}, [addToast, preventsSleepWhileDownloading, activeTransferCount]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
|
||||
}, [showMenuBarIcon]);
|
||||
@@ -228,6 +277,7 @@ function App() {
|
||||
}, [globalSpeedLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coreReady) return;
|
||||
const unlisten = listen('schedule-trigger', async (event) => {
|
||||
const state = useSettingsStore.getState();
|
||||
const payload = event.payload;
|
||||
@@ -235,17 +285,48 @@ function App() {
|
||||
processingScheduleKeys.add(payload.key);
|
||||
try {
|
||||
if (payload.action === 'start') {
|
||||
const scheduledQueueIds = getScheduledQueueIds();
|
||||
if (scheduledQueueIds.length === 0) {
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
addToast({
|
||||
message: 'Scheduler has no valid queues selected. Update Scheduler settings.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
return;
|
||||
}
|
||||
const startedResults = await Promise.all(
|
||||
getScheduledQueueIds().map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
const acceptedIds = startedResults.flat();
|
||||
state.setSchedulerActiveDownloadIds(acceptedIds);
|
||||
state.setSchedulerRunning(acceptedIds.length > 0);
|
||||
const scheduledQueueSet = new Set(scheduledQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
isActiveDownloadStatus(download.status)
|
||||
)
|
||||
.map(download => download.id);
|
||||
const activeIds = [...new Set([...acceptedIds, ...trackedIds])];
|
||||
state.setSchedulerActiveDownloadIds(activeIds);
|
||||
state.setSchedulerRunning(activeIds.length > 0);
|
||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||
} else if (payload.action === 'stop') {
|
||||
await Promise.all(
|
||||
getScheduledQueueIds().map(queueId => useDownloadStore.getState().pauseQueue(queueId))
|
||||
);
|
||||
const trackedIds = state.schedulerActiveDownloadIds;
|
||||
if (trackedIds.length > 0) {
|
||||
const pauseResults = await Promise.allSettled(
|
||||
trackedIds.map(id => invoke('pause_download', { id }))
|
||||
);
|
||||
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
|
||||
if (failedPauses > 0) {
|
||||
addToast({
|
||||
message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
state.setSchedulerActiveDownloadIds([]);
|
||||
state.setSchedulerRunning(false);
|
||||
await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key });
|
||||
@@ -258,33 +339,34 @@ function App() {
|
||||
return () => {
|
||||
unlisten.then(f => f()).catch(console.error);
|
||||
};
|
||||
}, []);
|
||||
}, [addToast, coreReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schedulerRunning) return;
|
||||
if (schedulerActiveDownloadIds.length === 0) return;
|
||||
const scheduledIds = new Set(schedulerActiveDownloadIds);
|
||||
const hasPendingScheduledWork = downloads.some(download =>
|
||||
scheduledIds.has(download.id) && isActiveDownloadStatus(download.status)
|
||||
);
|
||||
if (hasPendingScheduledWork) return;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
const scheduledItems = schedulerActiveDownloadIds.map(id =>
|
||||
downloads.find(download => download.id === id)
|
||||
);
|
||||
const hasFailures = scheduledItems.some(item => !item || item.status === 'failed');
|
||||
if (scheduledItems.some(item => item && isActiveDownloadStatus(item.status))) return;
|
||||
|
||||
const allCompleted = scheduledItems.every(item => item?.status === 'completed');
|
||||
settings.setSchedulerActiveDownloadIds([]);
|
||||
settings.setSchedulerRunning(false);
|
||||
if (hasFailures) {
|
||||
if (!allCompleted) {
|
||||
addToast({
|
||||
message: 'Scheduled downloads finished with failures. The post-queue system action was skipped.',
|
||||
message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
|
||||
variant: 'warning',
|
||||
isActionable: true
|
||||
});
|
||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||
invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => {
|
||||
console.error('Scheduled post action failed:', error);
|
||||
addToast({
|
||||
message: `Scheduled system action failed: ${String(error)}`,
|
||||
variant: 'error',
|
||||
isActionable: true
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
|
||||
|
||||
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
|
||||
|
||||
@@ -414,9 +414,20 @@ export const AddDownloadsModal = () => {
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
if (fileExistsInStore || fileExistsOnDisk) {
|
||||
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: 'File exists at destination' }, resolution: 'rename' });
|
||||
}
|
||||
if (fileExistsInStore || fileExistsOnDisk) {
|
||||
newConflicts.push({
|
||||
id: i.toString(),
|
||||
fileName: finalFile,
|
||||
reason: {
|
||||
type: 'file',
|
||||
msg: fileExistsInStore
|
||||
? 'Existing Firelink download uses this destination'
|
||||
: 'File exists on disk; rename or skip to avoid deleting unrelated data'
|
||||
},
|
||||
resolution: 'rename',
|
||||
replaceAllowed: fileExistsInStore
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +514,7 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
itemsToAdd[idx] = { ...item, file: newName };
|
||||
} else if (res.resolution === 'replace') {
|
||||
if (conflict?.reason.type !== 'file') {
|
||||
if (conflict?.reason.type !== 'file' || !conflict.replaceAllowed) {
|
||||
itemsToAdd[idx] = null;
|
||||
continue;
|
||||
}
|
||||
@@ -516,8 +527,6 @@ export const AddDownloadsModal = () => {
|
||||
const itemLocation = useSharedDestination
|
||||
? finalLocation
|
||||
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
||||
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
|
||||
|
||||
const store = useDownloadStore.getState();
|
||||
let existingItem;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
@@ -525,8 +534,8 @@ export const AddDownloadsModal = () => {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
if (
|
||||
(download.url === item.downloadUrl ||
|
||||
(destination === itemLocation && download.fileName === finalFile)) &&
|
||||
destination === itemLocation &&
|
||||
download.fileName === finalFile &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
existingItem = download;
|
||||
@@ -538,10 +547,10 @@ export const AddDownloadsModal = () => {
|
||||
throw new Error(`Pause ${existingItem.fileName} before replacing it.`);
|
||||
}
|
||||
|
||||
await invoke('delete_file', { path: fullPath });
|
||||
if (existingItem) {
|
||||
await store.removeDownload(existingItem.id);
|
||||
if (!existingItem) {
|
||||
throw new Error(`Cannot replace ${finalFile}: file is not owned by a Firelink download.`);
|
||||
}
|
||||
await store.removeDownload(existingItem.id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,7 +574,7 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
|
||||
const category = categoryForFileName(finalFile);
|
||||
await addDownload({
|
||||
const added = await addDownload({
|
||||
id,
|
||||
url: item.downloadUrl,
|
||||
fileName: finalFile,
|
||||
@@ -588,6 +597,9 @@ export const AddDownloadsModal = () => {
|
||||
mediaFormatSelector: formatSelector,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
|
||||
}, action);
|
||||
if (!added) {
|
||||
throw new Error('Backend rejected download start.');
|
||||
}
|
||||
addedCount += 1;
|
||||
} catch (e) {
|
||||
console.error("Invalid URL or failed to add:", e);
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface DuplicateConflict {
|
||||
fileName: string;
|
||||
reason: DuplicateReason;
|
||||
resolution: DuplicateResolution;
|
||||
replaceAllowed?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -44,7 +45,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="rename">Rename</option>
|
||||
{conflict.reason.type === 'file' && <option value="replace">Replace</option>}
|
||||
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">Replace</option>}
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
@@ -19,6 +20,11 @@ export const PropertiesModal = () => {
|
||||
? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null
|
||||
: null
|
||||
);
|
||||
const liveProgress = useDownloadProgressStore(state =>
|
||||
selectedPropertiesDownloadId
|
||||
? state.progressMap[selectedPropertiesDownloadId]
|
||||
: undefined
|
||||
);
|
||||
|
||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
||||
|
||||
@@ -152,6 +158,15 @@ export const PropertiesModal = () => {
|
||||
|
||||
const identityLocked = getIdentityLocked(item.status);
|
||||
const transferLocked = getTransferLocked(item.status);
|
||||
const displayedFraction = item.status === 'completed'
|
||||
? 1
|
||||
: liveProgress?.fraction ?? item.fraction ?? 0;
|
||||
const displayedSpeed = item.status === 'completed'
|
||||
? '-'
|
||||
: liveProgress?.speed ?? item.speed ?? '-';
|
||||
const displayedEta = item.status === 'completed'
|
||||
? '-'
|
||||
: liveProgress?.eta ?? item.eta ?? '-';
|
||||
|
||||
let statusColor = 'text-text-secondary';
|
||||
let StatusIcon = Info;
|
||||
@@ -176,14 +191,14 @@ export const PropertiesModal = () => {
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-border-color rounded-full h-1.5 overflow-hidden mb-4">
|
||||
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${(item.status === 'completed' ? 1 : item.fraction || 0) * 100}%` }}></div>
|
||||
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${displayedFraction * 100}%` }}></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '100%' : ((item.fraction || 0) * 100).toFixed(0) + '%'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Size</span><span className="text-text-secondary truncate">{item.size || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.speed || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.eta || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate">{item.connections || perServerConnections || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Pause, Play, Power, RotateCcw, Save
|
||||
} from 'lucide-react';
|
||||
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
@@ -89,9 +89,7 @@ export default function SchedulerView() {
|
||||
|
||||
const availableQueueIds = new Set(queues.map(queue => queue.id));
|
||||
const selectedQueueIds = draft.selectedQueueIds.filter(queueId => availableQueueIds.has(queueId));
|
||||
const effectiveSelectedQueueIds = selectedQueueIds.length > 0
|
||||
? selectedQueueIds
|
||||
: [MAIN_QUEUE_ID];
|
||||
const effectiveSelectedQueueIds = selectedQueueIds;
|
||||
|
||||
const toggleQueue = (queueId: string) => {
|
||||
setDraft(current => {
|
||||
@@ -114,6 +112,10 @@ export default function SchedulerView() {
|
||||
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (effectiveSelectedQueueIds.length === 0) {
|
||||
addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
|
||||
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
|
||||
return;
|
||||
@@ -132,12 +134,19 @@ export default function SchedulerView() {
|
||||
const results = await Promise.all(
|
||||
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
|
||||
);
|
||||
const count = results.reduce((total, ids) => total + ids.length, 0);
|
||||
const acceptedIds = results.flat();
|
||||
if (count > 0) {
|
||||
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
['queued', 'downloading', 'processing', 'retrying'].includes(download.status)
|
||||
)
|
||||
.map(download => download.id);
|
||||
const activeIds = [...new Set([...acceptedIds, ...trackedIds])];
|
||||
if (activeIds.length > 0) {
|
||||
useSettingsStore.getState().setSchedulerRunning(true);
|
||||
useSettingsStore.getState().setSchedulerActiveDownloadIds(acceptedIds);
|
||||
addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' });
|
||||
useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds);
|
||||
addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' });
|
||||
} else {
|
||||
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
|
||||
}
|
||||
@@ -157,7 +166,7 @@ export default function SchedulerView() {
|
||||
if (!isMac) return;
|
||||
|
||||
try {
|
||||
await invoke('request_automation_permission');
|
||||
await invoke('check_automation_permission');
|
||||
setAutomationPermissionGranted(true);
|
||||
if (showMessage) {
|
||||
setPermissionMessage('Automation permission is available.');
|
||||
@@ -165,7 +174,7 @@ export default function SchedulerView() {
|
||||
} catch {
|
||||
setAutomationPermissionGranted(false);
|
||||
if (showMessage) {
|
||||
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for Finder in System Settings.');
|
||||
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.');
|
||||
}
|
||||
}
|
||||
}, [isMac]);
|
||||
@@ -204,7 +213,7 @@ export default function SchedulerView() {
|
||||
|
||||
const handlePermissionAction = async () => {
|
||||
if (automationPermissionGranted) {
|
||||
await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke it in System Settings, then return to Firelink.');
|
||||
await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +224,7 @@ export default function SchedulerView() {
|
||||
setPermissionMessage('Automation permission is available.');
|
||||
} catch {
|
||||
setAutomationPermissionGranted(false);
|
||||
await openAutomationSettings('Enable Firelink under Automation for Finder in System Settings, then return to Firelink.');
|
||||
await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -361,7 +370,7 @@ export default function SchedulerView() {
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<LockKeyhole size={17} className="text-accent" /> System Permissions
|
||||
</div>
|
||||
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for Finder.</p>
|
||||
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for System Events.</p>
|
||||
<div className="mb-4 flex items-center gap-2 text-[12px]">
|
||||
{automationPermissionGranted ? (
|
||||
<>
|
||||
|
||||
+1
-1
@@ -40,13 +40,13 @@ type CommandMap = {
|
||||
set_concurrent_limit: { args: { limit: number }; result: void };
|
||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
request_automation_permission: { args: undefined; result: void };
|
||||
check_automation_permission: { args: undefined; result: void };
|
||||
open_automation_settings: { args: undefined; result: void };
|
||||
get_free_space: { args: { path: string }; result: string };
|
||||
set_keychain_password: { args: { id: string; password: string }; result: void };
|
||||
get_keychain_password: { args: { id: string }; result: string };
|
||||
delete_keychain_password: { args: { id: string }; result: void };
|
||||
check_file_exists: { args: { path: string }; result: boolean };
|
||||
delete_file: { args: { path: string }; result: void };
|
||||
toggle_tray_icon: { args: { show: boolean }; result: void };
|
||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||
get_extension_server_port: { args: undefined; result: number | null };
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useDownloadProgressStore } from './downloadStore';
|
||||
|
||||
describe('useDownloadProgressStore', () => {
|
||||
beforeEach(() => {
|
||||
useDownloadProgressStore.setState({ progressMap: {} });
|
||||
});
|
||||
|
||||
it('prunes terminal progress entries', () => {
|
||||
useDownloadProgressStore.getState().updateDownloadProgress('download-1', {
|
||||
id: 'download-1',
|
||||
fraction: 0.5,
|
||||
speed: '1 MB/s',
|
||||
eta: '10s',
|
||||
size: '2 MB',
|
||||
size_is_final: false
|
||||
});
|
||||
|
||||
useDownloadProgressStore.getState().clearDownloadProgress('download-1');
|
||||
|
||||
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { listenEvent as listen } from '../ipc';
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
clearDownloadProgress: (id: string) => void;
|
||||
}
|
||||
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
@@ -20,6 +21,13 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
|
||||
[id]: payload,
|
||||
},
|
||||
})),
|
||||
clearDownloadProgress: (id) =>
|
||||
set((state) => {
|
||||
if (!(id in state.progressMap)) return state;
|
||||
const next = { ...state.progressMap };
|
||||
delete next[id];
|
||||
return { progressMap: next };
|
||||
}),
|
||||
}));
|
||||
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
@@ -36,12 +44,9 @@ export async function initDownloadListener() {
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current) {
|
||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
||||
mainStore.updateDownload(payload.id, {
|
||||
fraction: payload.fraction,
|
||||
speed: payload.speed,
|
||||
eta: payload.eta,
|
||||
...(shouldUpdateSize ? { size: payload.size! } : {}),
|
||||
});
|
||||
if (shouldUpdateSize && current.size !== payload.size) {
|
||||
mainStore.updateDownload(payload.id, { size: payload.size! });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,7 +57,11 @@ export async function initDownloadListener() {
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current) {
|
||||
const status = payload.status as DownloadStatus;
|
||||
const updates: Partial<any> = { status };
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
const updates: Partial<any> = {
|
||||
status,
|
||||
...(progress ? { fraction: progress.fraction } : {})
|
||||
};
|
||||
if (status !== 'downloading') {
|
||||
updates.speed = '-';
|
||||
updates.eta = '-';
|
||||
@@ -72,6 +81,9 @@ export async function initDownloadListener() {
|
||||
} else if (status === 'completed' || status === 'failed') {
|
||||
mainStore.unregisterBackendIds([payload.id]);
|
||||
}
|
||||
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
|
||||
vi.mock('../ipc', () => ({
|
||||
@@ -172,6 +173,26 @@ describe('useDownloadStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a rejected immediate start instead of claiming success', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'enqueue_download') {
|
||||
throw new Error('backend unavailable');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const added = await useDownloadStore.getState().addDownload({
|
||||
id: 'rejected-start',
|
||||
url: 'https://example.com/rejected.bin',
|
||||
fileName: 'rejected.bin',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}, { type: 'start-now' });
|
||||
|
||||
expect(added).toBe(false);
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('failed');
|
||||
});
|
||||
|
||||
it('redownloads fallback media without requiring a format selector', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
@@ -280,6 +301,33 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
|
||||
});
|
||||
|
||||
it('disables scheduler when its last selected queue is deleted', async () => {
|
||||
const originalSettings = useSettingsStore.getState();
|
||||
const setScheduler = vi.fn();
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
scheduler: {
|
||||
enabled: true,
|
||||
selectedQueueIds: ['queue-a']
|
||||
},
|
||||
setScheduler
|
||||
} as any);
|
||||
useDownloadStore.setState({
|
||||
queues: [
|
||||
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main Queue', isMain: true },
|
||||
{ id: 'queue-a', name: 'Scheduled', isMain: false }
|
||||
],
|
||||
downloads: []
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().removeQueue('queue-a');
|
||||
|
||||
expect(setScheduler).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enabled: false,
|
||||
selectedQueueIds: []
|
||||
}));
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue(originalSettings);
|
||||
});
|
||||
|
||||
it('retains the UI item when backend removal fails', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
|
||||
@@ -125,11 +125,6 @@ const syncSystemIntegrations = () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
|
||||
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
|
||||
if (settings.preventsSleepWhileDownloading) {
|
||||
invoke('set_prevent_sleep', { prevent: activeCount > 0 }).catch(() => {});
|
||||
} else {
|
||||
invoke('set_prevent_sleep', { prevent: false }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveDestinationForItem = async (
|
||||
@@ -198,7 +193,7 @@ interface DownloadState {
|
||||
openDeleteModal: (downloadIds?: string | string[]) => void;
|
||||
closeDeleteModal: () => void;
|
||||
setSelectedPropertiesDownloadId: (id: string | null) => void;
|
||||
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<void>;
|
||||
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
|
||||
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
|
||||
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
|
||||
redownload: (id: string) => Promise<void>;
|
||||
@@ -335,12 +330,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
|
||||
if (action.type === 'add-to-queue') {
|
||||
info(`Download ${item.id} added to queue ${action.queueId}`);
|
||||
return true;
|
||||
} else if (action.type === 'start-now') {
|
||||
if (await dispatchItem(item.id)) {
|
||||
get().updateDownload(item.id, { hasBeenDispatched: true });
|
||||
info(`Download ${item.id} started`);
|
||||
return true;
|
||||
}
|
||||
info(`Download ${item.id} started`);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
applyProperties: async (id, updates) => {
|
||||
const state = get();
|
||||
@@ -676,6 +675,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d
|
||||
)
|
||||
}));
|
||||
const settings = useSettingsStore.getState();
|
||||
if (settings.scheduler.selectedQueueIds.includes(id)) {
|
||||
const selectedQueueIds = settings.scheduler.selectedQueueIds.filter(queueId => queueId !== id);
|
||||
settings.setScheduler({
|
||||
...settings.scheduler,
|
||||
enabled: selectedQueueIds.length > 0 ? settings.scheduler.enabled : false,
|
||||
selectedQueueIds
|
||||
});
|
||||
}
|
||||
},
|
||||
initDB: async () => {
|
||||
try {
|
||||
@@ -764,6 +772,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to init DB", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -261,7 +261,6 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
|
||||
info('Settings updated: preventsSleepWhileDownloading');
|
||||
set({ preventsSleepWhileDownloading });
|
||||
if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error);
|
||||
},
|
||||
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
|
||||
setCategorySubfolder: (category, subfolder) => {
|
||||
@@ -344,6 +343,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
isSidebarVisible: state.isSidebarVisible,
|
||||
activeSettingsTab: state.activeSettingsTab,
|
||||
scheduler: state.scheduler,
|
||||
schedulerRunning: state.schedulerRunning,
|
||||
schedulerActiveDownloadIds: state.schedulerActiveDownloadIds,
|
||||
schedulerLastStartKey: state.schedulerLastStartKey,
|
||||
schedulerLastStopKey: state.schedulerLastStopKey,
|
||||
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
|
||||
|
||||
Reference in New Issue
Block a user