mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
style: run cargo fmt to resolve CI failure
This commit is contained in:
+10
-14
@@ -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
@@ -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
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user