style: run cargo fmt to resolve CI failure

This commit is contained in:
NimBold
2026-06-25 00:46:21 +03:30
parent b3f4074d67
commit e03a3b5dbb
16 changed files with 963 additions and 510 deletions
+10 -14
View File
@@ -168,24 +168,20 @@ mod tests {
assert!(
authorize_exact_path(Path::new("owned.bin"), std::slice::from_ref(&owned)).is_err()
);
assert!(
authorize_exact_path(
&root.path().join("sub/../owned.bin"),
std::slice::from_ref(&owned)
)
.is_err()
);
assert!(authorize_exact_path(
&root.path().join("sub/../owned.bin"),
std::slice::from_ref(&owned)
)
.is_err());
assert!(
authorize_exact_path(Path::new("/etc/hosts"), std::slice::from_ref(&owned)).is_err()
);
if let Some(home) = std::env::var_os("HOME") {
assert!(
authorize_exact_path(
&PathBuf::from(home).join(".ssh"),
std::slice::from_ref(&owned)
)
.is_err()
);
assert!(authorize_exact_path(
&PathBuf::from(home).join(".ssh"),
std::slice::from_ref(&owned)
)
.is_err());
}
}
+26 -21
View File
@@ -80,11 +80,7 @@ fn init_at_path_internal(app_data_dir: &Path) -> Result<DbState, String> {
// We no longer touch the keychain on backend startup.
// Legacy imports will safely preserve any pairing token in the JSON payload.
// The frontend will manually trigger migration to the keychain via IPC if access is granted.
import_legacy_data(
&mut connection,
app_data_dir,
false,
)?;
import_legacy_data(&mut connection, app_data_dir, false)?;
Ok(DbState {
conn: Mutex::new(connection),
@@ -357,41 +353,50 @@ pub fn sanitize_current_settings_and_restore_token(
let Some(settings) = load_settings(connection)? else {
return Ok((false, false));
};
let (sanitized, legacy_token, keychain_granted) = sanitize_settings_text(&settings, force_migrate)?;
let (sanitized, legacy_token, keychain_granted) =
sanitize_settings_text(&settings, force_migrate)?;
if sanitized == settings {
return Ok((false, keychain_granted));
}
let should_migrate = force_migrate || keychain_granted;
if should_migrate
&& get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err() {
if let Some(token) = legacy_token.filter(|token| !token.trim().is_empty()) {
if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token) {
log::warn!(
if should_migrate && get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err() {
if let Some(token) = legacy_token.filter(|token| !token.trim().is_empty()) {
if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token) {
log::warn!(
"Persisted pairing token could not be migrated yet; original settings retained: {}",
error
);
return Ok((true, keychain_granted));
}
return Ok((true, keychain_granted));
}
}
}
save_settings(connection, &sanitized)?;
Ok((false, keychain_granted))
}
fn sanitize_settings_value(value: &Value, force_migrate: bool) -> Result<(String, Option<String>, bool), String> {
fn sanitize_settings_value(
value: &Value,
force_migrate: bool,
) -> Result<(String, Option<String>, bool), String> {
match value {
Value::String(text) => sanitize_settings_text(text, force_migrate),
_ => sanitize_settings_document(value.clone(), force_migrate),
}
}
fn sanitize_settings_text(text: &str, force_migrate: bool) -> Result<(String, Option<String>, bool), String> {
fn sanitize_settings_text(
text: &str,
force_migrate: bool,
) -> Result<(String, Option<String>, bool), String> {
let document: Value = serde_json::from_str(text)
.map_err(|error| format!("failed to decode persisted settings: {error}"))?;
sanitize_settings_document(document, force_migrate)
}
fn sanitize_settings_document(mut document: Value, force_migrate: bool) -> Result<(String, Option<String>, bool), String> {
fn sanitize_settings_document(
mut document: Value,
force_migrate: bool,
) -> Result<(String, Option<String>, bool), String> {
let state_value = if document.get("state").is_some() {
document
.get_mut("state")
@@ -402,14 +407,14 @@ fn sanitize_settings_document(mut document: Value, force_migrate: bool) -> Resul
let state = state_value
.as_object_mut()
.ok_or_else(|| "persisted settings state must be an object".to_string())?;
let keychain_granted = state
.get("keychainAccessGranted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let should_migrate = force_migrate || keychain_granted;
let token = if should_migrate {
state
.remove("extensionPairingToken")
@@ -417,7 +422,7 @@ fn sanitize_settings_document(mut document: Value, force_migrate: bool) -> Resul
} else {
None
};
let serialized = serde_json::to_string(&document)
.map_err(|error| format!("failed to encode persisted settings: {error}"))?;
Ok((serialized, token, keychain_granted))
@@ -755,7 +760,7 @@ pub fn hydrate_pairing_token(
if skip_keychain {
return Ok((generate_pairing_token(), false));
}
let existing = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).ok();
let generated = generate_pairing_token();
let decision = decide_pairing_token(
+38 -13
View File
@@ -118,7 +118,11 @@ impl DownloadCoordinator {
.map_err(|_| "download coordinator is unavailable".to_string())
}
pub async fn pause_media_with_ack(&self, id: String, ack: tokio::sync::oneshot::Sender<()>) -> Result<(), String> {
pub async fn pause_media_with_ack(
&self,
id: String,
ack: tokio::sync::oneshot::Sender<()>,
) -> Result<(), String> {
self.media_tx
.send(MediaCmd::PauseWithAck(id, ack))
.await
@@ -474,14 +478,27 @@ async fn download_file(
let mut attempts = 0_usize;
loop {
attempts += 1;
match download_attempt(&events, &client, &default_headers, &payload, url, &mut control_rx).await {
match download_attempt(
&events,
&client,
&default_headers,
&payload,
url,
&mut control_rx,
)
.await
{
Ok(()) => return DownloadOutcome::Completed,
Err(AttemptError::Controlled(DownloadControl::Pause)) => {
return DownloadOutcome::Paused;
}
Err(AttemptError::Controlled(DownloadControl::Cancel)) => {
if let Err(e) = fs::remove_file(&payload.output_path).await {
log::warn!("Failed to remove cancelled file '{}': {}", payload.output_path.display(), e);
log::warn!(
"Failed to remove cancelled file '{}': {}",
payload.output_path.display(),
e
);
}
return DownloadOutcome::Cancelled;
}
@@ -591,7 +608,9 @@ async fn download_attempt(
.get(reqwest::header::CONTENT_RANGE)
.and_then(|h| h.to_str().ok());
if !content_range.is_some_and(|r| r.starts_with(&format!("bytes {}-", existing_len))) {
return Err(AttemptError::Failed("Server returned invalid Content-Range for resume".to_string()));
return Err(AttemptError::Failed(
"Server returned invalid Content-Range for resume".to_string(),
));
}
}
let completed_at_start = if resumed { existing_len } else { 0 };
@@ -721,11 +740,17 @@ fn build_client(payload: &DownloadPayload) -> Result<(Client, HeaderMap), String
if proxy == "none" {
builder = builder.no_proxy();
} else {
builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|_| "Invalid proxy URL configured".to_string())?);
builder = builder.proxy(
reqwest::Proxy::all(proxy)
.map_err(|_| "Invalid proxy URL configured".to_string())?,
);
}
}
builder.build().map_err(|error| error.to_string()).map(|c| (c, headers))
builder
.build()
.map_err(|error| error.to_string())
.map(|c| (c, headers))
}
pub(crate) fn format_speed(bytes_per_second: f64) -> String {
@@ -808,14 +833,16 @@ mod tests {
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
coordinator
.send(DownloadCmd::CaptureUrls(vec![
"https://example.com/startup.zip".to_string()
"https://example.com/startup.zip".to_string(),
]))
.await
.unwrap();
assert!(tokio::time::timeout(Duration::from_millis(20), events.recv())
.await
.is_err());
assert!(
tokio::time::timeout(Duration::from_millis(20), events.recv())
.await
.is_err()
);
coordinator
.send(DownloadCmd::FrontendReady(true))
@@ -827,9 +854,7 @@ mod tests {
.await
.unwrap()
.unwrap(),
DownloadEvent::CapturedUrls(
"https://example.com/startup.zip".to_string()
)
DownloadEvent::CapturedUrls("https://example.com/startup.zip".to_string())
);
}
}
+15 -5
View File
@@ -18,7 +18,12 @@ pub fn canonical_download_filename(filename: &str) -> String {
let sanitized = leaf
.chars()
.map(|character| {
if character.is_control() || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
if character.is_control()
|| matches!(
character,
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'
)
{
'-'
} else {
character
@@ -30,7 +35,10 @@ pub fn canonical_download_filename(filename: &str) -> String {
"download".to_string()
} else if crate::platform::is_windows_reserved_filename(sanitized) {
let path = Path::new(sanitized);
let stem = path.file_stem().and_then(|value| value.to_str()).unwrap_or("download");
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("download");
match path.extension().and_then(|value| value.to_str()) {
Some(extension) => format!("{stem}-.{extension}"),
None => format!("{stem}-"),
@@ -135,7 +143,7 @@ fn load_records(app_handle: &tauri::AppHandle) -> Result<Vec<DownloadOwnershipRe
fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
let settings = crate::settings::load_settings(app_handle).ok();
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
let downloads = crate::db::load_downloads(&connection)?
@@ -144,7 +152,6 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("Invalid download queue ownership data: {error}"))?;
let mut paths = Vec::new();
for download in downloads {
let category = format!("{:?}", download.category);
@@ -214,7 +221,10 @@ mod tests {
#[test]
fn canonicalizes_untrusted_download_filenames() {
assert_eq!(canonical_download_filename("../folder/video?.mp4"), "video-.mp4");
assert_eq!(
canonical_download_filename("../folder/video?.mp4"),
"video-.mp4"
);
assert_eq!(canonical_download_filename(" report. "), "report");
assert_eq!(canonical_download_filename(".."), "download");
assert_eq!(canonical_download_filename("CON.txt"), "CON-.txt");
+8 -7
View File
@@ -72,7 +72,10 @@ fn executable_relative_candidates(
.join("engine-dist")
.join(target)
.join(binary_name),
executable_dir.join("engines").join(target).join(binary_name),
executable_dir
.join("engines")
.join(target)
.join(binary_name),
];
if cfg!(target_os = "macos") {
@@ -99,11 +102,7 @@ fn development_candidates(cwd: &Path, target: &str, binary_name: &str) -> Vec<Pa
let roots = [cwd.to_path_buf(), cwd.join("src-tauri")];
let mut candidates = Vec::new();
for root in roots {
candidates.push(
root.join("engine-dist")
.join(target)
.join(binary_name),
);
candidates.push(root.join("engine-dist").join(target).join(binary_name));
candidates.push(root.join("binaries").join(target).join(binary_name));
if cfg!(target_os = "macos") {
candidates.push(root.join("binaries").join(binary_name));
@@ -130,7 +129,9 @@ mod tests {
);
assert_eq!(
candidates[0],
Path::new("/resources/engine-dist/x86_64-unknown-linux-gnu/yt-dlp-x86_64-unknown-linux-gnu")
Path::new(
"/resources/engine-dist/x86_64-unknown-linux-gnu/yt-dlp-x86_64-unknown-linux-gnu"
)
);
}
+18 -15
View File
@@ -124,13 +124,14 @@ pub async fn start_server(
async fn add_server_identity(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.insert(SERVER_HEADER, HeaderValue::from_static("1"));
response
.headers_mut()
.insert(PROTOCOL_VERSION_HEADER, HeaderValue::from_static(PROTOCOL_VERSION));
response
response
.headers_mut()
.insert(SERVER_HEADER, HeaderValue::from_static("1"));
response.headers_mut().insert(
PROTOCOL_VERSION_HEADER,
HeaderValue::from_static(PROTOCOL_VERSION),
);
response
}
async fn bind_extension_listener() -> Result<(u16, tokio::net::TcpListener), String> {
@@ -382,7 +383,7 @@ fn is_allowed_origin(origin: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{add_server_identity, PROTOCOL_VERSION_HEADER, SERVER_HEADER};
use super::{add_server_identity, PROTOCOL_VERSION_HEADER, SERVER_HEADER};
use axum::{http::StatusCode, middleware, routing::get, Router};
#[tokio::test]
@@ -398,13 +399,15 @@ mod tests {
axum::serve(listener, app).await.unwrap();
});
let response = reqwest::get(format!("http://{address}/ping")).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
"2"
);
let response = reqwest::get(format!("http://{address}/ping"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(response.headers().get(SERVER_HEADER).unwrap(), "1");
assert_eq!(
response.headers().get(PROTOCOL_VERSION_HEADER).unwrap(),
"2"
);
server.abort();
}
+614 -296
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,5 +1,8 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")]
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
fn main() {
firelink_lib::run()
+59 -24
View File
@@ -8,7 +8,11 @@ pub async fn get_system_proxy() -> Result<Option<String>, String> {
match sysproxy::Sysproxy::get_system_proxy() {
Ok(proxy) => {
if proxy.enable {
let protocol = if proxy.host.contains("://") { "" } else { "http://" };
let protocol = if proxy.host.contains("://") {
""
} else {
"http://"
};
Ok(Some(format!("{}{}:{}", protocol, proxy.host, proxy.port)))
} else {
Ok(None)
@@ -26,12 +30,28 @@ pub fn get_file_category(filename: String) -> DownloadCategory {
.map(|s| s.to_lowercase())
.unwrap_or_default();
let music_exts = ["mp3", "wav", "aac", "flac", "ogg", "m4a", "wma", "alac", "ape", "mid", "midi"];
let movie_exts = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpeg", "mpg", "3gp", "ts", "vob"];
let compressed_exts = ["zip", "rar", "7z", "tar", "gz", "xz", "bz2", "lz", "lzma", "zst", "iso", "cab", "tgz", "tbz", "z", "sit", "sitx"];
let picture_exts = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "svg", "ico", "heic", "raw", "psd", "ai"];
let document_exts = ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv", "md", "epub", "mobi", "azw3"];
let app_exts = ["exe", "msi", "bat", "cmd", "app", "dmg", "pkg", "apk", "appx", "deb", "rpm", "appimage", "run", "sh", "bin", "jar"];
let music_exts = [
"mp3", "wav", "aac", "flac", "ogg", "m4a", "wma", "alac", "ape", "mid", "midi",
];
let movie_exts = [
"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpeg", "mpg", "3gp", "ts", "vob",
];
let compressed_exts = [
"zip", "rar", "7z", "tar", "gz", "xz", "bz2", "lz", "lzma", "zst", "iso", "cab", "tgz",
"tbz", "z", "sit", "sitx",
];
let picture_exts = [
"jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "svg", "ico", "heic", "raw", "psd",
"ai",
];
let document_exts = [
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "csv", "md", "epub",
"mobi", "azw3",
];
let app_exts = [
"exe", "msi", "bat", "cmd", "app", "dmg", "pkg", "apk", "appx", "deb", "rpm", "appimage",
"run", "sh", "bin", "jar",
];
if music_exts.contains(&ext.as_str()) {
DownloadCategory::Musics
@@ -65,8 +85,13 @@ pub struct AvailableReleaseUpdate {
#[serde(tag = "type")]
#[ts(export, export_to = "../../src/bindings/")]
pub enum ReleaseCheckOutcome {
UpdateAvailable { update: AvailableReleaseUpdate },
UpToDate { latest_version: String, local_version: String },
UpdateAvailable {
update: AvailableReleaseUpdate,
},
UpToDate {
latest_version: String,
local_version: String,
},
}
#[derive(Deserialize)]
@@ -81,11 +106,14 @@ struct GitHubRelease {
}
#[tauri::command]
pub async fn check_for_updates(app_handle: tauri::AppHandle) -> Result<ReleaseCheckOutcome, String> {
pub async fn check_for_updates(
app_handle: tauri::AppHandle,
) -> Result<ReleaseCheckOutcome, String> {
let current_version = app_handle.package_info().version.to_string();
let client = reqwest::Client::new();
let res = client.get("https://api.github.com/repos/nimbold/Firelink/releases?per_page=30")
let res = client
.get("https://api.github.com/repos/nimbold/Firelink/releases?per_page=30")
.header("User-Agent", "Firelink")
.header("Accept", "application/vnd.github+json")
.send()
@@ -97,11 +125,12 @@ pub async fn check_for_updates(app_handle: tauri::AppHandle) -> Result<ReleaseCh
}
let releases: Vec<GitHubRelease> = res.json().await.map_err(|e| e.to_string())?;
let latest_stable = releases.into_iter()
let latest_stable = releases
.into_iter()
.filter(|r| !r.draft && !r.prerelease)
.max_by(|a, b| cmp_versions(&a.tag_name, &b.tag_name));
let release = match latest_stable {
Some(r) => r,
None => return Err("No stable release was found.".to_string()),
@@ -115,10 +144,12 @@ pub async fn check_for_updates(app_handle: tauri::AppHandle) -> Result<ReleaseCh
version: latest_version.clone(),
tag_name: release.tag_name.clone(),
title: release.name.unwrap_or(release.tag_name),
release_notes: release.body.unwrap_or_else(|| "No release notes were provided for this version.".to_string()),
release_notes: release.body.unwrap_or_else(|| {
"No release notes were provided for this version.".to_string()
}),
release_url: release.html_url,
published_at: release.published_at,
}
},
})
} else {
Ok(ReleaseCheckOutcome::UpToDate {
@@ -130,13 +161,13 @@ pub async fn check_for_updates(app_handle: tauri::AppHandle) -> Result<ReleaseCh
fn cmp_versions(a: &str, b: &str) -> std::cmp::Ordering {
use semver::Version;
let a_clean = a.trim_start_matches(['v', 'V']);
let b_clean = b.trim_start_matches(['v', 'V']);
let a_ver = Version::parse(a_clean).unwrap_or_else(|_| Version::new(0, 0, 0));
let b_ver = Version::parse(b_clean).unwrap_or_else(|_| Version::new(0, 0, 0));
a_ver.cmp(&b_ver)
}
@@ -181,14 +212,18 @@ pub async fn create_category_directories(
}
pub static SUPPORTED_DOMAINS: &[&str] = &[
"youtube.com", "youtu.be",
"twitter.com", "x.com",
"youtube.com",
"youtu.be",
"twitter.com",
"x.com",
"vimeo.com",
"twitch.tv",
"instagram.com",
"tiktok.com",
"facebook.com", "fb.watch",
"reddit.com", "v.redd.it",
"facebook.com",
"fb.watch",
"reddit.com",
"v.redd.it",
"soundcloud.com",
];
+14 -4
View File
@@ -97,8 +97,10 @@ pub fn is_windows_reserved_filename(filename: &str) -> bool {
.unwrap_or(filename)
.trim_end_matches(['.', ' '])
.to_ascii_uppercase();
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$")
|| numbered_windows_device(&stem, "COM")
matches!(
stem.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$"
) || numbered_windows_device(&stem, "COM")
|| numbered_windows_device(&stem, "LPT")
}
@@ -125,10 +127,18 @@ mod tests {
#[test]
fn recognizes_windows_reserved_device_names() {
for filename in ["CON", "con.txt", "PRN.", "aux.mp4", "NUL", "COM1.zip", "lpt9"] {
for filename in [
"CON", "con.txt", "PRN.", "aux.mp4", "NUL", "COM1.zip", "lpt9",
] {
assert!(is_windows_reserved_filename(filename), "{filename}");
}
for filename in ["console.txt", "com0.zip", "com10.zip", "lpt.txt", "movie.mp4"] {
for filename in [
"console.txt",
"com0.zip",
"com10.zip",
"lpt.txt",
"movie.mp4",
] {
assert!(!is_windows_reserved_filename(filename), "{filename}");
}
}
+51 -43
View File
@@ -1,14 +1,14 @@
use crate::ipc::{DownloadStateEvent, DownloadStatus, QueueDirection};
use crate::retry::{BackoffOutcome, MAX_RETRIES, backoff_and_emit, is_transient_network_error};
use crate::retry::{backoff_and_emit, is_transient_network_error, BackoffOutcome, MAX_RETRIES};
use log;
use serde::Deserialize;
use serde_json;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::sync::{Mutex, Notify, OwnedSemaphorePermit, Semaphore};
use ts_rs::TS;
use serde_json;
use log;
/// Default capacity when no setting is read yet.
pub const DEFAULT_MAX_CONCURRENT: usize = 3;
@@ -120,14 +120,12 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
impl QueueManager<tauri::Wry> {
/// Production constructor. Wired up in lib.rs setup().
pub fn new(app_handle: AppHandle<tauri::Wry>, capacity: usize) -> Self {
let spawner: Arc<dyn SidecarSpawner> =
Arc::new(ProductionSpawner::new(app_handle.clone()));
let spawner: Arc<dyn SidecarSpawner> = Arc::new(ProductionSpawner::new(app_handle.clone()));
Self::test_new(app_handle, capacity, spawner)
}
}
impl<R: tauri::Runtime> QueueManager<R> {
/// Test-only constructor injecting a fake spawner.
pub fn test_new(
app_handle: AppHandle<R>,
@@ -193,11 +191,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// Acquire a permit from the semaphore (blocks until one is available).
pub async fn acquire_permit(&self) -> Option<OwnedSemaphorePermit> {
self.semaphore
.clone()
.acquire_owned()
.await
.ok()
self.semaphore.clone().acquire_owned().await.ok()
}
/// Park an already-acquired permit under `id`.
@@ -258,9 +252,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
.map(|(id, _)| id.clone())
.collect()
};
for id in ids_to_fail {
self.apply_completion(&id, PendingOutcome::Error("Aria2 WebSocket connection lost".to_string())).await;
self.apply_completion(
&id,
PendingOutcome::Error("Aria2 WebSocket connection lost".to_string()),
)
.await;
}
}
@@ -271,10 +269,9 @@ impl<R: tauri::Runtime> QueueManager<R> {
fn emit_state(&self, id: impl Into<String>, status: DownloadStatus) {
use tauri::Emitter;
let _ = self.app_handle.emit(
"download-state",
DownloadStateEvent::new(id, status),
);
let _ = self
.app_handle
.emit("download-state", DownloadStateEvent::new(id, status));
}
/// Resize the global concurrency limit. Grow adds permits immediately;
@@ -328,12 +325,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
continue;
}
// (2) Acquire a slot.
let permit_opt = self
.semaphore
.clone()
.acquire_owned()
.await
.ok();
let permit_opt = self.semaphore.clone().acquire_owned().await.ok();
let permit = match permit_opt {
Some(p) => p,
None => break, // Semaphore closed, exit dispatcher
@@ -380,7 +372,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
// aria2's RPC returns instantly, so the permit must outlive the
// dispatch_one call. Media/Native runners release on exit.
self.park_permit(&id, permit).await;
self.active_kinds.lock().await.insert(id.clone(), task.kind.clone());
self.active_kinds
.lock()
.await
.insert(id.clone(), task.kind.clone());
self.emit_state(&id, DownloadStatus::Downloading);
match task.kind {
@@ -513,8 +508,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.aria2_retry_cancelled.lock().await.remove(id);
}
pub fn aria2_gid_for_download(&self, id: &str) -> Option<String> {
self.aria2_gids
.read()
@@ -592,10 +585,10 @@ impl<R: tauri::Runtime> QueueManager<R> {
let id = match id {
Some(id) => id,
None => {
self.pending_completion
.lock()
.await
.insert(gid.to_string(), (String::new(), PendingOutcome::Error(error)));
self.pending_completion.lock().await.insert(
gid.to_string(),
(String::new(), PendingOutcome::Error(error)),
);
return;
}
};
@@ -617,13 +610,15 @@ impl<R: tauri::Runtime> QueueManager<R> {
let transient = is_transient_network_error(&error);
let strikes_left = strike < MAX_RETRIES;
if !(transient && strikes_left) {
self.apply_completion(&id, PendingOutcome::Error(error)).await;
self.apply_completion(&id, PendingOutcome::Error(error))
.await;
return;
}
let payload = self.aria2_payloads.lock().await.get(&id).cloned();
if payload.is_none() {
self.apply_completion(&id, PendingOutcome::Error(error)).await;
self.apply_completion(&id, PendingOutcome::Error(error))
.await;
return;
}
let payload = payload.unwrap();
@@ -702,11 +697,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
this.emit_state(&id_for_task, DownloadStatus::Downloading);
}
Err(retry_error) => {
this.apply_completion(
&id_for_task,
PendingOutcome::Error(retry_error),
)
.await;
this.apply_completion(&id_for_task, PendingOutcome::Error(retry_error))
.await;
}
}
});
@@ -853,7 +845,8 @@ impl SidecarSpawner for ProductionSpawner {
"dir".to_string(),
serde_json::json!(resolved_dest.to_string_lossy().to_string()),
);
let safe_filename = crate::download_ownership::canonical_download_filename(&payload.filename);
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()));
@@ -904,7 +897,14 @@ impl SidecarSpawner for ProductionSpawner {
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
let params = serde_json::json!([uris, options]);
match crate::rpc_call(state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, "aria2.addUri", params).await {
match crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.addUri",
params,
)
.await
{
Ok(result) => {
let gid = result.as_str().unwrap_or("").to_string();
if gid.is_empty() {
@@ -919,13 +919,17 @@ 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 = crate::download_ownership::canonical_download_filename(&payload.filename);
let safe_filename =
crate::download_ownership::canonical_download_filename(&payload.filename);
state
.download_coordinator
.send(crate::download::DownloadCmd::Start(Box::new(
crate::download::DownloadPayload {
id: download_id,
urls: crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()),
urls: crate::collect_download_uris(
&payload.url,
payload.mirrors.as_deref(),
),
output_path: resolved_dest.join(safe_filename),
speed_limit: payload.speed_limit.clone(),
username: payload.username.clone(),
@@ -995,7 +999,10 @@ impl SidecarSpawner for ProductionSpawner {
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &path);
}
}
let _ = state.download_coordinator.finish_media(id.to_string()).await;
let _ = state
.download_coordinator
.finish_media(id.to_string())
.await;
outcome
}
@@ -1004,7 +1011,8 @@ 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 = crate::download_ownership::canonical_download_filename(&payload.filename);
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
+26 -7
View File
@@ -177,7 +177,14 @@ mod tests {
#[test]
fn schedule_is_three_strike_exponential() {
assert_eq!(BACKOFF_SCHEDULE, [Duration::from_secs(2), Duration::from_secs(5), Duration::from_secs(10)]);
assert_eq!(
BACKOFF_SCHEDULE,
[
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(10)
]
);
assert_eq!(MAX_RETRIES, 3);
}
@@ -196,10 +203,16 @@ mod tests {
#[test]
fn classifies_reqwest_timeouts_as_transient() {
assert!(is_transient_network_error("operation timed out"));
assert!(is_transient_network_error("error sending request: operation timed out"));
assert!(is_transient_network_error(
"error sending request: operation timed out"
));
assert!(is_transient_network_error("connection reset by peer"));
assert!(is_transient_network_error("connection refused (os error 61)"));
assert!(is_transient_network_error("dns error: failed to lookup address"));
assert!(is_transient_network_error(
"connection refused (os error 61)"
));
assert!(is_transient_network_error(
"dns error: failed to lookup address"
));
}
#[test]
@@ -221,7 +234,9 @@ mod tests {
assert!(is_transient_network_error(
"ERROR: unable to download video: Connection timed out"
));
assert!(is_transient_network_error("Connection was closed by server"));
assert!(is_transient_network_error(
"Connection was closed by server"
));
assert!(is_transient_network_error("Timeout."));
assert!(is_transient_network_error("network is unreachable"));
}
@@ -234,13 +249,17 @@ mod tests {
assert!(!is_transient_network_error("HTTP 403 Forbidden"));
assert!(!is_transient_network_error("HTTP 410 Gone"));
assert!(!is_transient_network_error("HTTP 401 Unauthorized"));
assert!(!is_transient_network_error("HTTP 451 Unavailable For Legal Reasons"));
assert!(!is_transient_network_error(
"HTTP 451 Unavailable For Legal Reasons"
));
}
#[test]
fn refuses_to_retry_permanent_fs_errors() {
assert!(!is_transient_network_error("No space left on device"));
assert!(!is_transient_network_error("Permission denied (os error 13)"));
assert!(!is_transient_network_error(
"Permission denied (os error 13)"
));
}
#[test]
+26 -24
View File
@@ -36,18 +36,15 @@ pub fn spawn_scheduler(
loop {
interval.tick().await;
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(),
)
})
});
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;
@@ -78,10 +75,13 @@ pub fn spawn_scheduler(
.get("start")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{
let _ = app_handle.emit("schedule-trigger", serde_json::json!({
"action": "start",
"key": start_key
}));
let _ = app_handle.emit(
"schedule-trigger",
serde_json::json!({
"action": "start",
"key": start_key
}),
);
last_emit.insert("start", std::time::Instant::now());
}
@@ -93,15 +93,17 @@ pub fn spawn_scheduler(
&start_key,
&scheduler_last_stop_key,
&stop_key,
)
&& last_emit
.get("stop")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
) && last_emit
.get("stop")
.is_none_or(|instant| instant.elapsed() >= Duration::from_secs(5))
{
let _ = app_handle.emit("schedule-trigger", serde_json::json!({
"action": "stop",
"key": stop_key
}));
let _ = app_handle.emit(
"schedule-trigger",
serde_json::json!({
"action": "stop",
"key": stop_key
}),
);
last_emit.insert("stop", std::time::Instant::now());
}
}
+7 -12
View File
@@ -53,7 +53,10 @@ pub fn preserve_scheduler_runtime_keys(
let existing_document = match decode_document(&Value::String(existing.to_string())) {
Ok(doc) => doc,
Err(e) => {
log::warn!("Failed to decode existing settings, dropping runtime keys: {}", e);
log::warn!(
"Failed to decode existing settings, dropping runtime keys: {}",
e
);
return Ok(incoming.to_string());
}
};
@@ -147,9 +150,7 @@ fn default_category_subfolders() -> HashMap<String, String> {
fn normalize_category_subfolder(value: &str, fallback: &str) -> String {
let parts = value
.split(['/', '\\'])
.filter(|part| {
!part.is_empty() && *part != "." && *part != ".." && !part.ends_with(':')
})
.filter(|part| !part.is_empty() && *part != "." && *part != ".." && !part.ends_with(':'))
.collect::<Vec<_>>();
if parts.is_empty() {
fallback.to_string()
@@ -329,14 +330,8 @@ mod tests {
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"
);
assert_eq!(merged["state"]["schedulerLastStartKey"], "2026-06-22-start");
assert_eq!(merged["state"]["schedulerLastStopKey"], "2026-06-22-stop");
}
#[test]
+6 -2
View File
@@ -9,6 +9,7 @@ use axum::{
routing::get,
Router,
};
use firelink_lib::download::{DownloadCmd, DownloadCoordinator, DownloadEvent, DownloadPayload};
use futures_util::stream;
use sha2::{Digest, Sha256};
use std::{
@@ -19,9 +20,12 @@ use std::{
},
time::{Duration, Instant},
};
use firelink_lib::download::{DownloadCmd, DownloadCoordinator, DownloadEvent, DownloadPayload};
use tempfile::TempDir;
use tokio::{net::TcpListener, sync::{mpsc, oneshot}, task::JoinHandle};
use tokio::{
net::TcpListener,
sync::{mpsc, oneshot},
task::JoinHandle,
};
use uuid::Uuid;
const TEST_TIMEOUT: Duration = Duration::from_secs(10);
+41 -22
View File
@@ -1,8 +1,8 @@
use firelink_lib::queue::{
QueueManager, QueuedTask, SidecarSpawner, SpawnPayload, TaskKind, MEDIA_RUN_CANCELLED,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::test::{mock_builder, mock_context, noop_assets};
use tauri::Listener;
@@ -84,7 +84,10 @@ async fn release_permit_is_idempotent() {
mgr.release_permit("a").await; // second release: no-op
let avail_after_second = mgr.available_permits();
assert_eq!(avail_after_first - avail_before, 1);
assert_eq!(avail_after_second, avail_after_first, "second release must not free another slot");
assert_eq!(
avail_after_second, avail_after_first,
"second release must not free another slot"
);
}
#[tokio::test]
@@ -140,7 +143,8 @@ async fn dispatcher_parks_when_idle_no_busy_spin() {
// No permit should have been acquired while idle.
assert_eq!(
mgr_arc.available_permits(), 3,
mgr_arc.available_permits(),
3,
"dispatcher must not acquire permits when pending is empty"
);
@@ -190,13 +194,19 @@ async fn grow_releases_immediately_and_dispatches_waiting_tasks() {
// Give dispatcher time to dispatch 2 (capacity) of the 4.
tokio::time::sleep(Duration::from_millis(100)).await;
let native_after_initial = spawner.native_calls.load(Ordering::SeqCst);
assert_eq!(native_after_initial, 2, "only capacity-many tasks dispatch initially");
assert_eq!(
native_after_initial, 2,
"only capacity-many tasks dispatch initially"
);
// Grow to 4; the remaining 2 should dispatch.
mgr_arc.set_capacity(4);
tokio::time::sleep(Duration::from_millis(100)).await;
let native_after_grow = spawner.native_calls.load(Ordering::SeqCst);
assert_eq!(native_after_grow, 4, "grow must allow the waiting tasks to dispatch");
assert_eq!(
native_after_grow, 4,
"grow must allow the waiting tasks to dispatch"
);
handle.abort();
}
@@ -325,8 +335,7 @@ async fn media_terminal_error_emits_failed_without_completed() {
#[tokio::test]
async fn media_cancellation_does_not_emit_completed() {
let (manager, event_rx) =
make_media_manager(Err(MEDIA_RUN_CANCELLED.to_string()));
let (manager, event_rx) = make_media_manager(Err(MEDIA_RUN_CANCELLED.to_string()));
let manager = Arc::new(manager);
manager.push(media_task("media-cancelled")).await.unwrap();
let dispatcher = {
@@ -358,14 +367,19 @@ async fn aria2_permit_survives_rpc_return() {
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 1);
assert_eq!(
mgr_arc.available_permits(), 0,
mgr_arc.available_permits(),
0,
"permit must remain parked while aria2 download is notionally running"
);
// Now simulate aria2 completion: release_permit frees the slot.
mgr_arc.release_permit("a").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(mgr_arc.available_permits(), 1, "release frees the parked permit");
assert_eq!(
mgr_arc.available_permits(),
1,
"release frees the parked permit"
);
handle.abort();
}
@@ -432,13 +446,17 @@ async fn move_up_down_reorders_pending() {
mgr_arc.push(sample_task("b")).await.unwrap();
mgr_arc.push(sample_task("c")).await.unwrap();
mgr_arc.move_in_queue("c", "main", QueueDirection::Down).await;
mgr_arc
.move_in_queue("c", "main", QueueDirection::Down)
.await;
assert_eq!(mgr_arc.pending_order(None).await, vec!["a", "b", "c"]);
mgr_arc.move_in_queue("c", "main", QueueDirection::Up).await;
assert_eq!(mgr_arc.pending_order(None).await, vec!["a", "c", "b"]);
mgr_arc.move_in_queue("a", "main", QueueDirection::Down).await;
mgr_arc
.move_in_queue("a", "main", QueueDirection::Down)
.await;
assert_eq!(mgr_arc.pending_order(None).await, vec!["c", "a", "b"]);
mgr_arc.move_in_queue("c", "main", QueueDirection::Up).await;
@@ -463,7 +481,10 @@ async fn moving_one_queue_does_not_reorder_another_queue() {
mgr.push(a2).await.unwrap();
mgr.push(b2).await.unwrap();
assert_eq!(mgr.move_in_queue("a2", "a", QueueDirection::Up).await, vec!["a2", "a1"]);
assert_eq!(
mgr.move_in_queue("a2", "a", QueueDirection::Up).await,
vec!["a2", "a1"]
);
assert_eq!(mgr.pending_order(Some("b")).await, vec!["b1", "b2"]);
}
@@ -481,17 +502,15 @@ async fn notify_fires_on_push_and_release() {
};
mgr_arc.push(sample_task("x")).await.unwrap();
let dispatched = timeout(
Duration::from_millis(150),
async {
loop {
if mgr_arc.available_permits() == 0 {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
let dispatched = timeout(Duration::from_millis(150), async {
loop {
if mgr_arc.available_permits() == 0 {
return;
}
},
).await;
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await;
assert!(dispatched.is_ok(), "push must wake the idle dispatcher");
handle.abort();