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:
NimBold
2026-06-22 18:05:04 +03:30
parent 6b3753949b
commit 7c835b022f
17 changed files with 529 additions and 181 deletions
+2
View File
@@ -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
View File
@@ -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,
+66 -8
View File
@@ -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",
));
}
}
+55 -1
View File
@@ -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!({