fix(core): harden download lifecycle and scheduling

This commit is contained in:
NimBold
2026-06-22 11:19:18 +03:30
parent d535bdac8f
commit 06b14df307
38 changed files with 1257 additions and 428 deletions
+3 -44
View File
@@ -7,7 +7,7 @@ pub async fn reveal_in_file_manager(
app_handle: tauri::AppHandle,
path: String,
) -> Result<(), String> {
let primary = authorize_download_path(&app_handle, &path, DownloadAsset::Primary)?;
let primary = authorize_download_path(&app_handle, &path)?;
let path = existing_download_asset(&primary).ok_or_else(|| {
format!(
"Downloaded file or partial file is missing: {}",
@@ -28,7 +28,7 @@ pub async fn open_downloaded_file(
app_handle: tauri::AppHandle,
path: String,
) -> Result<(), String> {
let path = authorize_download_path(&app_handle, &path, DownloadAsset::Primary)?;
let path = authorize_download_path(&app_handle, &path)?;
if !path.exists() {
return Err(format!("Downloaded file is missing: {}", path.display()));
}
@@ -41,52 +41,11 @@ pub async fn open_downloaded_file(
Ok(())
}
#[tauri::command]
pub async fn trash_download_assets(
app_handle: tauri::AppHandle,
path: String,
partial_paths: Vec<String>,
) -> Result<(), String> {
let primary = authorize_download_path(&app_handle, &path, DownloadAsset::Primary)?;
let partials = partial_paths
.iter()
.map(|partial| authorize_download_path(&app_handle, partial, DownloadAsset::Partial))
.collect::<Result<Vec<_>, _>>()?;
if primary.exists() {
trash::delete(&primary).map_err(|e| format!("Failed to trash primary file: {}", e))?;
}
for partial in partials {
if partial.exists() {
trash::delete(&partial).map_err(|e| format!("Failed to trash partial file: {}", e))?;
}
}
Ok(())
}
#[derive(Clone, Copy)]
enum DownloadAsset {
Primary,
Partial,
}
fn authorize_download_path(
app_handle: &tauri::AppHandle,
requested: &str,
asset: DownloadAsset,
) -> Result<PathBuf, String> {
let known_paths = known_download_paths(app_handle)?;
let allowed_paths = match asset {
DownloadAsset::Primary => known_paths,
DownloadAsset::Partial => known_paths
.iter()
.flat_map(|path| [append_suffix(path, ".aria2"), append_suffix(path, ".part")])
.collect(),
};
authorize_exact_path(Path::new(requested), &allowed_paths)
authorize_exact_path(Path::new(requested), &known_download_paths(app_handle)?)
}
fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
+5 -2
View File
@@ -26,7 +26,7 @@ pub enum DownloadCmd {
Start(Box<DownloadPayload>),
Pause(Uuid),
PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
Cancel(Uuid),
CancelWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
CaptureUrls(Vec<String>),
FrontendReady(bool),
}
@@ -338,9 +338,12 @@ async fn run_coordinator(
let _ = ack.send(());
}
}
DownloadCmd::Cancel(id) => {
DownloadCmd::CancelWithAck(id, ack) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Cancel).await;
pending_acks.insert(id, ack);
} else {
let _ = ack.send(());
}
}
DownloadCmd::CaptureUrls(urls) => {
+47 -4
View File
@@ -9,6 +9,30 @@ struct DownloadOwnershipRecord {
primary_path: String,
}
pub fn canonical_download_filename(filename: &str) -> String {
let leaf = filename.replace('\\', "/");
let leaf = Path::new(&leaf)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("download");
let sanitized = leaf
.chars()
.map(|character| {
if character.is_control() || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
'-'
} else {
character
}
})
.collect::<String>();
let sanitized = sanitized.trim().trim_end_matches(['.', ' ']);
if sanitized.is_empty() || matches!(sanitized, "." | "..") {
"download".to_string()
} else {
sanitized.to_string()
}
}
pub fn expected_primary_path(
app_handle: &tauri::AppHandle,
destination: &str,
@@ -19,10 +43,7 @@ pub fn expected_primary_path(
return Err("Path traversal blocked".to_string());
}
let safe_filename = Path::new(&filename.replace('\\', "/"))
.file_name()
.ok_or_else(|| "Download filename is invalid".to_string())?
.to_owned();
let safe_filename = canonical_download_filename(filename);
Ok(resolved_dest.join(safe_filename))
}
@@ -67,6 +88,16 @@ pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
crate::db::remove_ownership(&connection, id)
}
pub fn primary_path_for_id(
app_handle: &tauri::AppHandle,
id: &str,
) -> Result<Option<PathBuf>, String> {
Ok(load_records(app_handle)?
.into_iter()
.find(|record| record.id == id)
.map(|record| PathBuf::from(record.primary_path)))
}
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
let mut paths: Vec<PathBuf> = load_records(app_handle)?
.into_iter()
@@ -168,3 +199,15 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::canonical_download_filename;
#[test]
fn canonicalizes_untrusted_download_filenames() {
assert_eq!(canonical_download_filename("../folder/video?.mp4"), "video-.mp4");
assert_eq!(canonical_download_filename(" report. "), "report");
assert_eq!(canonical_download_filename(".."), "download");
}
}
+17 -1
View File
@@ -8,6 +8,8 @@ use ts_rs::TS;
pub enum DownloadStatus {
/// Added to the download list but not assigned to a queue or dispatched.
Ready,
/// Assigned to a queue but intentionally not registered with the backend.
Staged,
Downloading,
/// Post-download media processing such as yt-dlp/ffmpeg merging or
/// extraction. The queue permit is still held.
@@ -25,6 +27,7 @@ impl DownloadStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::Staged => "staged",
Self::Downloading => "downloading",
Self::Processing => "processing",
Self::Paused => "paused",
@@ -100,6 +103,8 @@ pub struct DownloadItem {
#[ts(optional)]
pub queue_id: Option<String>,
#[ts(optional)]
pub queue_position: Option<i32>,
#[ts(optional)]
pub has_been_dispatched: Option<bool>,
}
@@ -110,9 +115,19 @@ pub struct EnqueueResult {
pub id: String,
pub success: bool,
#[ts(optional)]
pub filename: Option<String>,
#[ts(optional)]
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct EnqueueAccepted {
pub id: String,
pub filename: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -170,7 +185,7 @@ pub enum ActiveView {
Scheduler,
#[serde(rename = "speedLimiter")]
SpeedLimiter,
Diagnostics,
Logs,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
@@ -219,6 +234,7 @@ pub struct SchedulerSettings {
pub stop_time: String,
pub everyday: bool,
pub selected_days: Vec<u32>,
pub selected_queue_ids: Vec<String>,
pub post_queue_action: PostQueueAction,
}
+235 -77
View File
@@ -665,6 +665,14 @@ fn aggregate_media_fraction(
}
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;
};
@@ -676,7 +684,9 @@ async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
.and_then(|name| name.to_str())
.unwrap_or(base_name);
let _ = tokio::fs::remove_file(out_path).await;
if remove_primary {
let _ = tokio::fs::remove_file(out_path).await;
}
let Ok(mut entries) = tokio::fs::read_dir(parent).await else {
return;
@@ -1425,7 +1435,7 @@ async fn test_aria2c(app_handle: tauri::AppHandle, state: tauri::State<'_, AppSt
.ok_or_else(|| "aria2 returned an invalid version response".to_string())
}
// ── get_engine_status: Structured engine diagnostics ──────────────
// ── get_engine_status: Structured engine status ──────────────
async fn run_sidecar_version(
app_handle: &tauri::AppHandle,
@@ -1915,11 +1925,7 @@ pub(crate) async fn start_media_download_internal(
max_tries: Option<i32>,
cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
) -> Result<(), String> {
let safe_filename = std::path::Path::new(&filename.replace('\\', "/"))
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let safe_filename = crate::download_ownership::canonical_download_filename(&filename);
let resolved_dest = resolve_path(&destination, &app_handle);
@@ -2413,11 +2419,10 @@ async fn remove_download(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
id: String,
filepath: Option<String>,
delete_assets: bool,
) -> Result<(), String> {
log::info!("remove_download called for id: {}", id);
let _ = crate::download_ownership::remove(&app_handle, &id);
state.queue_manager.release_registered_id(&id).await;
let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?;
let active_kind = state.queue_manager.active_kind(&id).await;
state.queue_manager.remove_from_pending(&id).await;
@@ -2447,9 +2452,8 @@ async fn remove_download(
} else if let Ok(download_id) = Uuid::parse_str(&id) {
state
.download_coordinator
.send(download::DownloadCmd::Cancel(download_id))
.send(download::DownloadCmd::CancelWithAck(download_id, tx))
.await?;
let _ = tx.send(());
} else {
let _ = tx.send(());
}
@@ -2468,41 +2472,61 @@ async fn remove_download(
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
);
if let Some(path) = filepath {
if !path.is_empty() {
let p = std::path::Path::new(&path);
if is_safe_path(p, &app_handle) {
if p.exists() {
let _ = tokio::fs::remove_file(p).await;
}
let aria2_path = format!("{}.aria2", path);
let p_aria2 = std::path::Path::new(&aria2_path);
if p_aria2.exists() {
let _ = tokio::fs::remove_file(p_aria2).await;
}
if delete_assets {
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?;
}
if let Some(parent) = p.parent() {
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
let stem_with_dot = format!("{}.", stem);
if let Ok(mut entries) = tokio::fs::read_dir(parent).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if let Ok(file_name) = entry.file_name().into_string() {
if file_name.starts_with(&stem_with_dot) &&
(file_name.ends_with(".part") || file_name.ends_with(".ytdl") || file_name.ends_with(".aria2")) {
let path_to_remove = entry.path();
if is_safe_path(&path_to_remove, &app_handle) {
let _ = tokio::fs::remove_file(path_to_remove).await;
}
}
}
}
}
}
}
}
crate::download_ownership::remove(&app_handle, &id)?;
state.queue_manager.release_registered_id(&id).await;
Ok(())
}
async fn remove_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());
}
if primary.exists() {
trash::delete(primary)
.map_err(|error| format!("failed to move downloaded file to Trash: {error}"))?;
}
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_processing_artifacts(primary).await;
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(())
}
@@ -2703,17 +2727,43 @@ fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), Stri
}
#[tauri::command]
async fn get_pending_order(state: tauri::State<'_, AppState>) -> Result<Vec<String>, AppError> {
Ok(state.queue_manager.pending_order().await)
fn ack_schedule_trigger(
app_handle: tauri::AppHandle,
action: String,
key: String,
) -> Result<(), String> {
crate::settings::update_settings_state(&app_handle, |state| match action.as_str() {
"start" => {
state.insert("schedulerLastStartKey".to_string(), serde_json::json!(key));
}
"stop" => {
state.insert("schedulerLastStopKey".to_string(), serde_json::json!(key));
}
_ => {}
})?;
match action.as_str() {
"start" | "stop" => Ok(()),
_ => Err("Unknown scheduler trigger action".to_string()),
}
}
#[tauri::command]
async fn get_pending_order(
state: tauri::State<'_, AppState>,
queue_id: Option<String>,
) -> Result<Vec<String>, AppError> {
Ok(state.queue_manager.pending_order(queue_id.as_deref()).await)
}
#[tauri::command]
async fn enqueue_download(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
item: queue::EnqueueItem,
) -> Result<String, AppError> {
mut item: queue::EnqueueItem,
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
let id = item.id.clone();
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
let accepted_filename = item.filename.clone();
crate::download_ownership::register_expected(
&app_handle,
&item.id,
@@ -2725,15 +2775,21 @@ async fn enqueue_download(
state.queue_manager.release_registered_id(&id).await;
return Err(AppError::Internal(e));
}
Ok(id)
Ok(crate::ipc::EnqueueAccepted {
id,
filename: accepted_filename,
})
}
#[tauri::command]
async fn enqueue_many(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
items: Vec<queue::EnqueueItem>,
mut items: Vec<queue::EnqueueItem>,
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
for item in &mut items {
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
}
for item in &items {
crate::download_ownership::register_expected(
&app_handle,
@@ -2759,9 +2815,13 @@ async fn enqueue_many(
async fn move_in_queue(
state: tauri::State<'_, AppState>,
id: String,
queue_id: String,
direction: crate::ipc::QueueDirection,
) -> Result<Vec<String>, AppError> {
Ok(state.queue_manager.move_in_queue(&id, direction).await)
Ok(state
.queue_manager
.move_in_queue(&id, &queue_id, direction)
.await)
}
#[tauri::command]
@@ -3010,26 +3070,120 @@ fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String>
}
}
#[tauri::command]
async fn export_logs(app_handle: tauri::AppHandle, dest_path: String) -> Result<String, String> {
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())?;
let log_file = log_dir.join("firelink.log");
let src = if log_file.exists() {
log_file
} else {
let mut found = None;
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.path().extension().is_some_and(|e| e == "log") {
found = Some(entry.path());
break;
}
let mut files = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.is_file()
&& path
.file_name()
.is_some_and(|name| name.to_string_lossy().contains(".log"))
{
files.push(path);
}
}
found.ok_or_else(|| "No log file found in app log directory".to_string())?
};
tokio::fs::copy(&src, &dest_path).await.map_err(|e| e.to_string())?;
}
files.sort();
Ok(files)
}
fn redact_log_line(line: &str) -> String {
use std::sync::OnceLock;
static SECRET: OnceLock<regex::Regex> = OnceLock::new();
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
let secret = SECRET.get_or_init(|| {
regex::Regex::new(
r"(?i)(authorization|cookie|password|token|secret)\s*[:=]\s*([^\s,;]+)",
)
.expect("valid secret redaction regex")
});
let query = QUERY.get_or_init(|| {
regex::Regex::new(r"(https?://[^\s?]+)\?[^\s]+")
.expect("valid URL query redaction regex")
});
let redacted = secret.replace_all(line, "$1=[redacted]");
query.replace_all(&redacted, "$1?[redacted]").into_owned()
}
#[tauri::command]
async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result<Vec<String>, String> {
let mut lines = Vec::new();
for file in log_files(&app_handle).await? {
let content = tokio::fs::read_to_string(&file)
.await
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
lines.extend(content.lines().map(redact_log_line));
}
let keep = limit.clamp(1, 10_000);
if lines.len() > keep {
lines.drain(..lines.len() - keep);
}
Ok(lines)
}
#[tauri::command]
async fn export_logs(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
dest_path: String,
) -> Result<String, String> {
let mut output = format!(
"Firelink support logs\nVersion: {}\nOS: {} {}\nArchitecture: {}\nGenerated: {}\n\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::FAMILY,
std::env::consts::ARCH,
chrono::Utc::now().to_rfc3339(),
);
let (aria2, ytdlp, ffmpeg, deno) = tokio::join!(
check_aria2(&app_handle, state.aria2_port, &state.aria2_secret),
check_ytdlp(&app_handle),
check_ffmpeg(&app_handle),
check_deno(&app_handle),
);
output.push_str("Engine status:\n");
for engine in [aria2, ytdlp, ffmpeg, deno] {
output.push_str(&format!(
"- {}: {}{}\n",
engine.name,
if engine.ready { "ready" } else { "unavailable" },
engine
.version
.as_deref()
.map(|version| format!(" ({version})"))
.unwrap_or_default()
));
if let Some(error) = engine.error {
output.push_str(&format!(" Error: {}\n", redact_log_line(&error)));
}
}
if let Ok(settings) = crate::settings::load_settings(&app_handle) {
output.push_str(&format!(
"\nRuntime settings:\n- Max concurrent downloads: {}\n- Per-server connections: {}\n- Automatic retries: {}\n- Proxy mode: {:?}\n- Scheduler enabled: {}\n\n",
settings.max_concurrent_downloads,
settings.per_server_connections,
settings.max_automatic_retries,
settings.proxy_mode,
settings.scheduler.enabled,
));
}
for file in log_files(&app_handle).await? {
output.push_str(&format!("===== {} =====\n", file.display()));
let content = tokio::fs::read_to_string(&file)
.await
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
for line in content.lines() {
output.push_str(&redact_log_line(line));
output.push('\n');
}
output.push('\n');
}
tokio::fs::write(&dest_path, output)
.await
.map_err(|e| e.to_string())?;
Ok(dest_path)
}
@@ -3154,7 +3308,7 @@ mod tests {
aggregate_media_fraction, build_media_format_options, collect_download_uris,
is_excluded_yt_dlp_format, json_lower, media_progress_speed,
normalize_speed_limit_for_aria2, parse_firelink_urls, parse_media_progress_line,
MediaProgress, MEDIA_PROGRESS_PREFIX,
redact_log_line, MediaProgress, MEDIA_PROGRESS_PREFIX,
};
use serde_json::json;
use std::time::{Duration, Instant};
@@ -3168,6 +3322,16 @@ mod tests {
assert_eq!(normalize_speed_limit_for_aria2("bad"), None);
}
#[test]
fn redacts_secrets_and_signed_url_queries_from_support_logs() {
let line = "Authorization: bearer-secret Cookie=session=abc https://example.com/file?token=secret";
let redacted = redact_log_line(line);
assert!(!redacted.contains("bearer-secret"));
assert!(!redacted.contains("session=abc"));
assert!(!redacted.contains("token=secret"));
assert!(redacted.contains("[redacted]"));
}
#[test]
fn collects_primary_url_and_unique_mirrors_in_order() {
let uris = collect_download_uris(
@@ -3802,18 +3966,11 @@ pub fn run() {
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
])
.level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info })
.max_file_size(10_000_000)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
.format(move |out, message, _record| {
let msg = message.to_string();
if msg.contains("[download]") && msg.contains('%') {
return;
}
out.finish(format_args!("{}\n", msg));
})
.timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal)
.build(),
)
.plugin(tauri_plugin_dialog::init())
@@ -3833,6 +3990,7 @@ pub fn run() {
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder,
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,
set_keychain_password, get_keychain_password, delete_keychain_password,
hydrate_extension_pairing_token, acknowledge_pairing_token_change,
@@ -3840,12 +3998,12 @@ pub fn run() {
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,
commands::reveal_in_file_manager, commands::open_downloaded_file, commands::trash_download_assets,
commands::reveal_in_file_manager, commands::open_downloaded_file,
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
parity::create_category_directories,
db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads,
db_get_all_queues, db_replace_queues,
export_logs
read_logs, export_logs
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
+30 -23
View File
@@ -37,6 +37,7 @@ pub enum TaskKind {
#[derive(Debug, Clone)]
pub struct QueuedTask {
pub id: String,
pub queue_id: String,
pub kind: TaskKind,
pub payload: SpawnPayload,
}
@@ -158,11 +159,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
/// Current pending order, as id list. Returned by move_in_queue.
pub async fn pending_order(&self) -> Vec<String> {
pub async fn pending_order(&self, queue_id: Option<&str>) -> Vec<String> {
self.pending
.lock()
.await
.iter()
.filter(|task| queue_id.is_none_or(|queue_id| task.queue_id == queue_id))
.map(|t| t.id.clone())
.collect()
}
@@ -721,26 +723,38 @@ impl<R: tauri::Runtime> QueueManager<R> {
pub async fn move_in_queue(
&self,
id: &str,
queue_id: &str,
direction: QueueDirection,
) -> Vec<String> {
let mut pending = self.pending.lock().await;
let pos = pending.iter().position(|t| t.id == id);
if let Some(pos) = pos {
let queue_positions = pending
.iter()
.enumerate()
.filter_map(|(index, task)| (task.queue_id == queue_id).then_some(index))
.collect::<Vec<_>>();
let queue_pos = queue_positions
.iter()
.position(|index| pending[*index].id == id);
if let Some(queue_pos) = queue_pos {
let target = match direction {
QueueDirection::Up => pos.checked_sub(1),
QueueDirection::Up => queue_pos.checked_sub(1),
QueueDirection::Down => {
if pos + 1 < pending.len() {
Some(pos + 1)
if queue_pos + 1 < queue_positions.len() {
Some(queue_pos + 1)
} else {
None
}
}
};
if let Some(target) = target {
pending.swap(pos, target);
pending.swap(queue_positions[queue_pos], queue_positions[target]);
}
}
pending.iter().map(|t| t.id.clone()).collect()
pending
.iter()
.filter(|task| task.queue_id == queue_id)
.map(|task| task.id.clone())
.collect()
}
/// Remove a task from pending if present (used by remove_download).
@@ -765,10 +779,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
for task in tasks {
let id = task.id.clone();
let filename = task.payload.filename.clone();
if registered.contains(&id) {
results.push(crate::ipc::EnqueueResult {
id: id.clone(),
success: false,
filename: None,
error: Some("Duplicate task".to_string()),
});
continue;
@@ -779,6 +795,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
results.push(crate::ipc::EnqueueResult {
id,
success: true,
filename: Some(filename),
error: None,
});
}
@@ -814,11 +831,7 @@ impl SidecarSpawner for ProductionSpawner {
"dir".to_string(),
serde_json::json!(resolved_dest.to_string_lossy().to_string()),
);
let safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/"))
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
options.insert("out".to_string(), serde_json::json!(safe_filename));
let conn = payload.connections.unwrap_or(1);
options.insert("split".to_string(), serde_json::json!(conn.to_string()));
@@ -880,11 +893,7 @@ impl SidecarSpawner for ProductionSpawner {
log::warn!("aria2 addUri failed, falling back to native: {}", e);
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
let mt = payload.max_tries.unwrap_or(1).max(1) as u32;
let safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/"))
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
state
.download_coordinator
.send(crate::download::DownloadCmd::Start(Box::new(
@@ -969,11 +978,7 @@ impl SidecarSpawner for ProductionSpawner {
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
let mt = payload.max_tries.unwrap_or(1).max(1) as u32;
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
let safe_filename = std::path::Path::new(&payload.filename.replace('\\', "/"))
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
let output_path = resolved_dest.join(safe_filename);
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &output_path);
state
@@ -1002,6 +1007,7 @@ impl SidecarSpawner for ProductionSpawner {
#[ts(export, export_to = "../../src/bindings/")]
pub struct EnqueueItem {
pub id: String,
pub queue_id: String,
pub url: String,
pub destination: String,
pub filename: String,
@@ -1032,6 +1038,7 @@ impl EnqueueItem {
let id = self.id.clone();
QueuedTask {
id,
queue_id: self.queue_id,
kind,
payload: SpawnPayload {
url: self.url,
+51 -19
View File
@@ -1,10 +1,19 @@
use chrono::{Datelike, Local};
use chrono::{Datelike, Local, Timelike};
use std::collections::HashMap;
use std::time::Duration;
use tauri::Emitter;
fn minute_of_day(value: &str) -> Option<u32> {
let (hour, minute) = value.split_once(':')?;
let hour = hour.parse::<u32>().ok()?;
let minute = minute.parse::<u32>().ok()?;
(hour < 24 && minute < 60).then_some(hour * 60 + minute)
}
pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
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;
@@ -15,7 +24,7 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
}
let now = Local::now();
let current_time = now.format("%H:%M").to_string();
let current_minute = now.hour() * 60 + now.minute();
let current_day = now.weekday().num_days_from_sunday();
let allowed_today =
@@ -25,37 +34,60 @@ pub fn spawn_scheduler(app_handle: tauri::AppHandle) {
}
let date_key = now.format("%Y-%m-%d").to_string();
let trigger_key = format!("{}-{}", date_key, current_time);
let start_key = format!("{date_key}-start");
let stop_key = format!("{date_key}-stop");
let start_minute = minute_of_day(&scheduler.start_time);
let stop_minute = minute_of_day(&scheduler.stop_time);
let before_stop = !scheduler.stop_time_enabled
|| stop_minute.is_some_and(|stop| current_minute < stop);
if scheduler.start_time == current_time
&& settings.scheduler_last_start_key != trigger_key
if start_minute.is_some_and(|start| current_minute >= start)
&& before_stop
&& settings.scheduler_last_start_key != start_key
&& last_emit
.get("start")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{
let key = trigger_key.clone();
let _ = crate::settings::update_settings_state(&app_handle, |state| {
state.insert("schedulerLastStartKey".to_string(), serde_json::json!(key));
state.insert("schedulerRunning".to_string(), serde_json::json!(true));
});
let _ = app_handle.emit("schedule-trigger", serde_json::json!({
"action": "start",
"key": key
"key": start_key
}));
last_emit.insert("start", std::time::Instant::now());
}
if scheduler.stop_time_enabled
&& scheduler.stop_time == current_time
&& settings.scheduler_last_stop_key != trigger_key
&& stop_minute.is_some_and(|stop| current_minute >= stop)
&& settings.scheduler_last_stop_key != stop_key
&& last_emit
.get("stop")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{
let key = trigger_key.clone();
let _ = crate::settings::update_settings_state(&app_handle, |state| {
state.insert("schedulerLastStopKey".to_string(), serde_json::json!(key));
state.insert("schedulerRunning".to_string(), serde_json::json!(false));
});
let _ = app_handle.emit("schedule-trigger", serde_json::json!({
"action": "stop",
"key": key
"key": stop_key
}));
last_emit.insert("stop", std::time::Instant::now());
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::minute_of_day;
#[test]
fn parses_valid_scheduler_times() {
assert_eq!(minute_of_day("00:00"), Some(0));
assert_eq!(minute_of_day("23:59"), Some(1439));
assert_eq!(minute_of_day("06:30"), Some(390));
}
#[test]
fn rejects_invalid_scheduler_times() {
assert_eq!(minute_of_day("24:00"), None);
assert_eq!(minute_of_day("12:60"), None);
assert_eq!(minute_of_day("bad"), None);
}
}
+5
View File
@@ -243,6 +243,7 @@ fn default_settings() -> PersistedSettings {
stop_time: "08:00".to_string(),
everyday: true,
selected_days: vec![0, 1, 2, 3, 4, 5, 6],
selected_queue_ids: vec!["00000000-0000-0000-0000-000000000001".to_string()],
post_queue_action: PostQueueAction::None,
},
scheduler_last_start_key: String::new(),
@@ -299,6 +300,10 @@ mod tests {
assert!(settings.scheduler.enabled);
assert_eq!(settings.scheduler.start_time, "06:30");
assert_eq!(settings.scheduler.selected_days, vec![1, 3, 5]);
assert_eq!(
settings.scheduler.selected_queue_ids,
vec!["00000000-0000-0000-0000-000000000001"]
);
assert_eq!(settings.base_download_folder, "~/Downloads");
}