#![allow(unexpected_cfgs)] // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ use regex::Regex; use serde::Serialize; use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::hash::{Hash, Hasher}; use std::path::PathBuf; use std::sync::OnceLock; use std::time::{Duration, Instant}; use tauri::{Emitter, Manager}; use tauri_plugin_deep_link::DeepLinkExt; use ts_rs::TS; pub(crate) fn ensure_reqwest_crypto_provider() { static INSTALL: OnceLock<()> = OnceLock::new(); INSTALL.get_or_init(|| { let _ = rustls::crypto::ring::default_provider().install_default(); }); } fn sanitize_metadata_filename(filename: &str) -> Option { let normalized = filename.trim().replace('\\', "/"); let basename = std::path::Path::new(&normalized) .file_name()? .to_str()? .trim() .trim_end_matches(['.', ' ']); if basename.is_empty() || basename == "." || basename == ".." || basename.len() > 255 { return None; } Some(basename.to_string()) } fn percent_decode_metadata_value(value: &str) -> Option { let bytes = value.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; let hex_value = |byte: u8| match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, }; while index < bytes.len() { if bytes[index] == b'%' && index + 2 < bytes.len() { if let (Some(high), Some(low)) = ( hex_value(bytes[index + 1]), hex_value(bytes[index + 2]), ) { decoded.push((high << 4) | low); index += 3; continue; } } decoded.push(bytes[index]); index += 1; } String::from_utf8(decoded).ok() } fn filename_from_content_disposition(disposition: &str) -> Option { let mut fallback = None; for part in disposition.split(';').map(str::trim) { let Some((name, value)) = part.split_once('=') else { continue; }; let normalized_name = name.trim().to_ascii_lowercase(); let raw_value = value.trim().trim_matches('"').trim_matches('\''); if normalized_name == "filename*" { let encoded = raw_value .split_once("''") .map(|(_, value)| value) .unwrap_or(raw_value); if let Some(filename) = percent_decode_metadata_value(encoded) .and_then(|value| sanitize_metadata_filename(&value)) { return Some(filename); } } else if normalized_name == "filename" { fallback = sanitize_metadata_filename(raw_value); } } fallback } fn filename_from_url_disposition_query(raw_url: &str) -> Option { let parsed = reqwest::Url::parse(raw_url).ok()?; for (name, value) in parsed.query_pairs() { if name.eq_ignore_ascii_case("response-content-disposition") || name.eq_ignore_ascii_case("rscd") { if let Some(filename) = filename_from_content_disposition(&value) { return Some(filename); } } } None } fn filename_from_url_path(raw_url: &str) -> Option { let parsed = reqwest::Url::parse(raw_url).ok()?; let last = parsed.path_segments()?.next_back()?; // URL path segments retain their percent-encoded representation. Decode // before applying the same filename sanitization used by header metadata. // If the escape sequence cannot form valid UTF-8, retain the raw segment // so a malformed URL cannot erase an otherwise usable filename. let decoded = percent_decode_metadata_value(last).unwrap_or_else(|| last.to_string()); let filename = sanitize_metadata_filename(&decoded)?; let is_gmail_profile_path = parsed.host_str() == Some("mail.google.com") && parsed.path().contains("/mail/u/"); let is_gmail_profile_number = is_gmail_profile_path && filename.chars().all(|character| character.is_ascii_digit()); (!is_weak_url_filename(&filename) && !is_gmail_profile_number).then_some(filename) } fn is_weak_url_filename(filename: &str) -> bool { let normalized = filename.trim().to_ascii_lowercase(); matches!( normalized.as_str(), "identifier" | "download" | "view" | "uc" ) } fn metadata_filename_from_response( response: &reqwest::Response, current_url: &str, original_url: &str, ) -> String { if let Some(filename) = response .headers() .get(reqwest::header::CONTENT_DISPOSITION) .and_then(|value| value.to_str().ok()) .and_then(filename_from_content_disposition) .filter(|filename| !is_weak_url_filename(filename)) { return filename; } filename_from_url_disposition_query(current_url) .filter(|filename| !is_weak_url_filename(filename)) .or_else(|| filename_from_url_path(original_url)) .or_else(|| filename_from_url_path(current_url)) .unwrap_or_else(|| "download".to_string()) } fn metadata_response_error(status: reqwest::StatusCode) -> Option { (!status.is_success()).then(|| { format!( "Metadata request failed with HTTP {} ({})", status.as_u16(), status.canonical_reason().unwrap_or("unknown status") ) }) } fn metadata_cookie_header_present( headers: Option<&str>, cookies: Option<&str>, cookie_scopes: Option<&[extension_server::ExtensionCookieScope]>, ) -> bool { cookies.is_some_and(|value| !value.trim().is_empty()) || headers.is_some_and(|value| { value.lines().any(|line| { let Some((name, value)) = line.split_once(':') else { return false; }; name.trim().eq_ignore_ascii_case("cookie") && !value.trim().is_empty() }) }) || cookie_scopes.is_some_and(|scopes| { scopes.iter().any(|scope| !scope.cookies.trim().is_empty()) }) } fn metadata_headers( headers: Option<&str>, cookie_header: Option<&str>, include_cookies: bool, ) -> reqwest::header::HeaderMap { let mut header_map = reqwest::header::HeaderMap::new(); if let Some(headers) = headers { for line in headers.lines() { let Some((name, value)) = line.split_once(':') else { continue; }; let Ok(name) = reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()) else { continue; }; if name == reqwest::header::COOKIE && !include_cookies { continue; } let value = value.trim(); if value.is_empty() { continue; } let Ok(value) = reqwest::header::HeaderValue::from_str(value) else { continue; }; header_map.insert(name, value); } } if include_cookies { if let Some(cookie_header) = cookie_header .map(str::trim) .filter(|value| !value.is_empty()) { if let Ok(value) = reqwest::header::HeaderValue::from_str(cookie_header) { header_map.insert(reqwest::header::COOKIE, value); } } } header_map } fn should_retry_metadata_with_cookies( status: reqwest::StatusCode, cookies_available: bool, cookie_retry_attempted: bool, ) -> bool { cookies_available && !cookie_retry_attempted && matches!( status, reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN ) } fn retry_metadata_with_cookies( status: reqwest::StatusCode, cookies_available: bool, send_cookies: &mut bool, cookie_retry_attempted: &mut bool, original_url: &str, current_url: &mut String, redirects: &mut usize, ) -> bool { if !should_retry_metadata_with_cookies( status, cookies_available && !*send_cookies, *cookie_retry_attempted, ) { return false; } // A cookie retry is a new, bounded resolution pass. Keep the retry // itself one-shot, while giving the authenticated pass the same redirect // budget as the initial unauthenticated pass. *send_cookies = true; *cookie_retry_attempted = true; current_url.clear(); current_url.push_str(original_url); *redirects = 0; true } #[derive(Serialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct MetadataResponse { url: String, filename: String, size: String, #[ts(type = "number")] size_bytes: u64, resumable: bool, } #[derive(Debug, Serialize, serde::Deserialize, Clone, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct MediaFormat { pub format_id: String, pub resolution: String, pub ext: String, pub format_label: String, #[ts(type = "number | null")] pub fps: Option, #[ts(type = "number | null")] pub filesize: Option, #[ts(type = "number | null")] pub filesize_approx: Option, } #[derive(Debug, Serialize, serde::Deserialize, Clone, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct MediaMetadata { pub title: String, #[ts(type = "number | null")] pub duration: Option, pub thumbnail: Option, pub formats: Vec, } #[derive(Debug, Serialize, serde::Deserialize, Clone, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct MediaPlaylistEntry { pub id: String, pub url: String, pub title: String, #[ts(type = "number | null")] pub playlist_index: Option, } #[derive(Debug, Serialize, serde::Deserialize, Clone, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct MediaPlaylistMetadata { pub title: String, pub playlist_id: Option, #[ts(type = "number | null")] pub entry_count: Option, #[ts(type = "number")] pub skipped_entries: u64, pub truncated: bool, pub entries: Vec, } const MEDIA_PLAYLIST_MAX_ENTRIES: usize = 1000; fn parse_media_playlist_metadata( value: &serde_json::Value, max_entries: usize, ) -> Result { let raw_entries = value .get("entries") .and_then(|entries| entries.as_array()) .ok_or_else(|| "yt-dlp did not return playlist entries".to_string())?; let entry_count = value.get("playlist_count").and_then(|count| count.as_u64()); let playlist_title = value .get("title") .and_then(|title| title.as_str()) .filter(|title| !title.trim().is_empty()) .unwrap_or("YouTube playlist") .trim() .to_string(); let playlist_id = value .get("id") .and_then(|id| id.as_str()) .map(str::trim) .filter(|id| !id.is_empty()) .map(ToOwned::to_owned); let mut seen_urls = HashSet::new(); let mut entries = Vec::new(); let mut skipped_entries = 0_u64; for (position, entry) in raw_entries.iter().enumerate() { let candidate_url = ["webpage_url", "url"].iter().find_map(|key| { entry .get(*key) .and_then(|url| url.as_str()) .map(str::trim) .filter(|url| url.starts_with("https://") || url.starts_with("http://")) }); let Some(url) = candidate_url else { skipped_entries += 1; continue; }; let url = url.to_string(); if !seen_urls.insert(url.clone()) { skipped_entries += 1; continue; } if entries.len() >= max_entries { continue; } let id = entry .get("id") .and_then(|id| id.as_str()) .map(str::trim) .filter(|id| !id.is_empty()) .unwrap_or(&url) .to_string(); let title = entry .get("title") .and_then(|title| title.as_str()) .map(str::trim) .filter(|title| !title.is_empty()) .unwrap_or("Untitled video") .to_string(); let playlist_index = entry .get("playlist_index") .and_then(|index| index.as_u64()) .or(Some((position + 1) as u64)); entries.push(MediaPlaylistEntry { id, url, title, playlist_index, }); } let truncated = raw_entries.len() > max_entries || entry_count.is_some_and(|count| count > max_entries as u64); if let Some(count) = entry_count { skipped_entries = skipped_entries.max(count.saturating_sub(entries.len() as u64)); } else { skipped_entries = skipped_entries.max( raw_entries .len() .saturating_sub(entries.len()) .try_into() .unwrap_or(u64::MAX), ); } if entries.is_empty() { return Err("yt-dlp returned no usable entries for this playlist".to_string()); } Ok(MediaPlaylistMetadata { title: playlist_title, playlist_id, entry_count, skipped_entries, truncated, entries, }) } fn is_media_processing_line(line: &str) -> bool { let lower = line.to_lowercase(); lower.contains("[merger]") || lower.contains("[extractaudio]") || lower.contains("[ffmpeg]") || lower.contains("[videoconvertor]") || lower.contains("[fixup") || lower.contains("merging formats") || lower.contains("post-process") } fn media_output_template( resolved_dest: &std::path::Path, safe_filename: &str, format_selector: Option<&str>, ) -> std::path::PathBuf { if format_selector.is_none() && std::path::Path::new(safe_filename).extension().is_none() { resolved_dest.join("%(title).200B [%(id)s].%(ext)s") } else { resolved_dest.join(safe_filename) } } fn media_progress_args() -> Vec { vec![ "--newline".to_string(), "--progress".to_string(), "--progress-delta".to_string(), "0.2".to_string(), "--progress-template".to_string(), format!("download:{MEDIA_PROGRESS_PREFIX}%(progress)j"), ] } fn json_str<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> { value .get(key) .and_then(|v| v.as_str()) .map(str::trim) .filter(|v| !v.is_empty()) } fn json_lower(value: &serde_json::Value, key: &str) -> String { json_str(value, key).unwrap_or_default().to_lowercase() } fn json_u64(value: &serde_json::Value, key: &str) -> Option { value .get(key) .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64))) } fn json_f64(value: &serde_json::Value, key: &str) -> Option { value.get(key).and_then(|v| { v.as_f64() .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) }) } fn media_filesize(value: &serde_json::Value) -> Option { json_u64(value, "filesize").or_else(|| json_u64(value, "filesize_approx")) } fn media_exact_filesize(value: &serde_json::Value) -> Option { json_u64(value, "filesize") } fn media_approx_filesize(value: &serde_json::Value) -> Option { media_exact_filesize(value) .is_none() .then(|| json_u64(value, "filesize_approx")) .flatten() } fn media_sized_bytes(value: &serde_json::Value) -> Option<(u64, bool)> { if let Some(size) = media_exact_filesize(value) { Some((size, false)) } else { media_approx_filesize(value).map(|size| (size, true)) } } fn estimated_stream_bytes( value: &serde_json::Value, duration_seconds: Option, ) -> Option<(u64, bool)> { if let Some(size) = media_sized_bytes(value) { return Some(size); } let duration_seconds = duration_seconds.filter(|duration| *duration > 0.0)?; let bitrate_kbps = if has_video_stream(value) && !has_audio_stream(value) { json_f64(value, "vbr").or_else(|| json_f64(value, "tbr")) } else if has_audio_stream(value) && !has_video_stream(value) { json_f64(value, "abr").or_else(|| json_f64(value, "tbr")) } else { json_f64(value, "tbr") } .filter(|bitrate| *bitrate > 0.0)?; let bytes = bitrate_kbps * 1_000.0 * duration_seconds / 8.0; (bytes.is_finite() && bytes > 0.0).then_some((bytes.round() as u64, true)) } fn split_size_estimate(bytes: Option<(u64, bool)>) -> (Option, Option) { match bytes { Some((bytes, false)) => (Some(bytes), None), Some((bytes, true)) => (None, Some(bytes)), None => (None, None), } } fn codec_is_present(codec: Option<&str>) -> bool { codec .map(str::trim) .filter(|codec| !codec.is_empty()) .map(|codec| codec.to_lowercase() != "none") .unwrap_or(false) } fn has_video_stream(value: &serde_json::Value) -> bool { codec_is_present(json_str(value, "vcodec")) } fn has_audio_stream(value: &serde_json::Value) -> bool { codec_is_present(json_str(value, "acodec")) } fn is_excluded_yt_dlp_format(value: &serde_json::Value) -> bool { let ext = json_lower(value, "ext"); let protocol = json_lower(value, "protocol"); if ext == "mhtml" || protocol.contains("mhtml") { return true; } for key in ["format_note", "format", "format_id", "protocol"] { let text = json_lower(value, key); if text.contains("storyboard") || text.contains("thumbnail") || text.contains("subtitle") || text.contains("subtitles") { return true; } } !(has_video_stream(value) || has_audio_stream(value)) } fn format_height(value: &serde_json::Value) -> Option { if let Some(height) = json_u64(value, "height").filter(|height| *height > 0) { return Some(height); } if let Some(resolution) = json_str(value, "resolution") { if let Some((_, height)) = resolution .split_once('x') .or_else(|| resolution.split_once('X')) { if let Ok(parsed) = height.trim().parse::() { if parsed > 0 { return Some(parsed); } } } } let note = json_lower(value, "format_note"); let bytes = note.as_bytes(); let mut heights = Vec::new(); for (index, byte) in bytes.iter().enumerate() { if *byte != b'p' || index == 0 { continue; } let mut start = index; while start > 0 && bytes[start - 1].is_ascii_digit() { start -= 1; } if start < index { if let Ok(height) = note[start..index].parse::() { if height > 0 { heights.push(height); } } } } heights.into_iter().max() } fn matches_media_height(value: &serde_json::Value, target: u64) -> bool { if !has_video_stream(value) { return false; } format_height(value) == Some(target) } fn format_score(value: &serde_json::Value) -> u64 { let height_score = format_height(value).unwrap_or(0).saturating_mul(1_000_000); let bitrate_score = json_f64(value, "tbr").unwrap_or(0.0).max(0.0) as u64 * 1_000; let size_score = media_filesize(value).unwrap_or(0).min(999); height_score + bitrate_score + size_score } fn best_matching_format<'a, F>( formats: &'a [&'a serde_json::Value], predicate: F, ) -> Option<&'a serde_json::Value> where F: Fn(&serde_json::Value) -> bool, { formats .iter() .copied() .filter(|format| predicate(format)) .max_by_key(|format| format_score(format)) } fn best_audio_format<'a>( formats: &'a [&'a serde_json::Value], ext: Option<&str>, ) -> Option<&'a serde_json::Value> { best_matching_format(formats, |format| { if !has_audio_stream(format) || has_video_stream(format) { return false; } ext.map(|wanted| json_lower(format, "ext") == wanted) .unwrap_or(true) }) } fn display_codec(codec: Option<&str>, fallback: &str) -> String { let Some(codec) = codec.map(str::trim).filter(|codec| !codec.is_empty()) else { return fallback.to_string(); }; let lower = codec.to_lowercase(); if lower == "none" { return fallback.to_string(); } if lower.starts_with("avc1") || lower.contains("h264") { "H.264".to_string() } else if lower.starts_with("av01") { "AV1".to_string() } else if lower.starts_with("vp09") || lower.starts_with("vp9") { "VP9".to_string() } else if lower.starts_with("vp8") { "VP8".to_string() } else if lower.starts_with("hev1") || lower.starts_with("hvc1") || lower.contains("h265") || lower.contains("hevc") { "H.265".to_string() } else if lower.starts_with("mp4a") || lower.contains("aac") { "AAC".to_string() } else if lower.contains("opus") { "Opus".to_string() } else if lower.contains("vorbis") { "Vorbis".to_string() } else if lower.contains("mp3") { "MP3".to_string() } else { fallback.to_string() } } fn joined_format_label( container: &str, video_codec: Option<&str>, audio_codec: Option<&str>, ) -> String { let mut codecs = Vec::new(); if video_codec.is_some() { codecs.push(display_codec(video_codec, "Video")); } if audio_codec.is_some() { codecs.push(display_codec(audio_codec, "Audio")); } if codecs.is_empty() { container.to_uppercase() } else { format!("{} • {}", container.to_uppercase(), codecs.join(" + ")) } } fn selected_format_id( video: Option<&serde_json::Value>, audio: Option<&serde_json::Value>, ) -> Option { match ( video.and_then(|format| json_str(format, "format_id")), audio.and_then(|format| json_str(format, "format_id")), ) { (Some(video_id), Some(audio_id)) if video_id != audio_id => { Some(format!("{video_id}+{audio_id}")) } (Some(video_id), _) => Some(video_id.to_string()), (None, Some(audio_id)) => Some(audio_id.to_string()), (None, None) => None, } } fn estimated_merged_size( video: Option<&serde_json::Value>, audio: Option<&serde_json::Value>, duration_seconds: Option, ) -> (Option, Option) { let video_has_audio = video.is_some_and(has_audio_stream); let v_bytes = video.and_then(|format| estimated_stream_bytes(format, duration_seconds)); let a_bytes = if video_has_audio { None } else { audio.and_then(|format| estimated_stream_bytes(format, duration_seconds)) }; match (video, audio, v_bytes, a_bytes, video_has_audio) { (Some(_), _, Some((v_size, v_approx)), _, true) => { split_size_estimate(Some((v_size, v_approx))) } (Some(_), Some(_), Some((v_size, v_approx)), Some((a_size, a_approx)), false) => { split_size_estimate(Some((v_size.saturating_add(a_size), v_approx || a_approx))) } (Some(_), None, Some((v_size, v_approx)), None, false) => { split_size_estimate(Some((v_size, v_approx))) } (None, Some(_), None, Some((a_size, a_approx)), false) => { split_size_estimate(Some((a_size, a_approx))) } _ => (None, None), } } fn raw_media_format( value: &serde_json::Value, duration_seconds: Option, ) -> Option { let format_id = json_str(value, "format_id")?.to_string(); let ext = json_str(value, "ext").unwrap_or("mkv").to_string(); let fps = json_f64(value, "fps"); let (filesize, filesize_approx) = split_size_estimate(estimated_stream_bytes(value, duration_seconds)); let resolution = if has_video_stream(value) { format_height(value) .map(|height| format!("{height}p")) .or_else(|| json_str(value, "resolution").map(ToOwned::to_owned)) .unwrap_or_else(|| "Video".to_string()) } else { "Audio only".to_string() }; let format_label = if has_video_stream(value) && has_audio_stream(value) { joined_format_label(&ext, json_str(value, "vcodec"), json_str(value, "acodec")) } else if has_video_stream(value) { joined_format_label(&ext, json_str(value, "vcodec"), None) } else { joined_format_label(&ext, None, json_str(value, "acodec")) }; Some(MediaFormat { format_id, resolution, ext, format_label, fps, filesize, filesize_approx, }) } fn build_media_format_options( formats_arr: &[serde_json::Value], duration_seconds: Option, ) -> Vec { let clean_formats: Vec<&serde_json::Value> = formats_arr .iter() .filter(|format| !is_excluded_yt_dlp_format(format)) .collect(); let has_video = clean_formats.iter().any(|format| has_video_stream(format)); let has_audio = clean_formats.iter().any(|format| has_audio_stream(format)); let mut options = Vec::new(); if has_video { let available_heights: Vec = clean_formats .iter() .filter(|format| has_video_stream(format)) .filter_map(|format| format_height(format)) .collect::>() .into_iter() .rev() .collect(); for height in available_heights { if let Some(video) = best_matching_format(&clean_formats, |format| { has_video_stream(format) && matches_media_height(format, height) }) { let audio = if has_audio_stream(video) { None } else { best_audio_format(&clean_formats, None) }; let (filesize, filesize_approx) = estimated_merged_size(Some(video), audio, duration_seconds); options.push(MediaFormat { format_id: selected_format_id(Some(video), audio).unwrap_or_else(|| { format!("bestvideo[height<={height}]+bestaudio/best[height<={height}]") }), resolution: format!("{height}p"), ext: "mkv".to_string(), format_label: joined_format_label( "mkv", json_str(video, "vcodec"), audio.and_then(|format| json_str(format, "acodec")), ), fps: json_f64(video, "fps"), filesize, filesize_approx, }); } if let Some(video) = best_matching_format(&clean_formats, |format| { has_video_stream(format) && matches_media_height(format, height) && json_lower(format, "ext") == "mp4" }) { let audio = if has_audio_stream(video) { None } else { best_audio_format(&clean_formats, Some("m4a")) .or_else(|| best_audio_format(&clean_formats, None)) }; let (filesize, filesize_approx) = estimated_merged_size(Some(video), audio, duration_seconds); options.push(MediaFormat { format_id: selected_format_id(Some(video), audio).unwrap_or_else(|| { format!("bestvideo[height<={height}][ext=mp4]+bestaudio[ext=m4a]/best[height<={height}][ext=mp4]/bestvideo[height<={height}]+bestaudio/best[height<={height}]") }), resolution: format!("{height}p"), ext: "mp4".to_string(), format_label: joined_format_label("mp4", json_str(video, "vcodec"), audio.and_then(|format| json_str(format, "acodec"))), fps: json_f64(video, "fps"), filesize, filesize_approx, }); } if let Some(video) = best_matching_format(&clean_formats, |format| { has_video_stream(format) && matches_media_height(format, height) && json_lower(format, "ext") == "webm" }) { let audio = if has_audio_stream(video) { None } else { best_audio_format(&clean_formats, Some("webm")) .or_else(|| best_audio_format(&clean_formats, Some("opus"))) .or_else(|| best_audio_format(&clean_formats, None)) }; let (filesize, filesize_approx) = estimated_merged_size(Some(video), audio, duration_seconds); options.push(MediaFormat { format_id: selected_format_id(Some(video), audio).unwrap_or_else(|| { format!("bestvideo[height<={height}][ext=webm]+bestaudio[ext=webm]/best[height<={height}][ext=webm]/bestvideo[height<={height}]+bestaudio/best[height<={height}]") }), resolution: format!("{height}p"), ext: "webm".to_string(), format_label: joined_format_label("webm", json_str(video, "vcodec"), audio.and_then(|format| json_str(format, "acodec"))), fps: json_f64(video, "fps"), filesize, filesize_approx, }); } } } if has_audio { if let Some(audio) = best_audio_format(&clean_formats, Some("m4a")) { let (filesize, filesize_approx) = split_size_estimate(estimated_stream_bytes(audio, duration_seconds)); options.push(MediaFormat { format_id: json_str(audio, "format_id") .unwrap_or("bestaudio[ext=m4a]/bestaudio/best") .to_string(), resolution: "Audio only".to_string(), ext: "m4a".to_string(), format_label: joined_format_label("m4a", None, json_str(audio, "acodec")), fps: None, filesize, filesize_approx, }); } if let Some(audio) = best_audio_format(&clean_formats, Some("webm")) .or_else(|| best_audio_format(&clean_formats, Some("opus"))) { let (filesize, filesize_approx) = split_size_estimate(estimated_stream_bytes(audio, duration_seconds)); options.push(MediaFormat { format_id: json_str(audio, "format_id") .unwrap_or("bestaudio[ext=webm]/bestaudio/best") .to_string(), resolution: "Audio only".to_string(), ext: "opus".to_string(), format_label: joined_format_label("opus", None, json_str(audio, "acodec")), fps: None, filesize, filesize_approx, }); } if let Some(audio) = best_audio_format(&clean_formats, None) { let (filesize, filesize_approx) = split_size_estimate(estimated_stream_bytes(audio, duration_seconds)); options.push(MediaFormat { format_id: json_str(audio, "format_id") .unwrap_or("bestaudio/best") .to_string(), resolution: "Audio only".to_string(), ext: "mp3".to_string(), format_label: "MP3 • Best audio".to_string(), fps: None, filesize, filesize_approx, }); } } if options.is_empty() { clean_formats .iter() .filter_map(|format| raw_media_format(format, duration_seconds)) .collect() } else { options } } const MEDIA_PROGRESS_PREFIX: &str = "__FIRELINK_PROGRESS__"; #[derive(Debug, PartialEq)] struct MediaProgress { fraction: f64, speed: String, eta: String, size: Option, downloaded_bytes: Option, total_bytes: Option, total_is_estimate: bool, } fn progress_json_number(progress: &serde_json::Value, key: &str) -> Option { progress.get(key).and_then(|value| { value .as_f64() .or_else(|| value.as_str().and_then(|text| text.parse::().ok())) }) } fn progress_json_string(progress: &serde_json::Value, key: &str) -> Option { progress .get(key) .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty() && *value != "N/A") .map(ToOwned::to_owned) } fn drain_media_output_lines(buffer: &mut String, chunk: &str) -> Vec { buffer.push_str(chunk); let mut lines = Vec::new(); while let Some(index) = match (buffer.find('\n'), buffer.find('\r')) { (Some(line_feed), Some(carriage_return)) => Some(line_feed.min(carriage_return)), (Some(line_feed), None) => Some(line_feed), (None, Some(carriage_return)) => Some(carriage_return), (None, None) => None, } { let mut line: String = buffer.drain(..=index).collect(); while line.ends_with('\n') || line.ends_with('\r') { line.pop(); } if !line.trim().is_empty() { lines.push(line); } } if lines.is_empty() && buffer.contains(MEDIA_PROGRESS_PREFIX) && parse_media_progress_line(buffer).is_some() { lines.push(std::mem::take(buffer)); } lines } fn flush_media_output_line(buffer: &mut String) -> Option { let line = std::mem::take(buffer); (!line.trim().is_empty()).then_some(line) } fn parse_media_progress_line(line: &str) -> Option { if let Some(prefix_index) = line.find(MEDIA_PROGRESS_PREFIX) { let progress: serde_json::Value = serde_json::from_str(line[prefix_index + MEDIA_PROGRESS_PREFIX.len()..].trim()).ok()?; let downloaded = progress_json_number(&progress, "downloaded_bytes").unwrap_or(0.0); let exact_total = progress_json_number(&progress, "total_bytes"); let estimated_total = progress_json_number(&progress, "total_bytes_estimate"); let total = exact_total.or(estimated_total).unwrap_or(0.0); let fragment_index = progress_json_number(&progress, "fragment_index"); let fragment_count = progress_json_number(&progress, "fragment_count"); let fraction = if let (Some(fragment_index), Some(fragment_count)) = (fragment_index, fragment_count) { if fragment_count > 1.0 { (fragment_index / fragment_count).clamp(0.0, 1.0) } else if total > 0.0 { downloaded / total } else { 0.0 } } else if total > 0.0 { downloaded / total } else { progress_json_number(&progress, "_percent") .or_else(|| { progress_json_string(&progress, "_percent_str").and_then(|percent| { percent.trim_end_matches('%').trim().parse::().ok() }) }) .unwrap_or(0.0) / 100.0 }; let speed = progress_json_string(&progress, "_speed_str") .or_else(|| { progress_json_number(&progress, "speed") .filter(|speed| *speed > 0.0) .map(|speed| format!("{}/s", crate::download::format_size(speed))) }) .unwrap_or_else(|| "-".to_string()); let eta = progress_json_string(&progress, "_eta_str") .or_else(|| { progress_json_number(&progress, "eta") .map(|seconds| format!("{}s", seconds.round() as u64)) }) .unwrap_or_else(|| "-".to_string()); let total_is_estimate = exact_total.is_none() && estimated_total.is_some(); // yt-dlp's estimated total is a moving bitrate-based guess. Use it to // derive a percentage when no better signal exists, but never expose // it as the media byte denominator. The Add window supplies the // stable format estimate, and an exact total is emitted at completion. let size = if total_is_estimate { None } else { progress_json_string(&progress, "_total_bytes_str") .or_else(|| exact_total.map(|total| crate::download::format_size(total))) }; return Some(MediaProgress { fraction: fraction.clamp(0.0, 1.0), speed, eta, size, downloaded_bytes: (total > 0.0 || downloaded > 0.0).then_some(downloaded), total_bytes: exact_total.filter(|total| total.is_finite() && *total > 0.0), total_is_estimate, }); } static ARIA2_RE: std::sync::OnceLock = std::sync::OnceLock::new(); static YTDLP_PCT_RE: std::sync::OnceLock = std::sync::OnceLock::new(); static YTDLP_SPD_RE: std::sync::OnceLock = std::sync::OnceLock::new(); static YTDLP_ETA_RE: std::sync::OnceLock = std::sync::OnceLock::new(); static YTDLP_SIZE_RE: std::sync::OnceLock = std::sync::OnceLock::new(); let aria2_re = ARIA2_RE.get_or_init(|| { Regex::new( r"\[#[^\s]+\s+([^/\s]+)/([^\s(]+)\((\d+(?:\.\d+)?)%\).*?\bDL:([^\s\]]+)(?:\s+ETA:([^\s\]]+))?", ) .unwrap() }); if let Some(captures) = aria2_re.captures(line) { let fraction = captures.get(3)?.as_str().parse::().ok()? / 100.0; let speed = captures .get(4) .map(|value| format!("{}/s", value.as_str())) .unwrap_or_else(|| "-".to_string()); let eta = captures .get(5) .map(|value| value.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); let size = captures.get(2).map(|value| value.as_str().to_string()); let downloaded_bytes = captures .get(1) .and_then(|value| parse_human_size(value.as_str())); let total_bytes = captures .get(2) .and_then(|value| parse_human_size(value.as_str())); return Some(MediaProgress { fraction: fraction.clamp(0.0, 1.0), speed, eta, size, downloaded_bytes, total_bytes, total_is_estimate: false, }); } let percent_re = YTDLP_PCT_RE.get_or_init(|| Regex::new(r"\[download\]\s+~?\s*(\d+(?:\.\d+)?)%").unwrap()); let captures = percent_re.captures(line)?; let fraction = captures.get(1)?.as_str().parse::().ok()? / 100.0; let speed_re = YTDLP_SPD_RE.get_or_init(|| Regex::new(r"\bat\s+([^\s]+)").unwrap()); let eta_re = YTDLP_ETA_RE.get_or_init(|| Regex::new(r"\bETA\s+([^\s]+)").unwrap()); let size_re = YTDLP_SIZE_RE .get_or_init(|| Regex::new(r"of\s+(~?)\s*([0-9.]+[a-zA-Z]+)").unwrap()); let mut parsed_size_str = None; let mut downloaded_bytes = None; let mut total_bytes = None; let mut total_is_estimate = false; if let Some(captures) = size_re.captures(line) { if let Some(size_str) = captures.get(2) { parsed_size_str = Some(size_str.as_str().to_string()); if let Some(parsed_total_bytes) = parse_human_size(size_str.as_str()) { downloaded_bytes = Some(parsed_total_bytes * fraction); total_bytes = Some(parsed_total_bytes); } } total_is_estimate = captures .get(1) .map(|marker| !marker.as_str().is_empty()) .unwrap_or(false); } Some(MediaProgress { fraction: fraction.clamp(0.0, 1.0), speed: speed_re .captures(line) .and_then(|capture| capture.get(1)) .map(|value| value.as_str().to_string()) .unwrap_or_else(|| "-".to_string()), eta: eta_re .captures(line) .and_then(|capture| capture.get(1)) .map(|value| value.as_str().to_string()) .unwrap_or_else(|| "-".to_string()), size: parsed_size_str, downloaded_bytes, total_bytes, total_is_estimate, }) } fn parse_human_size(s: &str) -> Option { let s = s.trim().to_lowercase(); let num_str = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect::(); let num = num_str.parse::().ok()?; if s.ends_with("kib") || s.ends_with("kb") || s.ends_with("k") { Some(num * 1024.0) } else if s.ends_with("mib") || s.ends_with("mb") || s.ends_with("m") { Some(num * 1024.0 * 1024.0) } else if s.ends_with("gib") || s.ends_with("gb") || s.ends_with("g") { Some(num * 1024.0 * 1024.0 * 1024.0) } else { Some(num) } } const MEDIA_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(1000); const MEDIA_SPEED_SAMPLE_WINDOW: Duration = Duration::from_secs(8); #[derive(Debug, Default)] struct MediaSpeedSampler { samples: VecDeque<(Instant, f64)>, } impl MediaSpeedSampler { fn reset(&mut self) { self.samples.clear(); } fn sample(&mut self, downloaded_bytes: f64, now: Instant) -> Option { if self .samples .back() .is_some_and(|(_, last_bytes)| downloaded_bytes < *last_bytes) { self.reset(); } self.samples.push_back((now, downloaded_bytes)); while self.samples.len() > 2 { let Some((oldest_at, _)) = self.samples.front() else { break; }; if now.duration_since(*oldest_at) <= MEDIA_SPEED_SAMPLE_WINDOW { break; } self.samples.pop_front(); } let (oldest_at, oldest_bytes) = *self.samples.front()?; let elapsed = now.duration_since(oldest_at).as_secs_f64(); if elapsed <= 0.25 || downloaded_bytes <= oldest_bytes { return None; } Some((downloaded_bytes - oldest_bytes) / elapsed) } } fn media_progress_speed( progress: &MediaProgress, now: Instant, speed_sampler: &mut MediaSpeedSampler, ) -> (String, String) { let Some(downloaded_bytes) = progress.downloaded_bytes else { return (progress.speed.clone(), progress.eta.clone()); }; if let Some(bytes_per_second) = speed_sampler.sample(downloaded_bytes, now) { let speed_str = crate::download::format_speed(bytes_per_second); let eta_str = if progress.fraction > 0.0 && progress.fraction < 1.0 { let total = downloaded_bytes / progress.fraction; let remaining = total - downloaded_bytes; crate::download::format_duration(remaining / bytes_per_second) } else { progress.eta.clone() }; (speed_str, eta_str) } else { (progress.speed.clone(), progress.eta.clone()) } } fn aggregate_media_fraction( total_tracks: f64, current_track: &mut f64, last_fraction: &mut f64, next_fraction: f64, ) -> f64 { let next_fraction = next_fraction.clamp(0.0, 1.0); let has_next_track = *current_track + 1.0 < total_tracks; if has_next_track && *last_fraction >= 0.95 && next_fraction <= 0.05 { *current_track += 1.0; *last_fraction = next_fraction; } else { *last_fraction = (*last_fraction).max(next_fraction); } ((*current_track + *last_fraction) / total_tracks).clamp(0.0, 1.0) } struct MediaProgressEmitterState { current_track: f64, last_fraction: f64, speed_sampler: MediaSpeedSampler, last_progress_at: Instant, completed_tracks_downloaded_bytes: Option, completed_tracks_total_bytes: Option, completed_tracks_total_is_estimate: bool, current_track_downloaded_bytes: Option, current_track_total_bytes: Option, current_track_total_is_estimate: bool, } impl MediaProgressEmitterState { fn new() -> Self { Self { current_track: 0.0, last_fraction: 0.0, speed_sampler: MediaSpeedSampler::default(), last_progress_at: Instant::now() .checked_sub(MEDIA_PROGRESS_EMIT_INTERVAL) .unwrap_or_else(Instant::now), completed_tracks_downloaded_bytes: Some(0.0), completed_tracks_total_bytes: Some(0.0), completed_tracks_total_is_estimate: false, current_track_downloaded_bytes: None, current_track_total_bytes: None, current_track_total_is_estimate: false, } } } fn aggregate_media_byte_progress( progress: &MediaProgress, track_changed: bool, state: &mut MediaProgressEmitterState, ) -> Option<(u64, u64, bool)> { if track_changed { if let Some(total_bytes) = state.current_track_total_bytes { let completed_downloaded = state.current_track_downloaded_bytes.unwrap_or(total_bytes); let completed_downloaded = if state.current_track_total_is_estimate { completed_downloaded.max(0.0) } else { completed_downloaded.clamp(0.0, total_bytes.max(0.0)) }; *state .completed_tracks_total_bytes .get_or_insert(0.0) += total_bytes; *state .completed_tracks_downloaded_bytes .get_or_insert(0.0) += completed_downloaded; state.completed_tracks_total_is_estimate |= state.current_track_total_is_estimate; } else { state.completed_tracks_total_bytes = None; state.completed_tracks_downloaded_bytes = None; } state.current_track_downloaded_bytes = None; state.current_track_total_bytes = None; state.current_track_total_is_estimate = false; } // yt-dlp's estimated total is a moving bitrate-based guess. Keep the // first total observed for a stream stable so the UI does not make the // download's size appear to change on every progress update. A new media // track gets its own stable total when split audio/video downloads switch // tracks. let next_total = progress .total_bytes .filter(|total_bytes| total_bytes.is_finite() && *total_bytes > 0.0); if state.current_track_total_bytes.is_none() || (state.current_track_total_is_estimate && !progress.total_is_estimate) { if let Some(total_bytes) = next_total { state.current_track_total_bytes = Some(total_bytes); state.current_track_total_is_estimate = progress.total_is_estimate; } } let stable_total_bytes = state.current_track_total_bytes; state.current_track_downloaded_bytes = progress .downloaded_bytes .or_else(|| progress.total_bytes.map(|total| total * progress.fraction)) .zip(stable_total_bytes) .map(|(downloaded, total)| { // An estimated total is only a display anchor. yt-dlp can // legitimately download more bytes than that first estimate, // so never turn a moving estimate into a false 100% byte count. if state.current_track_total_is_estimate { downloaded.max(0.0) } else { downloaded.clamp(0.0, total.max(0.0)) } }) .or(progress.downloaded_bytes); match ( state.completed_tracks_downloaded_bytes, state.completed_tracks_total_bytes, state.current_track_downloaded_bytes, state.current_track_total_bytes, ) { ( Some(completed_downloaded), Some(completed_total), Some(current_downloaded), Some(current_total), ) if current_total > 0.0 => Some(( (completed_downloaded + current_downloaded).max(0.0).round() as u64, (completed_total + current_total).max(0.0).round() as u64, state.completed_tracks_total_is_estimate || state.current_track_total_is_estimate, )), _ => None, } } fn media_progress_event_totals( progress: &MediaProgress, byte_progress: Option<(u64, u64, bool)>, total_tracks: f64, ) -> (Option, Option) { let fallback_total = (total_tracks <= 1.0) .then_some(progress.total_bytes) .flatten(); let total_bytes = byte_progress .map(|value| value.1 as f64) .or(fallback_total); let total_is_estimate = byte_progress .map(|value| value.2) .or_else(|| fallback_total.map(|_| progress.total_is_estimate)); (total_bytes, total_is_estimate) } fn emit_media_progress( app_handle: &tauri::AppHandle, id: &str, progress: MediaProgress, total_tracks: f64, state: &mut MediaProgressEmitterState, ) { let previous_track = state.current_track; let overall_fraction = aggregate_media_fraction( total_tracks, &mut state.current_track, &mut state.last_fraction, progress.fraction, ); let track_changed = state.current_track != previous_track; if track_changed { state.speed_sampler.reset(); } let byte_progress = aggregate_media_byte_progress(&progress, track_changed, state); let (speed, eta) = media_progress_speed(&progress, Instant::now(), &mut state.speed_sampler); let (total_bytes, total_is_estimate) = media_progress_event_totals(&progress, byte_progress, total_tracks); let downloaded_bytes = byte_progress .map(|value| value.0 as f64) .or(progress.downloaded_bytes); let size = byte_progress .map(|(_, total, total_is_estimate)| { let prefix = if total_is_estimate { "~" } else { "" }; format!("{prefix}{}", crate::download::format_size(total as f64)) }) .or(progress.size); let now = Instant::now(); if now.duration_since(state.last_progress_at) >= MEDIA_PROGRESS_EMIT_INTERVAL { let _ = app_handle.emit( "download-progress", DownloadProgressEvent { id: id.to_string(), fraction: overall_fraction, speed, eta, size, size_is_final: false, downloaded_bytes, total_bytes, total_is_estimate, }, ); state.last_progress_at = now; } } async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) { cleanup_media_artifacts(out_path, true).await; } async fn remove_file_best_effort_with_retry(path: &std::path::Path) { for attempt in 0..=5 { match tokio::fs::remove_file(path).await { Ok(()) => return, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, Err(error) if attempt == 5 => { log::warn!( "failed to remove media artifact '{}' after retries: {}", path.display(), error ); } Err(_) => { tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } } } async fn cleanup_media_artifacts(out_path: &std::path::Path, remove_primary: bool) { let Some(parent) = out_path.parent() else { return; }; let Some(base_name) = out_path.file_name().and_then(|name| name.to_str()) else { return; }; let base_stem = out_path .file_stem() .and_then(|name| name.to_str()) .unwrap_or(base_name); if remove_primary { remove_file_best_effort_with_retry(out_path).await; } let Ok(mut entries) = tokio::fs::read_dir(parent).await else { return; }; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); if path == out_path { continue; } let Some(name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; if !name.starts_with(base_name) && !name.starts_with(base_stem) { continue; } let yt_dlp_format_fragment = name .strip_prefix(base_stem) .and_then(|suffix| suffix.strip_prefix(".f")) .and_then(|suffix| suffix.chars().next()) .is_some_and(|ch| ch.is_ascii_digit()); let looks_like_media_temp = name.contains(".part") || name.contains(".ytdl") || name.contains(".temp") || name.contains(".tmp") || yt_dlp_format_fragment; if looks_like_media_temp { remove_file_best_effort_with_retry(&path).await; } } } fn sanitize_ytdlp_config_value(value: &str) -> String { value.replace(['\n', '\r'], "") } fn append_ytdlp_config_option(config: &mut String, option: &str, value: &str) { let safe_value = sanitize_ytdlp_config_value(value); if !safe_value.is_empty() { config.push_str(option); config.push(' '); // yt-dlp parses one configuration line at a time. Quoting keeps // whitespace and cookie delimiters inside this option's single value, // rather than turning them into additional input URLs. config.push('\''); config.push_str(&safe_value.replace('\'', "'\\''")); config.push('\''); config.push('\n'); } } fn append_ytdlp_add_header(config: &mut String, header: &str) -> Result { let safe_header = sanitize_ytdlp_config_value(header).trim().to_string(); if safe_header.is_empty() { return Ok(false); } let Some((name, _)) = safe_header.split_once(':') else { return Err(format!("invalid HTTP header: {safe_header}")); }; if name.trim().is_empty() { return Err(format!("invalid HTTP header: {safe_header}")); } append_ytdlp_config_option(config, "--add-header", &safe_header); Ok(name.trim().eq_ignore_ascii_case("cookie")) } fn append_ytdlp_http_headers( config: &mut String, headers: Option<&str>, cookies: Option<&str>, ) -> Result<(), String> { let mut has_cookie_header = false; if let Some(headers) = headers { for header in headers.lines() { has_cookie_header |= append_ytdlp_add_header(config, header)?; } } if !has_cookie_header { if let Some(cookies) = cookies { let safe_cookies = sanitize_ytdlp_config_value(cookies).trim().to_string(); if !safe_cookies.is_empty() { append_ytdlp_add_header(config, &format!("Cookie: {safe_cookies}"))?; } } } Ok(()) } fn is_browser_cookie_extraction_error(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("could not copy") && lower.contains("cookie database") || lower.contains("could not access browser cookie database") || lower.contains("failed to read browser cookie") || lower.contains("failed to decrypt with dpapi") } fn should_retry_without_browser_cookies( cookie_browser: Option<&str>, failure_reason: &str, fallback_used: bool, ) -> bool { // Browser cookies are an optional authentication enhancement. Chromium on // Windows can deny access to its database independently of whether the // requested media needs authentication, so only this narrowly classified // extraction failure may downgrade to a public-media attempt. !fallback_used && cookie_browser.is_some_and(|source| { let source = source.trim(); !source.is_empty() && !source.eq_ignore_ascii_case("none") }) && is_browser_cookie_extraction_error(failure_reason) } fn should_cleanup_media_artifacts_after_failure( failure_reason: &str, strike: usize, max_retries: usize, ) -> bool { !(crate::retry::is_transient_network_error(failure_reason) && strike < max_retries) } fn is_blocked_network_address(ip: std::net::IpAddr) -> bool { if ip.is_loopback() || ip.is_multicast() || ip.is_unspecified() { return true; } match ip { std::net::IpAddr::V4(ipv4) => ipv4.is_private() || ipv4.is_link_local(), std::net::IpAddr::V6(ipv6) => { ipv6.to_ipv4().is_some_and(|ipv4| is_blocked_network_address(ipv4.into())) || (ipv6.segments()[0] & 0xfe00) == 0xfc00 || (ipv6.segments()[0] & 0xffc0) == 0xfe80 } } } async fn resolve_and_validate_url_host( parsed: &reqwest::Url, ) -> Result<(String, std::net::SocketAddr), String> { let host = parsed.host_str().ok_or("SSRF blocked: No host")?; let lookup_host = host.trim_start_matches('[').trim_end_matches(']'); let port = parsed.port_or_known_default().unwrap_or_else(|| match parsed.scheme() { "ftp" => 21, "sftp" => 22, _ => 80, }); let addrs: Vec<_> = if let Ok(ip) = lookup_host.parse::() { vec![std::net::SocketAddr::new(ip, port)] } else { tokio::net::lookup_host((lookup_host, port)) .await .map_err(|_| "SSRF blocked: DNS resolution failed")? .collect() }; let addr = addrs.first().copied().ok_or("SSRF blocked: No DNS records")?; if addrs.iter().any(|candidate| is_blocked_network_address(candidate.ip())) { return Err("SSRF blocked: Private/local IP not allowed".to_string()); } Ok((lookup_host.to_string(), addr)) } async fn validate_url_ssrf(url: &str) -> Result, String> { let parsed = reqwest::Url::parse(url).map_err(|_| "SSRF blocked: Invalid URL")?; if parsed.scheme() != "http" && parsed.scheme() != "https" { return Err("SSRF blocked: Only HTTP/HTTPS schemes allowed".to_string()); } resolve_and_validate_url_host(&parsed).await.map(Some) } fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool { left.scheme() == right.scheme() && left.host() == right.host() && left.port_or_known_default() == right.port_or_known_default() } fn should_send_metadata_credentials( original: Option<&reqwest::Url>, current: Option<&reqwest::Url>, redirects: usize, ) -> bool { redirects == 0 || original .zip(current) .is_some_and(|(original, current)| same_origin(original, current)) } fn cookie_scope_for_url<'a>( current_url: &str, cookie_scopes: Option<&'a [extension_server::ExtensionCookieScope]>, ) -> Option<&'a str> { let current = reqwest::Url::parse(current_url).ok()?; cookie_scopes?.iter().find_map(|scope| { let scope_url = reqwest::Url::parse(&scope.url).ok()?; let same_googleusercontent_site = scope_url.scheme() == current.scheme() && scope_url.port_or_known_default() == current.port_or_known_default() && scope_url.host_str() == Some("googleusercontent.com") && current .host_str() .is_some_and(|host| host.ends_with(".googleusercontent.com")); (same_origin(&scope_url, ¤t) || same_googleusercontent_site) .then_some(scope.cookies.as_str()) }) } fn metadata_authentication_error(current_url: &str, content_type: Option<&str>) -> Option { let url = reqwest::Url::parse(current_url).ok()?; let host = url.host_str()?.to_ascii_lowercase(); let path = url.path().to_ascii_lowercase(); let content_type = content_type.unwrap_or_default().to_ascii_lowercase(); let is_html = content_type.starts_with("text/html") || content_type.starts_with("application/xhtml+xml"); let is_google_auth_host = host == "accounts.google.com" && (path.contains("signin") || path.contains("servicelogin") || url.query().is_some_and(|query| query.contains("continue="))); (is_google_auth_host && (is_html || path.contains("signin"))).then(|| { "The server returned a Google sign-in page instead of the requested attachment. Re-authenticate in the browser and try again.".to_string() }) } #[allow(clippy::too_many_arguments)] // Keep the metadata IPC fields explicit and independently typed. #[tauri::command] async fn fetch_metadata( url: String, user_agent: Option, username: Option, password: Option, headers: Option, cookies: Option, cookie_scopes: Option>, proxy: Option, defer_cookies: Option, ) -> Result { ensure_reqwest_crypto_provider(); let mut current_url = url.clone(); let original_origin = reqwest::Url::parse(&url).ok(); let mut redirects = 0; let cookies_available = metadata_cookie_header_present( headers.as_deref(), cookies.as_deref(), cookie_scopes.as_deref(), ); let defer_cookies = defer_cookies.unwrap_or(false); let mut send_cookies = !defer_cookies || !cookies_available; let mut cookie_retry_attempted = false; let res; loop { if redirects >= 5 { return Err("Too many redirects".to_string()); } let mut builder = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(FILE_METADATA_TIMEOUT); if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { if proxy.eq_ignore_ascii_case("none") { builder = builder.no_proxy(); } else { builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|e| e.to_string())?); } } if let Some(ref ua) = user_agent { let ua = ua.trim(); if !ua.is_empty() { builder = builder.user_agent(ua); } else { builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"); } } else { builder = builder.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"); } let parsed_current_url = reqwest::Url::parse(¤t_url) .map_err(|_| "SSRF blocked: Invalid URL".to_string())?; let resolved_addr = match parsed_current_url.scheme() { "http" | "https" => validate_url_ssrf(¤t_url).await?, "ftp" | "sftp" => resolve_and_validate_url_host(&parsed_current_url) .await .map(Some)?, _ => return Err("Unsupported URL scheme".to_string()), }; if let Some((host, addr)) = resolved_addr { builder = builder.resolve(&host, addr); } let current_origin = reqwest::Url::parse(¤t_url).ok(); let same_origin_credentials = should_send_metadata_credentials( original_origin.as_ref(), current_origin.as_ref(), redirects, ); let scoped_cookie = if send_cookies { cookie_scope_for_url(¤t_url, cookie_scopes.as_deref()) } else { None }; let cookie_header = if same_origin_credentials { scoped_cookie.or(cookies.as_deref()) } else { scoped_cookie }; let header_map = if same_origin_credentials { metadata_headers(headers.as_deref(), cookie_header, send_cookies) } else { // A cross-origin redirect may receive only the cookie scope that // explicitly matches its host. Never forward captured headers or // a source-host Cookie header to an unrelated redirect target. metadata_headers(None, cookie_header, send_cookies) }; builder = builder.default_headers(header_map); let client = builder.build().map_err(|e| e.to_string())?; let request_url = current_url.clone(); let build_get_range = || { let mut get_req = client .get(&request_url) .header(reqwest::header::RANGE, "bytes=0-0"); if same_origin_credentials { if let Some(ref user) = username { if !user.is_empty() { get_req = get_req.basic_auth(user, password.as_deref()); } } } get_req }; let mut head_req = client.head(&request_url); if same_origin_credentials { if let Some(ref user) = username { if !user.is_empty() { head_req = head_req.basic_auth(user, password.as_deref()); } } } let mut current_res = match head_req.send().await { Ok(response) => response, Err(head_error) => build_get_range().send().await.map_err(|get_error| { format!( "HEAD metadata request failed ({head_error}); ranged GET fallback failed ({get_error})" ) })?, }; if retry_metadata_with_cookies( current_res.status(), cookies_available, &mut send_cookies, &mut cookie_retry_attempted, &url, &mut current_url, &mut redirects, ) { // Browser captures may carry a large cookie jar even when the // direct download is public. Probe without it first so a server // or proxy header limit cannot turn harmless metadata into the // generic fallback state. Retry once with the captured cookie // header only when the origin explicitly challenges us. continue; } let mut needs_fallback = false; if (!current_res.status().is_success() && !current_res.status().is_redirection()) || (current_res.status().is_success() && current_res.headers().get(reqwest::header::CONTENT_LENGTH).is_none()) { needs_fallback = true; } if needs_fallback { current_res = build_get_range().send().await.map_err(|e| e.to_string())?; if retry_metadata_with_cookies( current_res.status(), cookies_available, &mut send_cookies, &mut cookie_retry_attempted, &url, &mut current_url, &mut redirects, ) { // HEAD is advisory. Some origins reject it while requiring // the captured session for the ranged GET that follows. continue; } } if current_res.status().is_redirection() { if let Some(loc) = current_res.headers().get(reqwest::header::LOCATION) { if let Ok(loc_str) = loc.to_str() { if let Ok(parsed_base) = reqwest::Url::parse(¤t_url) { if let Ok(new_url) = parsed_base.join(loc_str) { current_url = new_url.to_string(); redirects += 1; continue; } } } } } if let Some(error) = metadata_authentication_error( ¤t_url, current_res .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()), ) { return Err(error); } if let Some(error) = metadata_response_error(current_res.status()) { return Err(error); } res = current_res; break; } let filename = metadata_filename_from_response(&res, ¤t_url, &url); let mut size_str = "Unknown".to_string(); let mut size_bytes = 0; if res.status() == reqwest::StatusCode::PARTIAL_CONTENT { if let Some(content_range) = res.headers().get(reqwest::header::CONTENT_RANGE) { if let Ok(cr_str) = content_range.to_str() { if let Some(idx) = cr_str.find('/') { if let Ok(bytes) = cr_str[idx + 1..].parse::() { size_bytes = bytes; } } } } } if size_bytes == 0 { if let Some(len) = res.headers().get(reqwest::header::CONTENT_LENGTH) { if let Ok(len_str) = len.to_str() { if let Ok(bytes) = len_str.parse::() { size_bytes = bytes; } } } } if size_bytes > 0 { let bytes = size_bytes; if bytes < 1024 { size_str = format!("{} B", bytes); } else if bytes < 1024 * 1024 { size_str = format!("{:.1} KB", bytes as f64 / 1024.0); } else if bytes < 1024 * 1024 * 1024 { size_str = format!("{:.1} MB", bytes as f64 / 1024.0 / 1024.0); } else { size_str = format!("{:.2} GB", bytes as f64 / 1024.0 / 1024.0 / 1024.0); } } let mut resumable = false; if res.status() == reqwest::StatusCode::PARTIAL_CONTENT { resumable = true; } else if let Some(accept_ranges) = res.headers().get(reqwest::header::ACCEPT_RANGES) { if let Ok(accept_ranges_str) = accept_ranges.to_str() { if accept_ranges_str.contains("bytes") { resumable = true; } } } Ok(MetadataResponse { url: current_url, filename, size: size_str, size_bytes, resumable, }) } const MEDIA_METADATA_CACHE_TTL: Duration = Duration::from_secs(60); const MEDIA_METADATA_TIMEOUT: Duration = Duration::from_secs(55); const MEDIA_METADATA_CACHE_MAX_ENTRIES: usize = 128; const FILE_METADATA_TIMEOUT: Duration = Duration::from_secs(20); const MAX_SHELL_OUTPUT_BYTES: usize = 32 * 1024 * 1024; struct ShellCommandOutput { status_code: Option, stdout: Vec, stderr: Vec, } fn terminate_shell_process_tree(child: tauri_plugin_shell::process::CommandChild) { crate::process::kill_process_tree(child.pid()); // The process-tree helper may already have signalled the root. The // direct kill is still needed when the root was not visible in the // snapshot, but its result is cleanup telemetry, not the operation's // primary error. let _ = child.kill(); } async fn shell_command_output_with_timeout( command: tauri_plugin_shell::process::Command, timeout: Duration, operation: &str, ) -> Result { let (mut events, child) = command .spawn() .map_err(|error| format!("failed to spawn command: {error}"))?; let collect_output = async move { let mut output = ShellCommandOutput { status_code: None, stdout: Vec::new(), stderr: Vec::new(), }; let mut output_bytes = 0usize; while let Some(event) = events.recv().await { match event { tauri_plugin_shell::process::CommandEvent::Stdout(bytes) => { let next_size = output_bytes .saturating_add(bytes.len()) .saturating_add(1); if next_size > MAX_SHELL_OUTPUT_BYTES { return Err(format!( "{operation} produced more than {} MiB of output", MAX_SHELL_OUTPUT_BYTES / (1024 * 1024) )); } output.stdout.extend(bytes); output.stdout.push(b'\n'); output_bytes = next_size; } tauri_plugin_shell::process::CommandEvent::Stderr(bytes) => { let next_size = output_bytes .saturating_add(bytes.len()) .saturating_add(1); if next_size > MAX_SHELL_OUTPUT_BYTES { return Err(format!( "{operation} produced more than {} MiB of output", MAX_SHELL_OUTPUT_BYTES / (1024 * 1024) )); } output.stderr.extend(bytes); output.stderr.push(b'\n'); output_bytes = next_size; } tauri_plugin_shell::process::CommandEvent::Terminated(payload) => { output.status_code = payload.code; } tauri_plugin_shell::process::CommandEvent::Error(_) => {} _ => {} } } Ok(output) }; match tokio::time::timeout(timeout, collect_output).await { Ok(Ok(output)) if output.status_code.is_some() => Ok(output), Ok(Ok(_)) => { terminate_shell_process_tree(child); Err(format!("{operation} ended without a process exit status")) } Ok(Err(error)) => { terminate_shell_process_tree(child); Err(error) } Err(_) => { terminate_shell_process_tree(child); Err(format!( "{operation} timed out after {}s", timeout.as_secs() )) } } } static MEDIA_METADATA_CACHE: OnceLock>> = OnceLock::new(); static MEDIA_METADATA_LOCKS: OnceLock< tokio::sync::Mutex>>>, > = OnceLock::new(); #[allow(clippy::too_many_arguments)] // Hash every user-controlled yt-dlp input explicitly. fn media_metadata_cache_key( url: &str, cookie_browser: &Option, user_agent: &Option, username: &Option, password: &Option, headers: &Option, cookies: &Option, proxy: &Option, ) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); url.hash(&mut hasher); cookie_browser.hash(&mut hasher); user_agent.hash(&mut hasher); username.hash(&mut hasher); password.hash(&mut hasher); headers.hash(&mut hasher); cookies.hash(&mut hasher); proxy.hash(&mut hasher); hasher.finish() } async fn release_media_metadata_lock( cache_key: u64, request_lock: &std::sync::Arc>, ) { let locks = MEDIA_METADATA_LOCKS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); let mut locks_guard = locks.lock().await; if std::sync::Arc::strong_count(request_lock) == 2 && locks_guard .get(&cache_key) .is_some_and(|current| std::sync::Arc::ptr_eq(current, request_lock)) { locks_guard.remove(&cache_key); } } fn resolve_metadata_ytdlp_path( app_handle: &tauri::AppHandle, ) -> Result<(PathBuf, &'static str), String> { resolve_bundled_binary_path(app_handle, "yt-dlp") .map(|path| (path, "bundled")) .map_err(|e| format!("failed to find bundled yt-dlp: {e}")) } #[allow(clippy::too_many_arguments)] fn build_ytdlp_config_content( username: Option<&str>, password: Option<&str>, headers: Option<&str>, cookies: Option<&str>, ) -> Result { let mut config_content = String::new(); if let Some(user) = username { if !user.is_empty() { append_ytdlp_config_option(&mut config_content, "--username", user); } } if let Some(pass) = password { if !pass.is_empty() { append_ytdlp_config_option(&mut config_content, "--password", pass); } } append_ytdlp_http_headers(&mut config_content, headers, cookies)?; Ok(config_content) } #[tauri::command] #[allow(clippy::too_many_arguments)] // Keep the generated TypeScript IPC contract flat and stable. async fn fetch_media_metadata( app_handle: tauri::AppHandle, url: String, cookie_browser: Option, user_agent: Option, username: Option, password: Option, headers: Option, cookies: Option, proxy: Option, ) -> Result { validate_url_ssrf(&url).await?; let cache_key = media_metadata_cache_key( &url, &cookie_browser, &user_agent, &username, &password, &headers, &cookies, &proxy, ); let cache = MEDIA_METADATA_CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); let mut cache_guard = cache.lock().await; cache_guard.retain(|_, (cached_at, _)| cached_at.elapsed() <= MEDIA_METADATA_CACHE_TTL); if let Some((_, metadata)) = cache_guard.get(&cache_key).cloned() { return Ok(metadata); } drop(cache_guard); let request_lock = { let locks = MEDIA_METADATA_LOCKS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); let mut locks = locks.lock().await; locks .entry(cache_key) .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) .clone() }; let request_guard = request_lock.lock().await; let mut cache_guard = cache.lock().await; cache_guard.retain(|_, (cached_at, _)| cached_at.elapsed() <= MEDIA_METADATA_CACHE_TTL); if let Some((_, metadata)) = cache_guard.get(&cache_key).cloned() { drop(cache_guard); drop(request_guard); release_media_metadata_lock(cache_key, &request_lock).await; return Ok(metadata); } drop(cache_guard); // A fallback result was resolved without the configured browser session; // keep that effective identity separate so a later successful cookie // extraction can refresh authenticated formats instead of hitting stale // public-only metadata. let mut result_cache_key = cache_key; let result = fetch_media_metadata_uncached( app_handle.clone(), url.clone(), cookie_browser.clone(), user_agent.clone(), username.clone(), password.clone(), headers.clone(), cookies.clone(), proxy.clone(), ) .await; let result = match (result, cookie_browser.as_deref()) { (Err(error), Some(browser)) if should_retry_without_browser_cookies(Some(browser), &error, false) => { log::warn!( "yt-dlp could not read browser cookies from {}; retrying media metadata without browser cookies", browser ); result_cache_key = media_metadata_cache_key( &url, &None, &user_agent, &username, &password, &headers, &cookies, &proxy, ); fetch_media_metadata_uncached( app_handle, url, None, user_agent, username, password, headers, cookies, proxy, ) .await } (result, _) => result, }; let result = match result { Ok(metadata) if metadata.formats.is_empty() => { Err("yt-dlp returned no usable media formats for this URL".to_string()) } Ok(metadata) => { let mut cache_guard = cache.lock().await; if cache_guard.len() >= MEDIA_METADATA_CACHE_MAX_ENTRIES && !cache_guard.contains_key(&result_cache_key) { if let Some(oldest_key) = cache_guard .iter() .min_by_key(|(_, (cached_at, _))| *cached_at) .map(|(key, _)| *key) { cache_guard.remove(&oldest_key); } } cache_guard.insert(result_cache_key, (Instant::now(), metadata.clone())); Ok(metadata) } Err(error) => Err(error), }; drop(request_guard); release_media_metadata_lock(cache_key, &request_lock).await; result } #[tauri::command] #[allow(clippy::too_many_arguments)] async fn fetch_media_playlist_metadata( app_handle: tauri::AppHandle, url: String, cookie_browser: Option, user_agent: Option, username: Option, password: Option, headers: Option, cookies: Option, proxy: Option, ) -> Result { validate_url_ssrf(&url).await?; let result = fetch_media_playlist_metadata_uncached( app_handle.clone(), url.clone(), cookie_browser.clone(), user_agent.clone(), username.clone(), password.clone(), headers.clone(), cookies.clone(), proxy.clone(), ) .await; match (result, cookie_browser.as_deref()) { (Err(error), Some(browser)) if should_retry_without_browser_cookies(Some(browser), &error, false) => { log::warn!( "yt-dlp could not read browser cookies from {}; retrying playlist metadata without browser cookies", browser ); fetch_media_playlist_metadata_uncached( app_handle, url, None, user_agent, username, password, headers, cookies, proxy, ) .await } (result, _) => result, } } #[allow(clippy::too_many_arguments)] async fn fetch_media_playlist_metadata_uncached( app_handle: tauri::AppHandle, url: String, cookie_browser: Option, user_agent: Option, username: Option, password: Option, headers: Option, cookies: Option, proxy: Option, ) -> Result { let deno_path = resolve_bundled_binary_path(&app_handle, "deno") .map_err(|e| format!("failed to find bundled deno: {e}"))?; let ffmpeg_path = resolve_bundled_binary_path(&app_handle, "ffmpeg") .map_err(|e| format!("failed to find bundled ffmpeg: {e}"))?; let deno_runtime = format!("deno:{}", deno_path.to_string_lossy()); let trusted_path = crate::platform::trusted_system_path()?; use tauri_plugin_shell::ShellExt; let (ytdlp_path, _) = resolve_metadata_ytdlp_path(&app_handle)?; let mut cmd = app_handle .shell() .command(ytdlp_path.to_string_lossy().to_string()); cmd = cmd .env("PATH", trusted_path) .arg("--ffmpeg-location") .arg(&ffmpeg_path) .arg("--js-runtimes") .arg(&deno_runtime) .arg("--no-warnings") .arg("--ignore-errors") .arg("--flat-playlist") .arg("--playlist-end") .arg(MEDIA_PLAYLIST_MAX_ENTRIES.to_string()) .arg("--dump-single-json") .arg("--socket-timeout") .arg("20") .arg("--retries") .arg("3") .arg("--extractor-retries") .arg("3") .arg("--compat-options") .arg("no-youtube-unavailable-videos"); if let Some(browser) = cookie_browser.as_deref() { if !browser.is_empty() { cmd = cmd.arg("--cookies-from-browser").arg(browser); } } if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { if proxy.eq_ignore_ascii_case("none") { cmd = cmd.arg("--proxy").arg(""); } else { cmd = cmd.arg("--proxy").arg(proxy); } } if let Some(ua) = user_agent .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { cmd = cmd.arg("--user-agent").arg(ua); } let mut config_file = tempfile::Builder::new() .prefix("ytdlp-playlist-") .suffix(".conf") .tempfile() .map_err(|e| e.to_string())?; let config_content = build_ytdlp_config_content( username.as_deref(), password.as_deref(), headers.as_deref(), cookies.as_deref(), )?; use std::io::Write; config_file .write_all(config_content.as_bytes()) .map_err(|e| e.to_string())?; let config_path = config_file.into_temp_path(); if !config_content.is_empty() { cmd = cmd.arg("--config-location").arg(&config_path); } cmd = cmd.arg("--").arg(&url); let output = shell_command_output_with_timeout( cmd, MEDIA_METADATA_TIMEOUT, "yt-dlp playlist metadata fetch", ) .await?; if output.status_code != Some(0) { let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); return Err(if err.is_empty() { format!( "yt-dlp failed while fetching playlist metadata (exit status: {:?})", output.status_code ) } else { format!("yt-dlp failed while fetching playlist metadata: {}", err) }); } let value: serde_json::Value = serde_json::from_slice(&output.stdout) .map_err(|e| format!("Failed to parse playlist JSON: {}", e))?; parse_media_playlist_metadata(&value, MEDIA_PLAYLIST_MAX_ENTRIES) } #[allow(clippy::too_many_arguments)] // Mirrors the stable command contract for the fallback call. async fn fetch_media_metadata_uncached( app_handle: tauri::AppHandle, url: String, cookie_browser: Option, user_agent: Option, username: Option, password: Option, headers: Option, cookies: Option, proxy: Option, ) -> Result { // Pass bundled tools by absolute path so extraction never depends on // system Python, a user-managed PATH, or auto-detection heuristics. let deno_path = resolve_bundled_binary_path(&app_handle, "deno") .map_err(|e| format!("failed to find bundled deno: {e}"))?; let ffmpeg_path = resolve_bundled_binary_path(&app_handle, "ffmpeg") .map_err(|e| format!("failed to find bundled ffmpeg: {e}"))?; let deno_runtime = format!("deno:{}", deno_path.to_string_lossy()); let trusted_path = crate::platform::trusted_system_path()?; use tauri_plugin_shell::ShellExt; let (ytdlp_path, _) = resolve_metadata_ytdlp_path(&app_handle)?; let mut cmd = app_handle .shell() .command(ytdlp_path.to_string_lossy().to_string()); cmd = cmd .env("PATH", trusted_path) .arg("--ffmpeg-location") .arg(&ffmpeg_path) .arg("--js-runtimes") .arg(&deno_runtime) .arg("--no-warnings") .arg("--no-playlist") .arg("--skip-download") .arg("--socket-timeout") .arg("20") .arg("--retries") .arg("3") .arg("--extractor-retries") .arg("3") .arg("--compat-options") .arg("no-youtube-unavailable-videos") .arg("--print") .arg("%(.{title,duration,thumbnail,formats})j"); if let Some(browser) = cookie_browser.as_deref() { if !browser.is_empty() { cmd = cmd.arg("--cookies-from-browser").arg(browser); } } if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { if proxy.eq_ignore_ascii_case("none") { cmd = cmd.arg("--proxy").arg(""); } else { cmd = cmd.arg("--proxy").arg(proxy); } } if let Some(ua) = user_agent .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) { cmd = cmd.arg("--user-agent").arg(ua); } let mut config_file = tempfile::Builder::new() .prefix("ytdlp-") .suffix(".conf") .tempfile() .map_err(|e| e.to_string())?; let config_content = build_ytdlp_config_content( username.as_deref(), password.as_deref(), headers.as_deref(), cookies.as_deref(), )?; use std::io::Write; config_file .write_all(config_content.as_bytes()) .map_err(|e| e.to_string())?; let config_path = config_file.into_temp_path(); if !config_content.is_empty() { cmd = cmd.arg("--config-location").arg(&config_path); } cmd = cmd.arg("--").arg(&url); let output = shell_command_output_with_timeout( cmd, MEDIA_METADATA_TIMEOUT, "yt-dlp media metadata fetch", ) .await?; if output.status_code == Some(0) { let value: serde_json::Value = serde_json::from_slice(&output.stdout) .map_err(|e| format!("Failed to parse JSON: {}", e))?; let title = value .get("title") .and_then(|v| v.as_str()) .unwrap_or("Unknown Title") .to_string(); let duration_seconds = value.get("duration").and_then(|v| v.as_f64()); let duration = duration_seconds.map(|v| v as u64); let thumbnail = value .get("thumbnail") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let formats = value .get("formats") .and_then(|v| v.as_array()) .map(|formats_arr| build_media_format_options(formats_arr, duration_seconds)) .unwrap_or_default(); Ok(MediaMetadata { title, duration, thumbnail, formats, }) } else { let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); if err.is_empty() { Err(format!( "yt-dlp failed while fetching media metadata (exit status: {:?})", output.status_code )) } else { Err(format!( "yt-dlp failed while fetching media metadata: {}", err )) } } } #[tauri::command] async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result { let (version, error, _) = run_sidecar_version(&app_handle, "yt-dlp", &["--version"]).await; match (version, error) { (Some(version), None) => Ok(version), (_, Some(error)) => Err(error), _ => Err("yt-dlp returned no version output".to_string()), } } #[tauri::command] async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result { let (version, error, _) = run_sidecar_version(&app_handle, "ffmpeg", &["-version"]).await; version .as_deref() .and_then(parse_ffmpeg_version) .ok_or_else(|| error.unwrap_or_else(|| "ffmpeg returned no version output".to_string())) } #[tauri::command] async fn test_deno(app_handle: tauri::AppHandle) -> Result { let (version, error, _) = run_sidecar_version(&app_handle, "deno", &["--version"]).await; if let Some(text) = version { let re = regex::Regex::new(r"deno\s+(\d+\.\d+\.\d+)").unwrap(); let clean = re .captures(&text) .and_then(|c| c.get(1)) .map(|m| m.as_str()) .unwrap_or(&text) .to_string(); Ok(clean) } else { Err(error.unwrap_or_else(|| "deno returned no version output".to_string())) } } pub(crate) fn is_safe_path(path: &std::path::Path, app_handle: &tauri::AppHandle) -> bool { if !path.is_absolute() || path.components().any(|component| { matches!( component, std::path::Component::ParentDir | std::path::Component::CurDir ) }) { return false; } let Some(canonical_path) = canonicalize_with_missing_components(path) else { return false; }; approved_download_roots(app_handle) .into_iter() .filter_map(|root| canonicalize_with_missing_components(&root)) .any(|root| crate::platform::path_is_within(&canonical_path, &root)) } pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool { use std::path::Component; let mut current = std::path::PathBuf::new(); for component in path.components() { match component { Component::Prefix(_) | Component::RootDir => { current.push(component.as_os_str()); } Component::Normal(name) => { current.push(name); if std::fs::symlink_metadata(¤t) .is_ok_and(|metadata| metadata.file_type().is_symlink()) { return true; } } Component::CurDir | Component::ParentDir => return true, } } false } pub(crate) fn canonicalize_with_missing_components( path: &std::path::Path, ) -> Option { if path_has_symlink_component(path) { return None; } let mut existing = path; let mut missing = Vec::new(); loop { match std::fs::symlink_metadata(existing) { Ok(metadata) => { if metadata.file_type().is_symlink() { return None; } break; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { missing.push(existing.file_name()?.to_owned()); existing = existing.parent()?; } Err(_) => return None, } } let mut canonical = std::fs::canonicalize(existing).ok()?; for component in missing.iter().rev() { canonical.push(component); } Some(canonical) } fn approved_download_roots(app_handle: &tauri::AppHandle) -> Vec { use tauri::Manager; let mut roots = Vec::new(); for root in [ app_handle.path().download_dir().ok(), app_handle.path().audio_dir().ok(), app_handle.path().video_dir().ok(), app_handle.path().picture_dir().ok(), app_handle.path().document_dir().ok(), app_handle.path().desktop_dir().ok(), ] .into_iter() .flatten() { push_unique_path(&mut roots, root); } if let Ok(settings) = crate::settings::load_settings(app_handle) { push_unique_path( &mut roots, resolve_path(&settings.base_download_folder, app_handle), ); for root in settings.category_directory_overrides.values() { push_unique_path(&mut roots, resolve_path(root, app_handle)); } for root in &settings.approved_download_roots { push_unique_path(&mut roots, resolve_path(root, app_handle)); } } roots } #[tauri::command] fn approve_download_root(app_handle: tauri::AppHandle, path: String) -> Result { let resolved = resolve_path(path.trim(), &app_handle); if !resolved.is_absolute() { return Err("Download root must be an absolute path".to_string()); } let canonical = std::fs::canonicalize(&resolved) .map_err(|error| format!("Failed to resolve download root: {error}"))?; if !canonical.is_dir() { return Err("Download root must be an existing directory".to_string()); } let canonical_text = crate::platform::display_path(&canonical); crate::settings::update_settings_state(&app_handle, |state| { let roots = state .entry("approvedDownloadRoots".to_string()) .or_insert_with(|| serde_json::Value::Array(Vec::new())); if !roots.is_array() { *roots = serde_json::Value::Array(Vec::new()); } let values = roots .as_array_mut() .expect("approved roots must be an array"); if !values .iter() .filter_map(serde_json::Value::as_str) .any(|root| { crate::platform::paths_equal( std::path::Path::new(root), std::path::Path::new(&canonical_text), ) }) { values.push(serde_json::Value::String(canonical_text.clone())); } })?; Ok(canonical_text) } fn push_unique_path(paths: &mut Vec, path: std::path::PathBuf) { if path.is_absolute() && !paths .iter() .any(|existing| crate::platform::paths_equal(existing, &path)) { paths.push(path); } } use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; struct Aria2DaemonGuard { child: Mutex>, startup_error: Mutex>, last_stderr: Mutex, config_path: Mutex>, } impl Aria2DaemonGuard { fn new() -> Self { Self { child: Mutex::new(None), startup_error: Mutex::new(None), last_stderr: Mutex::new(String::new()), config_path: Mutex::new(None), } } } impl Drop for Aria2DaemonGuard { fn drop(&mut self) { if let Ok(mut lock) = self.child.lock() { if let Some(mut child) = lock.take() { let _ = child.kill(); let _ = child.wait(); } } } } pub mod commands; pub mod download; pub mod download_ownership; mod engines; pub mod error; #[allow(dead_code)] pub mod ipc; mod parity; mod platform; pub mod queue; pub mod process; pub mod retry; mod settings; mod storage; pub use error::AppError; // Retained only for compatibility with the optional aria2 diagnostic monitor. // Active downloads are owned by DownloadCoordinator. #[non_exhaustive] pub enum TaskHandle { Aria2(String), } pub struct AppState { pub download_coordinator: download::DownloadCoordinator, pub storage_layout: crate::storage::StorageLayout, /// Credential-store access is denied until the frontend has completed the /// startup consent decision for this process. This is intentionally /// process-local: a new binary must pass through the consent barrier /// again before any stale or early IPC caller can reach the OS store. pub keychain_access_authorized: Arc, pub keychain_grant_in_progress: Arc, pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_frontend_ready: extension_server::SharedFrontendReady, pub extension_acks: extension_server::SharedExtensionAcks, pub extension_server_port: extension_server::SharedServerPort, pub extension_server_shutdown: tokio::sync::watch::Sender, pub aria2_port: std::sync::Arc, pub aria2_secret: String, pub media_semaphore: Arc, pub sleep_preventer: Arc>>, pub scheduler_settings: Arc>>, pub queue_manager: Arc, } const KEYCHAIN_CONSENT_REQUIRED_ERROR: &str = "Credential-store access requires explicit user consent"; const KEYCHAIN_GRANT_IN_PROGRESS_ERROR: &str = "Credential-store access is already being requested"; fn require_keychain_access(state: &AppState) -> Result<(), String> { if state .keychain_access_authorized .load(Ordering::Acquire) { Ok(()) } else { Err(KEYCHAIN_CONSENT_REQUIRED_ERROR.to_string()) } } struct KeychainGrantGuard(Arc); impl Drop for KeychainGrantGuard { fn drop(&mut self) { self.0.store(false, Ordering::Release); } } fn begin_keychain_grant(state: &AppState) -> Result { state .keychain_grant_in_progress .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .map(|_| KeychainGrantGuard(Arc::clone(&state.keychain_grant_in_progress))) .map_err(|_| KEYCHAIN_GRANT_IN_PROGRESS_ERROR.to_string()) } #[derive(Clone, Serialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct DownloadProgressEvent { id: String, fraction: f64, speed: String, eta: String, size: Option, size_is_final: bool, #[ts(optional)] downloaded_bytes: Option, #[ts(optional)] total_bytes: Option, #[ts(optional)] total_is_estimate: Option, } #[derive(Debug, Clone, Serialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct EngineStatusItem { name: String, kind: String, expected_sidecar: String, resolved_path: Option, version: Option, ready: bool, error: Option, stderr_tail: Option, remediation_hint: Option, rpc_port: Option, daemon_alive: Option, rpc_ready: Option, last_stderr_tail: Option, expects_internal_dir: Option, has_internal_dir: Option, has_python_framework: Option, } #[derive(Debug, Clone, Serialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct EngineStatusResult { pub engines: Vec, } pub(crate) fn resolve_path(path: &str, app_handle: &tauri::AppHandle) -> std::path::PathBuf { use tauri::Manager; let mut resolved = std::path::PathBuf::from(path); if let Some(stripped) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) { if let Some(home) = app_handle.path().home_dir().ok().or_else(|| { std::env::var("USERPROFILE") .ok() .map(std::path::PathBuf::from) }) { resolved = home.join(stripped); } else { log::warn!("Failed to resolve home directory for ~ expansion"); } } else if path == "~" { if let Some(home) = app_handle.path().home_dir().ok().or_else(|| { std::env::var("USERPROFILE") .ok() .map(std::path::PathBuf::from) }) { resolved = home; } else { log::warn!("Failed to resolve home directory for ~ expansion"); } } resolved } pub(crate) fn collect_download_uris(url: &str, mirrors: Option<&str>) -> Vec { let mut uris = Vec::new(); for uri in std::iter::once(url).chain(mirrors.into_iter().flat_map(str::lines)) { let uri = uri.trim(); if !uri.is_empty() && !uris.iter().any(|existing| existing == uri) { uris.push(uri.to_string()); } } uris } const MAX_DEEP_LINK_PAYLOAD_LEN: usize = 65_536; const MAX_DEEP_LINK_URLS: usize = 200; #[derive(Debug, Clone, PartialEq, Eq)] enum FirelinkDeepLink { Launch, Add(Vec), Invalid, } fn parse_firelink_deep_link(deep_link: &url::Url) -> FirelinkDeepLink { if deep_link.scheme() != "firelink" || !deep_link.username().is_empty() || deep_link.password().is_some() || deep_link.port().is_some() { return FirelinkDeepLink::Invalid; } if deep_link.host_str() == Some("launch") { return if matches!(deep_link.path(), "" | "/") && deep_link.query().is_none() && deep_link.fragment().is_none() { FirelinkDeepLink::Launch } else { FirelinkDeepLink::Invalid }; } if deep_link.host_str() != Some("add") || !matches!(deep_link.path(), "" | "/") || deep_link.fragment().is_some() { return FirelinkDeepLink::Invalid; } let Some(raw_urls) = deep_link .query_pairs() .find_map(|(key, value)| (key == "url").then(|| value.into_owned())) else { return FirelinkDeepLink::Invalid; }; if raw_urls.is_empty() || raw_urls.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN { return FirelinkDeepLink::Invalid; } let mut captured = Vec::new(); for raw_url in raw_urls.lines() { let raw_url = raw_url.trim(); let Ok(url) = url::Url::parse(raw_url) else { continue; }; if !matches!(url.scheme(), "http" | "https" | "ftp" | "sftp") { continue; } let url = url.to_string(); if !captured.iter().any(|existing| existing == &url) { captured.push(url); if captured.len() == MAX_DEEP_LINK_URLS { break; } } } if captured.is_empty() { FirelinkDeepLink::Invalid } else { FirelinkDeepLink::Add(captured) } } fn restore_main_window(app_handle: &tauri::AppHandle) { if let Some(window) = app_handle.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } } fn dispatch_deep_links(app_handle: tauri::AppHandle, deep_links: Vec) { let mut should_restore = false; let mut urls = Vec::new(); for deep_link in deep_links { match parse_firelink_deep_link(&deep_link) { FirelinkDeepLink::Launch => should_restore = true, FirelinkDeepLink::Add(parsed) => { should_restore = true; for url in parsed { if !urls.contains(&url) && urls.len() < MAX_DEEP_LINK_URLS { urls.push(url); } } } FirelinkDeepLink::Invalid => {} } } if !should_restore { return; } restore_main_window(&app_handle); if urls.is_empty() { return; } let coordinator = app_handle.state::().download_coordinator.clone(); tauri::async_runtime::spawn(async move { if let Err(error) = coordinator .send(download::DownloadCmd::CaptureUrls(urls)) .await { eprintln!("Failed to dispatch deep link to download coordinator: {error}"); } }); } pub(crate) async fn rpc_call( port: u16, secret: &str, method: &str, params: serde_json::Value, ) -> Result { ensure_reqwest_crypto_provider(); let url = format!("http://127.0.0.1:{}/jsonrpc", port); let mut payload = serde_json::Map::new(); payload.insert("jsonrpc".to_string(), serde_json::json!("2.0")); payload.insert("id".to_string(), serde_json::json!("1")); payload.insert("method".to_string(), serde_json::json!(method)); let mut p = vec![serde_json::json!(format!("token:{}", secret))]; if let serde_json::Value::Array(arr) = params { p.extend(arr); } payload.insert("params".to_string(), serde_json::json!(p)); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(3)) .build() .map_err(|e| e.to_string())?; let res = client .post(&url) .json(&payload) .send() .await .map_err(|e| e.to_string())?; let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; if let Some(error) = json.get("error") { return Err(error.to_string()); } json.get("result") .cloned() .ok_or_else(|| "aria2 returned no result".to_string()) } #[tauri::command] async fn test_aria2c( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { let guard = app_handle.state::(); let startup_err = guard .startup_error .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); if let Some(err) = startup_err { return Err(format!("aria2 daemon unavailable: {err}")); } let result = rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, "aria2.getVersion", serde_json::json!([]), ) .await .map_err(|error| format!("aria2 daemon unavailable: {error}"))?; result .get("version") .and_then(serde_json::Value::as_str) .map(str::to_string) .ok_or_else(|| "aria2 returned an invalid version response".to_string()) } // ── get_engine_status: Structured engine status ────────────── async fn run_sidecar_version( app_handle: &tauri::AppHandle, sidecar_name: &str, args: &[&str], ) -> (Option, Option, Option) { let binary_path = match resolve_bundled_binary_path(app_handle, sidecar_name) { Ok(p) => p, Err(e) => { return ( None, Some(format!("Missing bundled binary '{}': {}", sidecar_name, e)), None, ) } }; if let Err(error) = validate_bundled_binary(&binary_path) { return (None, Some(error), None); } let cache_key = version_cache_key(&binary_path, args); if let Ok(cache) = version_check_cache().lock() { if let Some(cached) = cache.get(&cache_key) { return cached.clone(); } } let mut command = tokio::process::Command::new(&binary_path); crate::platform::hide_tokio_child_console(&mut command); if sidecar_name == "aria2c" { crate::engines::apply_aria2_tokio_environment(&mut command, &binary_path); } command.args(args).kill_on_drop(true); let output_future = command.output(); tokio::pin!(output_future); const VERSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(12); let result = tokio::time::timeout(VERSION_TIMEOUT, &mut output_future) .await .map_err(|_| VERSION_TIMEOUT); let output = match result { Ok(Ok(output)) => output, Ok(Err(e)) => { return ( None, Some(format!("Failed to execute '{}': {}", sidecar_name, e)), None, ) } Err(timeout) => { return ( None, Some(format!( "'{}' version check timed out after {} seconds at '{}'", sidecar_name, timeout.as_secs(), binary_path.display() )), None, ) } }; let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stderr_tail = if stderr.is_empty() { None } else { Some(stderr.clone()) }; let result = if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); (Some(stdout), None, stderr_tail) } else { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let err = if !stderr.is_empty() { stderr.lines().rev().take(10).collect::>().join("\n") } else { format!("Exited with code {:?}", output.status.code()) }; ( if stdout.is_empty() { None } else { Some(stdout) }, Some(err), stderr_tail, ) }; if result.1.is_none() { if let Ok(mut cache) = version_check_cache().lock() { cache.insert(cache_key, result.clone()); } } result } type VersionCheckResult = (Option, Option, Option); fn version_check_cache( ) -> &'static std::sync::Mutex> { static CACHE: std::sync::OnceLock< std::sync::Mutex>, > = std::sync::OnceLock::new(); CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) } fn version_cache_key(binary_path: &std::path::Path, args: &[&str]) -> String { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); let modified = std::fs::metadata(binary_path) .and_then(|metadata| metadata.modified()) .ok() .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_nanos()) .unwrap_or_default(); binary_path.hash(&mut hasher); modified.hash(&mut hasher); args.hash(&mut hasher); hasher.finish().to_string() } fn validate_bundled_binary(binary_path: &std::path::Path) -> Result<(), String> { let metadata = std::fs::metadata(binary_path).map_err(|error| { format!( "Missing bundled binary at '{}': {error}", binary_path.display() ) })?; if !metadata.is_file() { return Err(format!( "Bundled binary path is not a file: '{}'", binary_path.display() )); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if metadata.permissions().mode() & 0o111 == 0 { return Err(format!( "Bundled binary is not executable: '{}'", binary_path.display() )); } } #[cfg(target_os = "macos")] { let expected_arch = if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }; if let Ok(output) = std::process::Command::new("/usr/bin/lipo") .arg("-archs") .arg(binary_path) .output() { if output.status.success() { let archs = String::from_utf8_lossy(&output.stdout); if !archs.split_whitespace().any(|arch| arch == expected_arch) { return Err(format!( "Wrong architecture for '{}': expected {}, found {}", binary_path.display(), expected_arch, archs.trim() )); } } } } Ok(()) } fn generate_remediation_hint(error: &str, _kind: &str) -> Option { let lower = error.to_lowercase(); if lower.contains("library not loaded") || lower.contains("dylib") { Some("A required system library is missing. Try reinstalling Firelink or run 'brew install openssl'.".to_string()) } else if lower.contains("not found") || lower.contains("could not find") { Some("The bundled binary file is missing. Reinstall Firelink to restore it.".to_string()) } else if lower.contains("timed out") { Some("The binary did not respond within the timeout. It may be damaged or incompatible with this system.".to_string()) } else if lower.contains("permission denied") { Some("The binary does not have execute permission. Try reinstalling Firelink.".to_string()) } else { None } } async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) -> EngineStatusItem { let sidecar_name = "aria2c"; let expected_sidecar = crate::platform::engine_binary_name(sidecar_name); let resolved = resolve_bundled_binary_path(app_handle, sidecar_name); let resolved_path = resolved .as_ref() .ok() .map(|p| crate::platform::display_path(p)); let (startup_err, daemon_stderr) = { let guard = app_handle.state::(); let se = guard .startup_error .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); let stderr = guard .last_stderr .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); (se, stderr) }; let daemon_alive = startup_err.is_none(); let last_stderr_tail = if daemon_stderr.is_empty() { None } else { Some(daemon_stderr) }; let (version_raw, run_error, stderr_tail) = run_sidecar_version(app_handle, sidecar_name, &["--version"]).await; let version = version_raw.and_then(|v| v.lines().next().map(|l| l.trim().to_string())); let rpc_ready = if daemon_alive { rpc_call(port, secret, "aria2.getVersion", serde_json::json!([])) .await .is_ok() } else { false }; let error = startup_err.or(run_error); let ready = daemon_alive && rpc_ready && version.is_some(); let remediation_hint = error .as_ref() .and_then(|e| generate_remediation_hint(e, sidecar_name)); EngineStatusItem { name: "Aria2".to_string(), kind: "aria2".to_string(), expected_sidecar, resolved_path, version, ready, error, stderr_tail, remediation_hint, rpc_port: Some(port), daemon_alive: Some(daemon_alive), rpc_ready: Some(rpc_ready), last_stderr_tail, expects_internal_dir: None, has_internal_dir: None, has_python_framework: None, } } async fn check_ytdlp(app_handle: &tauri::AppHandle) -> EngineStatusItem { let sidecar_name = "yt-dlp"; let expected_sidecar = crate::platform::engine_binary_name(sidecar_name); let resolved = resolve_bundled_binary_path(app_handle, sidecar_name); let resolved_path = resolved .as_ref() .ok() .map(|p| crate::platform::display_path(p)); let (has_internal_dir, has_python_runtime) = if let Ok(ref path) = resolved { let parent = path.parent().map(|p| p.to_path_buf()); if let Some(parent) = parent { let internal = crate::engines::ytdlp_internal_dir(path).unwrap_or_else(|| parent.join("_internal")); let hi = internal.is_dir(); let hp = hi && ytdlp_embedded_runtime_exists(&internal); (hi, hp) } else { (false, false) } } else { (false, false) }; let (version_raw, run_error, stderr_tail) = run_sidecar_version(app_handle, sidecar_name, &["--version"]).await; let version = version_raw.and_then(|v| v.lines().next().map(|l| l.trim().to_string())); let mut error = run_error; let mut remediation_hint = None; if error.is_none() && has_internal_dir && !has_python_runtime { error = Some(yt_dlp_missing_runtime_message().to_string()); remediation_hint = Some( "The yt-dlp distribution is missing its embedded Python runtime. Reinstall Firelink." .to_string(), ); } if remediation_hint.is_none() { remediation_hint = error .as_ref() .and_then(|e| generate_remediation_hint(e, sidecar_name)); } EngineStatusItem { name: "yt-dlp".to_string(), kind: "ytdlp".to_string(), expected_sidecar, resolved_path, version, ready: error.is_none(), error, stderr_tail, remediation_hint, rpc_port: None, daemon_alive: None, rpc_ready: None, last_stderr_tail: None, expects_internal_dir: has_internal_dir.then_some(true), has_internal_dir: has_internal_dir.then_some(true), has_python_framework: has_internal_dir.then_some(has_python_runtime), } } fn ytdlp_embedded_runtime_exists(internal: &std::path::Path) -> bool { #[cfg(target_os = "windows")] { return std::fs::read_dir(internal) .ok() .into_iter() .flat_map(|entries| entries.flatten()) .any(|entry| { let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); name.starts_with("python") && entry .path() .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("dll")) }); } #[cfg(target_os = "linux")] { return std::fs::read_dir(internal) .ok() .into_iter() .flat_map(|entries| entries.flatten()) .any(|entry| { let name = entry.file_name().to_string_lossy().to_string(); name == "Python" || name.starts_with("libpython") && name.contains(".so") }); } #[cfg(target_os = "macos")] { return internal.join("Python.framework").is_dir() || internal.join("Python").exists(); } #[allow(unreachable_code)] false } fn yt_dlp_missing_runtime_message() -> &'static str { #[cfg(target_os = "windows")] { return "_internal/python*.dll was not found beside yt-dlp sidecar"; } #[cfg(target_os = "linux")] { return "_internal/libpython*.so was not found beside yt-dlp sidecar"; } #[cfg(target_os = "macos")] { return "_internal/Python.framework was not found beside yt-dlp sidecar"; } #[allow(unreachable_code)] "_internal Python runtime was not found beside yt-dlp sidecar" } async fn check_ffmpeg(app_handle: &tauri::AppHandle) -> EngineStatusItem { let sidecar_name = "ffmpeg"; let expected_sidecar = crate::platform::engine_binary_name(sidecar_name); let resolved = resolve_bundled_binary_path(app_handle, sidecar_name); let resolved_path = resolved .as_ref() .ok() .map(|p| crate::platform::display_path(p)); let (version_raw, run_error, stderr_tail) = run_sidecar_version(app_handle, sidecar_name, &["-version"]).await; let version = version_raw .as_ref() .and_then(|text| parse_ffmpeg_version(text)); let error = run_error; let remediation_hint = error .as_ref() .and_then(|e| generate_remediation_hint(e, sidecar_name)); EngineStatusItem { name: "FFmpeg".to_string(), kind: "ffmpeg".to_string(), expected_sidecar, resolved_path, version, ready: error.is_none(), error, stderr_tail, remediation_hint, rpc_port: None, daemon_alive: None, rpc_ready: None, last_stderr_tail: None, expects_internal_dir: None, has_internal_dir: None, has_python_framework: None, } } fn parse_ffmpeg_version(output: &str) -> Option { let first = output.lines().next()?.trim(); let token = first .split_whitespace() .collect::>() .windows(2) .find_map(|window| { window[0] .eq_ignore_ascii_case("version") .then_some(window[1]) })?; let without_url = token .split_once("-https://") .or_else(|| token.split_once("-http://")) .map(|(prefix, _)| prefix) .unwrap_or(token); if without_url.starts_with("N-") { return Some(without_url.to_string()); } without_url .split('-') .next() .filter(|version| !version.trim().is_empty()) .map(str::to_string) } async fn check_deno(app_handle: &tauri::AppHandle) -> EngineStatusItem { let sidecar_name = "deno"; let expected_sidecar = crate::platform::engine_binary_name(sidecar_name); let resolved = resolve_bundled_binary_path(app_handle, sidecar_name); let resolved_path = resolved .as_ref() .ok() .map(|p| crate::platform::display_path(p)); let (version_raw, run_error, stderr_tail) = run_sidecar_version(app_handle, sidecar_name, &["--version"]).await; let version = version_raw .as_ref() .and_then(|text| { let re = regex::Regex::new(r"deno\s+(\d+\.\d+\.\d+)").ok()?; re.captures(text) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()) }) .or(version_raw); let error = run_error; let remediation_hint = error .as_ref() .and_then(|e| generate_remediation_hint(e, sidecar_name)); EngineStatusItem { name: "Deno".to_string(), kind: "deno".to_string(), expected_sidecar, resolved_path, version, ready: error.is_none(), error, stderr_tail, remediation_hint, rpc_port: None, daemon_alive: None, rpc_ready: None, last_stderr_tail: None, expects_internal_dir: None, has_internal_dir: None, has_python_framework: None, } } #[tauri::command] async fn get_engine_status( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); let secret = state.aria2_secret.clone(); let (aria2, ytdlp, ffmpeg, deno) = tokio::join!( check_aria2(&app_handle, port, &secret), check_ytdlp(&app_handle), check_ffmpeg(&app_handle), check_deno(&app_handle), ); Ok(EngineStatusResult { engines: vec![aria2, ytdlp, ffmpeg, deno], }) } #[tauri::command] async fn get_aria2_engine_status( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, ) -> Result { Ok(check_aria2( &app_handle, state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, ) .await) } #[tauri::command] async fn get_ytdlp_engine_status(app_handle: tauri::AppHandle) -> Result { Ok(check_ytdlp(&app_handle).await) } #[tauri::command] async fn get_ffmpeg_engine_status( app_handle: tauri::AppHandle, ) -> Result { Ok(check_ffmpeg(&app_handle).await) } #[tauri::command] async fn get_deno_engine_status(app_handle: tauri::AppHandle) -> Result { Ok(check_deno(&app_handle).await) } fn resolve_bundled_binary_path( app_handle: &tauri::AppHandle, binary_name: &str, ) -> Result { crate::engines::resolve_bundled_binary_path(app_handle, binary_name) } #[allow(clippy::too_many_arguments)] pub(crate) async fn start_media_download_internal( app_handle: tauri::AppHandle, id: &str, url: String, destination: String, filename: String, format_selector: Option, cookie_source: Option, speed_limit: Option, username: Option, password: Option, headers: Option, cookies: Option, proxy: Option, user_agent: Option, max_tries: Option, cancel_rx: &mut tokio::sync::watch::Receiver, ) -> Result { let safe_filename = crate::download_ownership::canonical_download_filename(&filename); let resolved_dest = resolve_path(&destination, &app_handle); if !is_safe_path(&resolved_dest, &app_handle) { return Err("Path traversal blocked".to_string()); } if !resolved_dest.exists() { let _ = tokio::fs::create_dir_all(&resolved_dest).await; } let out_path = resolved_dest.join(&safe_filename); let output_template = media_output_template(&resolved_dest, &safe_filename, format_selector.as_deref()); let total_tracks: f64 = if let Some(ref format) = format_selector { if format.contains('+') { 2.0 } else { 1.0 } } else { 1.0 }; use tauri_plugin_shell::ShellExt; let mut config_file = tempfile::Builder::new() .prefix("ytdlp-") .suffix(".conf") .tempfile() .map_err(|e| e.to_string())?; let config_content = build_ytdlp_config_content( username.as_deref(), password.as_deref(), headers.as_deref(), cookies.as_deref(), )?; use std::io::Write; config_file .write_all(config_content.as_bytes()) .map_err(|e| e.to_string())?; let config_path = config_file.into_temp_path(); use crate::ipc::DownloadStateEvent; use crate::retry::{backoff_and_emit_cancel, is_transient_network_error, BackoffOutcome}; const STDERR_TAIL: usize = 2048; let config_location = if !config_content.is_empty() { Some(config_path.to_string_lossy().to_string()) } else { None }; let _keep_alive = config_path; let mut progress_state = MediaProgressEmitterState::new(); // Resolve absolute paths to bundled binaries let aria2c_path = resolve_bundled_binary_path(&app_handle, "aria2c")?; let ffmpeg_path = resolve_bundled_binary_path(&app_handle, "ffmpeg")?; let deno_path = resolve_bundled_binary_path(&app_handle, "deno")?; log::info!("Using bundled aria2c: {:?}", aria2c_path); log::info!("Using bundled ffmpeg: {:?}", ffmpeg_path); log::info!("Using bundled deno: {:?}", deno_path); // yt-dlp accepts an absolute path for its external downloader. Keep every // engine explicit so behavior never depends on PATH, symlink privileges, // user-installed tools, or platform-specific executable aliases. let trusted_path = crate::platform::trusted_system_path()?; let max_retries = max_tries.unwrap_or(0).max(0) as usize; let mut strike = 0_usize; let mut effective_cookie_source = cookie_source.clone(); let mut browser_cookie_fallback_used = false; while strike <= max_retries { if *cancel_rx.borrow() { return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string()); } let mut processing_started = false; let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?; let mut cmd = app_handle.shell().command(&ytdlp_path); for arg in media_progress_args() { cmd = cmd.arg(arg); } cmd = cmd .arg("--socket-timeout") .arg("20") .arg("--retries") // Firelink owns the retry budget so `maxAutomaticRetries` means // the same thing for aria2 and media downloads. Letting yt-dlp // also consume that value would multiply the configured retry // count on every outer restart. .arg("0") .arg("--extractor-retries") .arg("0") .arg("--fragment-retries") .arg("0") .arg("--ffmpeg-location") .arg(&ffmpeg_path) .arg("--js-runtimes") .arg(format!("deno:{}", deno_path.to_string_lossy())) .arg("--concurrent-fragments") .arg("4") .arg("--no-warnings") // Playlist expansion is owned by Firelink. Every persisted media // row must remain a single-video lifecycle, even when yt-dlp's // webpage URL still carries a playlist query parameter. .arg("--no-playlist") .arg("--continue") .arg("--compat-options") .arg("no-youtube-unavailable-videos") .arg("--print") .arg("after_move:%(filepath)s") .arg("-o") .arg(output_template.to_string_lossy().to_string()) .env("PATH", &trusted_path); if let Some(limit) = speed_limit .as_deref() .and_then(normalize_speed_limit_for_aria2) { cmd = cmd.arg("--limit-rate").arg(limit); } if let Some(p) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { if p.eq_ignore_ascii_case("none") { cmd = cmd.arg("--proxy").arg(""); } else { cmd = cmd.arg("--proxy").arg(p); } } if let Some(cs) = effective_cookie_source.as_ref() { let mut cs = cs.clone(); if !cs.is_empty() && cs != "none" { if cs == "safari" { cs = "safari:".to_string() } cmd = cmd.arg("--cookies-from-browser").arg(cs); } } if let Some(ua) = user_agent.as_ref() { if !ua.is_empty() { cmd = cmd.arg("--user-agent").arg(ua); } } if let Some(loc) = config_location.as_ref() { cmd = cmd.arg("--config-location").arg(loc); } if let Some(format) = format_selector.as_ref() { cmd = cmd.arg("-f").arg(format); if safe_filename.ends_with(".mp3") { cmd = cmd.arg("-x").arg("--audio-format").arg("mp3"); } else if safe_filename.ends_with(".m4a") { cmd = cmd.arg("-x").arg("--audio-format").arg("m4a"); } else if safe_filename.ends_with(".opus") { cmd = cmd.arg("-x").arg("--audio-format").arg("opus"); } else if safe_filename.ends_with(".mp4") { cmd = cmd.arg("--merge-output-format").arg("mp4"); } else if safe_filename.ends_with(".webm") { cmd = cmd.arg("--merge-output-format").arg("webm"); } else { // `--merge-output-format` only affects split video/audio // selections. A progressive MP4/WebM stream already includes // audio, so also request remuxing to keep an MKV option from // producing a mismatched file extension and container. cmd = cmd .arg("--merge-output-format") .arg("mkv") .arg("--remux-video") .arg("mkv"); } } cmd = cmd.arg("--").arg(&url); if *cancel_rx.borrow() { return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string()); } let (mut rx, child) = cmd .spawn() .map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?; if strike > 0 { // The backoff path emits `Retrying`. Restore the live transfer // state when the replacement process actually starts so React // accepts progress from this attempt instead of staying stuck. progress_state.speed_sampler.reset(); let _ = app_handle.emit( "download-state", DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Downloading), ); } log::info!("yt-dlp spawned for id: {} (strike {})", id, strike); let mut stderr_tail = String::new(); let mut final_output_path: Option = None; let mut stdout_buffer = String::new(); let mut stderr_buffer = String::new(); let failure_reason = loop { tokio::select! { _ = cancel_rx.changed() => { terminate_shell_process_tree(child); if processing_started { cleanup_media_processing_artifacts(&out_path).await; } return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string()); } event = rx.recv() => { match event { Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => { let chunk = String::from_utf8_lossy(&line_bytes); for line in drain_media_output_lines(&mut stdout_buffer, &chunk) { if let Some(progress) = parse_media_progress_line(&line) { emit_media_progress( &app_handle, id, progress, total_tracks, &mut progress_state, ); } else { let candidate = line.trim(); if !candidate.is_empty() { let candidate_path = std::path::PathBuf::from(candidate); if candidate_path.is_absolute() { final_output_path = Some(candidate_path); } } } } } Some(tauri_plugin_shell::process::CommandEvent::Stderr(line_bytes)) => { let chunk = String::from_utf8_lossy(&line_bytes); stderr_tail.push_str(&chunk); if stderr_tail.len() > STDERR_TAIL { stderr_tail = stderr_tail.split_off(stderr_tail.len() - STDERR_TAIL); } for line in drain_media_output_lines(&mut stderr_buffer, &chunk) { if let Some(progress) = parse_media_progress_line(&line) { emit_media_progress( &app_handle, id, progress, total_tracks, &mut progress_state, ); } if !processing_started && is_media_processing_line(&line) { processing_started = true; let _ = app_handle.emit( "download-state", DownloadStateEvent::new( id, crate::ipc::DownloadStatus::Processing, ), ); let _ = app_handle.emit("download-progress", DownloadProgressEvent { id: id.to_string(), fraction: 1.0, speed: "Processing".to_string(), eta: "-".to_string(), size: None, size_is_final: false, downloaded_bytes: None, total_bytes: None, total_is_estimate: Some(false), }); } let lower = line.to_lowercase(); if lower.contains("error") || lower.contains("critical") { log::error!("yt-dlp stderr [{}]: {}", id, line.trim()); } } } Some(tauri_plugin_shell::process::CommandEvent::Error(err)) => { log::error!("yt-dlp shell error [{}]: {}", id, err); break err; } Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload)) => { if let Some(line) = flush_media_output_line(&mut stdout_buffer) { if let Some(progress) = parse_media_progress_line(&line) { emit_media_progress( &app_handle, id, progress, total_tracks, &mut progress_state, ); } else { let candidate = line.trim(); if !candidate.is_empty() { let candidate_path = std::path::PathBuf::from(candidate); if candidate_path.is_absolute() { final_output_path = Some(candidate_path); } } } } if let Some(line) = flush_media_output_line(&mut stderr_buffer) { if let Some(progress) = parse_media_progress_line(&line) { emit_media_progress( &app_handle, id, progress, total_tracks, &mut progress_state, ); } if !processing_started && is_media_processing_line(&line) { let _ = app_handle.emit( "download-state", DownloadStateEvent::new( id, crate::ipc::DownloadStatus::Processing, ), ); } } if payload.code == Some(0) { log::info!("yt-dlp completed successfully id: {}", id); let completed_path = final_output_path .as_ref() .filter(|path| path.is_file()) .cloned() .unwrap_or_else(|| out_path.clone()); if let Ok(metadata) = tokio::fs::metadata(&completed_path).await { if metadata.is_file() { let _ = app_handle.emit("download-progress", DownloadProgressEvent { id: id.to_string(), fraction: 1.0, speed: "-".to_string(), eta: "-".to_string(), size: Some(crate::download::format_size(metadata.len() as f64)), size_is_final: true, downloaded_bytes: Some(metadata.len() as f64), total_bytes: Some(metadata.len() as f64), total_is_estimate: Some(false), }); } } return Ok(completed_path); } log::error!("yt-dlp exited with non-zero code {:?} for id: {}", payload.code, id); break if stderr_tail.is_empty() { format!("yt-dlp exited with code {:?}", payload.code) } else { stderr_tail.clone() }; } Some(_) => {} None => { break if stderr_tail.is_empty() { "yt-dlp process ended unexpectedly".to_string() } else { stderr_tail.clone() }; } } } } }; let transient = is_transient_network_error(&failure_reason); let strikes_left = strike < max_retries; if should_cleanup_media_artifacts_after_failure(&failure_reason, strike, max_retries) { cleanup_media_artifacts(&out_path, false).await; } if should_retry_without_browser_cookies( effective_cookie_source.as_deref(), &failure_reason, browser_cookie_fallback_used, ) { let source = effective_cookie_source.clone().unwrap_or_default(); log::warn!( "yt-dlp could not read browser cookies from {}; retrying media download without browser cookies", source ); effective_cookie_source = None; browser_cookie_fallback_used = true; continue; } if !(transient && strikes_left) { return Err(failure_reason); } let reason = failure_reason.clone(); let outcome = backoff_and_emit_cancel(strike, reason, cancel_rx, |retry_reason| { let _ = app_handle.emit( "download-state", DownloadStateEvent::retrying(id, retry_reason), ); }) .await; if outcome == BackoffOutcome::Aborted { return Err(crate::queue::MEDIA_RUN_CANCELLED.to_string()); } strike += 1; } Err("yt-dlp retry loop exhausted".to_string()) } #[tauri::command] async fn pause_download( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { log::info!("pause_download called for id: {}", id); let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let active_kind = state.queue_manager.active_kind(&id).await; let registered_lifecycle_generation = state .queue_manager .registered_lifecycle_generation(&id) .await; let media_lifecycle_generation = registered_lifecycle_generation.unwrap_or_default(); let removed_pending = state.queue_manager.remove_from_pending(&id).await; let gid = state.queue_manager.aria2_gid_for_download(&id); if let Some(gid) = gid.as_deref() { let status = aria2_download_status( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, gid, ) .await?; match status.as_str() { "paused" => { state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; log::info!("aria2 pause [{}]: gid {} was already paused", id, gid); } "active" | "waiting" => { state.queue_manager.cancel_aria2_retries(&id).await; let pause_result = match rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, "aria2.forcePause", serde_json::json!([gid]), ) .await { Ok(result) => ensure_aria2_gid_result("forcePause", gid, &result), Err(error) => Err(format!("failed to pause aria2 gid {gid}: {error}")), }; if let Err(pause_error) = pause_result { match aria2_download_status( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, gid, ) .await { Ok(status) if status == "paused" => { // forcePause may have returned an RPC error after // the daemon actually paused the GID. Invalidate // terminal events already in flight before // preserving the resumable mapping. state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; log::info!( "aria2 pause [{}]: forcePause failed but gid {} is paused", id, gid ); } Ok(status) if status == "complete" => { state .queue_manager .apply_completion_locked(&id, crate::queue::PendingOutcome::Complete) .await; return Ok(()); } Ok(status) if matches!(status.as_str(), "error" | "removed") => { let terminal_error = format!( "cannot pause aria2 gid {gid}: {pause_error}; daemon reports terminal state {status}" ); state .queue_manager .apply_completion_locked( &id, crate::queue::PendingOutcome::Error(terminal_error.clone()), ) .await; return Err(terminal_error); } Ok(status) => { state.queue_manager.allow_aria2_retries(&id).await; return Err(format!( "{pause_error}; aria2 gid {gid} is still {status}" )); } Err(status_error) => { state.queue_manager.allow_aria2_retries(&id).await; return Err(format!( "{pause_error}; failed to verify aria2 gid {gid}: {status_error}" )); } } } state.queue_manager.next_aria2_control_epoch(&id).await; log::info!("aria2 pause [{}]: gid {} paused", id, gid); } "complete" => { // Aria2 can reach complete before its terminal event updates // Firelink's row. Treat a pause request in that narrow window // as an idempotent completion reconciliation, not as an // actionable pause failure. log::info!( "aria2 pause [{}]: gid {} was already complete; reconciling completion", id, gid ); state .queue_manager .apply_completion_locked(&id, crate::queue::PendingOutcome::Complete) .await; return Ok(()); } terminal => { let retrying = state.queue_manager.has_aria2_retry_state(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_permit(&id).await; state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; if retrying && matches!(terminal, "error" | "removed") { // The terminal GID was forgotten above, so this // lifecycle can no longer be resumed in place. Release // backend ownership as well; otherwise a paused row // keeps a dead registration until another command happens // to repair it. state.queue_manager.release_registered_id(&id).await; use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new( id, crate::ipc::DownloadStatus::Paused, ), ); return Ok(()); } state.queue_manager.release_registered_id(&id).await; return Err(format!( "cannot pause aria2 gid {gid} in terminal state {terminal}" )); } } state.queue_manager.release_permit(&id).await; use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused), ); return Ok(()); } // A terminal runner may have already released its lifecycle while this // command was waiting for the per-download control lock. Treat pause as // an idempotent no-op instead of emitting a stale Paused state. if active_kind.is_none() && !removed_pending && registered_lifecycle_generation.is_none() { return Ok(()); } if matches!(active_kind, Some(crate::queue::TaskKind::Aria2)) { state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; } let (tx, rx) = tokio::sync::oneshot::channel(); if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { state .download_coordinator .pause_media_with_ack(id.clone(), media_lifecycle_generation, tx) .await?; } else { let _ = tx.send(false); } let media_was_registered = rx .await .map_err(|_| "download worker stopped without acknowledging pause".to_string())?; if matches!(active_kind, Some(crate::queue::TaskKind::Media)) && !media_was_registered { // No media runner reached the coordinator. The queue control lock is // still held, and run_media now checks the same lifecycle generation // before registration, so this tombstone can be retired safely. state .download_coordinator .finish_media(id.clone(), media_lifecycle_generation) .await; } state.queue_manager.release_permit(&id).await; if registered_lifecycle_generation.is_some() || removed_pending || matches!(active_kind, Some(crate::queue::TaskKind::Aria2)) { if let Some(generation) = registered_lifecycle_generation { state .queue_manager .release_registered_id_for_generation(&id, generation) .await; } else { state.queue_manager.release_registered_id(&id).await; } } use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Paused), ); Ok(()) } #[tauri::command] async fn resume_download( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result { let control_guard = state.queue_manager.acquire_aria2_control(&id).await; let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else { log::info!( "aria2 resume [{}]: no mapped gid; re-enqueue is permitted", id ); state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.release_permit(&id).await; state.queue_manager.release_registered_id(&id).await; return Ok(false); }; let status = aria2_download_status( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, &gid, ) .await?; match status.as_str() { "paused" => { let control_epoch = state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.allow_aria2_retries(&id).await; state.queue_manager.reset_aria2_retry_strikes(&id).await; use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new(&id, crate::ipc::DownloadStatus::Queued), ); let queue_manager = state.queue_manager.clone(); let aria2_port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); let aria2_secret = state.aria2_secret.clone(); let id_clone = id.clone(); let gid_clone = gid.clone(); let app_handle_clone = app_handle.clone(); drop(control_guard); tauri::async_runtime::spawn(async move { let had_permit = queue_manager.has_active_permit(&id_clone).await; let permit_candidate = if had_permit { None } else { queue_manager.acquire_aria2_permit_candidate().await }; if permit_candidate.is_none() && !had_permit { return; } let _control_guard = queue_manager.acquire_aria2_control(&id_clone).await; let current_epoch = queue_manager .is_aria2_control_epoch_current(&id_clone, control_epoch) .await; if queue_manager.is_aria2_retry_cancelled(&id_clone).await || !current_epoch || queue_manager.aria2_gid_for_download(&id_clone).as_deref() != Some(gid_clone.as_str()) || !queue_manager.is_registered(&id_clone).await { return; } if !queue_manager .rebind_aria2_gid_epoch(&id_clone, &gid_clone, control_epoch) .await { log::warn!( "aria2 resume [{}]: gid {} disappeared before lifecycle rebind", id_clone, gid_clone ); return; } if let Some(permit) = permit_candidate { let _ = queue_manager .park_aria2_permit_if_missing(&id_clone, permit) .await; } let _ = app_handle_clone.emit( "download-state", crate::ipc::DownloadStateEvent::new( &id_clone, crate::ipc::DownloadStatus::Downloading, ), ); let unpause_error = match rpc_call( aria2_port, &aria2_secret, "aria2.unpause", serde_json::json!([gid_clone]), ) .await { Ok(result) => ensure_aria2_gid_result("unpause", &gid_clone, &result) .err() .map(|error| error.to_string()), Err(error) => Some(format!("failed to resume aria2 gid {gid_clone}: {error}")), }; if let Some(unpause_error) = unpause_error { match aria2_download_status(aria2_port, &aria2_secret, &gid_clone).await { Ok(status) if matches!(status.as_str(), "active" | "waiting") => { log::warn!( "aria2 resume [{}]: {} but daemon reports gid {} as {}; retaining permit", id_clone, unpause_error, gid_clone, status ); return; } Ok(status) if status == "complete" => { log::info!( "aria2 resume [{}]: {} but daemon reports gid {} complete; reconciling completion", id_clone, unpause_error, gid_clone ); queue_manager .apply_completion_locked( &id_clone, crate::queue::PendingOutcome::Complete, ) .await; return; } Ok(status) if status == "paused" => { queue_manager.next_aria2_control_epoch(&id_clone).await; queue_manager.cancel_aria2_retries(&id_clone).await; queue_manager.release_permit(&id_clone).await; log::error!( "aria2 resume [{}]: {}; daemon kept gid {} paused", id_clone, unpause_error, gid_clone ); let _ = app_handle_clone.emit( "download-state", crate::ipc::DownloadStateEvent::new( &id_clone, crate::ipc::DownloadStatus::Paused, ), ); return; } Ok(status) if matches!(status.as_str(), "error" | "removed") => { let terminal_error = format!( "{unpause_error}; daemon reports gid {gid_clone} as {status}" ); queue_manager .apply_completion_locked( &id_clone, crate::queue::PendingOutcome::Error(terminal_error), ) .await; return; } Ok(status) => { // An unrecognized daemon state is not proof that // the transfer stopped. Keep its permit and // mapping so a later reconciliation can observe // the real terminal state. log::error!( "aria2 resume [{}]: {}; daemon reports gid {} as {}; retaining permit", id_clone, unpause_error, gid_clone, status ); return; } Err(status_error) => { log::error!( "aria2 resume [{}]: {}; could not verify gid {} after the RPC failure: {}; retaining permit", id_clone, unpause_error, gid_clone, status_error ); return; } } } let current_epoch = queue_manager .is_aria2_control_epoch_current(&id_clone, control_epoch) .await; if queue_manager.is_aria2_retry_cancelled(&id_clone).await || !current_epoch || queue_manager.aria2_gid_for_download(&id_clone).as_deref() != Some(gid_clone.as_str()) { let _ = rpc_call( aria2_port, &aria2_secret, "aria2.forcePause", serde_json::json!([gid_clone]), ) .await; return; } log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone); }); Ok(true) } "active" | "waiting" => { let resume_epoch = state.queue_manager.current_aria2_control_epoch(&id).await; drop(control_guard); state.queue_manager.allow_aria2_retries(&id).await; let had_permit = state.queue_manager.has_active_permit(&id).await; let permit_candidate = if had_permit { None } else { state.queue_manager.acquire_aria2_permit_candidate().await }; if permit_candidate.is_none() && !had_permit { return Ok(true); } let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let still_current = state.queue_manager.is_registered(&id).await && !state.queue_manager.is_aria2_retry_cancelled(&id).await && state .queue_manager .is_aria2_control_epoch_current(&id, resume_epoch) .await && state.queue_manager.aria2_gid_for_download(&id).as_deref() == Some(gid.as_str()); if still_current { if let Some(permit) = permit_candidate { let _ = state .queue_manager .park_aria2_permit_if_missing(&id, permit) .await; } log::info!( "aria2 resume [{}]: gid {} already {}; no duplicate job created", id, gid, status ); use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new( id, crate::ipc::DownloadStatus::Downloading, ), ); } Ok(true) } "complete" | "error" | "removed" => { drop(control_guard); state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_permit(&id).await; state.queue_manager.release_registered_id(&id).await; log::info!( "aria2 resume [{}]: gid {} is {}; re-enqueue is permitted", id, gid, status ); Ok(false) } other => { drop(control_guard); Err(format!("aria2 gid {gid} returned unknown status {other}")) } } } #[tauri::command] async fn remove_download( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, delete_assets: bool, preserve_resumable: Option, ) -> Result<(), String> { log::info!("remove_download called for id: {}", id); let preserve_resumable = preserve_resumable.unwrap_or(false); let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?; let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let active_kind = state.queue_manager.active_kind(&id).await; let media_lifecycle_generation = state .queue_manager .registered_lifecycle_generation(&id) .await .unwrap_or_default(); state.queue_manager.remove_from_pending(&id).await; let gid = state.queue_manager.aria2_gid_for_download(&id); if let Some(gid) = gid.as_deref() { // Keep the current epoch and mapping alive until daemon removal is // confirmed. If removal fails, terminal events from this lifecycle // must still be accepted so the permit and mapping can be cleaned up. state.queue_manager.cancel_aria2_retries(&id).await; let removal_result = async { force_remove_aria2_gid( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, gid, ) .await?; wait_for_aria2_stopped( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, gid, ) .await } .await; if let Err(error) = removal_result { state.queue_manager.allow_aria2_retries(&id).await; return Err(error); } state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_permit(&id).await; log::info!("aria2 remove [{}]: gid {} stopped and forgotten", id, gid); } else { // There may be an addUri or retry handoff in flight with no mapped // GID yet. Invalidate that pending lifecycle before acknowledging the // removal so its late result cannot resurrect the download. state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; let (tx, rx) = tokio::sync::oneshot::channel(); if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { state .download_coordinator .pause_media_with_ack(id.clone(), media_lifecycle_generation, tx) .await?; } else { let _ = tx.send(false); } let media_was_registered = rx.await.unwrap_or(false); if matches!(active_kind, Some(crate::queue::TaskKind::Media)) && !media_was_registered { state .download_coordinator .finish_media(id.clone(), media_lifecycle_generation) .await; } state.queue_manager.release_permit(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; } use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), ); let preserve_assets = preserve_resumable && primary_path .as_deref() .is_some_and(has_resumable_download_assets); let cleanup_result = async { if delete_assets && !preserve_assets { if let Some(path) = primary_path.as_deref() { remove_download_assets(path, &app_handle).await?; } } crate::download_ownership::remove(&app_handle, &id)?; Ok::<(), String>(()) } .await; state.queue_manager.release_registered_id(&id).await; cleanup_result } fn has_resumable_download_assets(primary: &std::path::Path) -> bool { [".aria2", ".part", ".ytdl"].iter().any(|suffix| { let mut candidate = primary.as_os_str().to_os_string(); candidate.push(suffix); std::path::PathBuf::from(candidate).exists() }) } pub(crate) 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() { let mut retries = 5; loop { let res = if primary.is_dir() { tokio::fs::remove_dir_all(primary).await.map_err(|e| e.to_string()) } else { if let Err(e) = trash::delete(primary) { log::warn!("failed to move downloaded file to Trash, attempting hard delete: {}", e); std::fs::remove_file(primary).map_err(|e| e.to_string()) } else { Ok(()) } }; match res { Ok(_) => break, Err(e) => { if retries == 0 { return Err(e); } retries -= 1; tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } } } for suffix in [".aria2", ".part", ".ytdl"] { let mut candidate_os = primary.as_os_str().to_os_string(); candidate_os.push(suffix); let candidate = std::path::PathBuf::from(candidate_os); if candidate.exists() && is_safe_path(&candidate, app_handle) { let mut retries = 5; loop { match tokio::fs::remove_file(&candidate).await { Ok(_) => break, Err(_) if retries == 0 => { return Err(format!("failed to remove '{}' after retries", candidate.display())); } Err(_) => { retries -= 1; tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } } } } cleanup_media_processing_artifacts(primary).await; Ok(()) } #[tauri::command] async fn detach_download_for_reconfigure( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { log::info!("detach_download_for_reconfigure called for id: {}", id); let _control_guard = state.queue_manager.acquire_aria2_control(&id).await; let active_kind = state.queue_manager.active_kind(&id).await; let media_lifecycle_generation = state .queue_manager .registered_lifecycle_generation(&id) .await .unwrap_or_default(); state.queue_manager.remove_from_pending(&id).await; let gid = state.queue_manager.aria2_gid_for_download(&id); if let Some(gid) = gid.as_deref() { // Do not invalidate the mapped lifecycle until the daemon confirms // the detach. A failed pause must leave terminal-event reconciliation // and permit ownership intact. state.queue_manager.cancel_aria2_retries(&id).await; let removal_result = async { let pause_res = rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, "aria2.forcePause", serde_json::json!([gid]), ) .await; if let Err(e) = pause_res { if !e.contains("cannot be paused now") { return Err(e); } } wait_for_aria2_stopped( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, gid, ) .await } .await; if let Err(error) = removal_result { state.queue_manager.allow_aria2_retries(&id).await; return Err(error); } state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_permit(&id).await; state.queue_manager.release_registered_id(&id).await; log::info!("aria2 detach [{}]: gid {} stopped and forgotten", id, gid); } else { // Invalidate a queued/addUri lifecycle that has not published a GID. state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.cancel_aria2_retries(&id).await; let (tx, rx) = tokio::sync::oneshot::channel(); if matches!(active_kind, Some(crate::queue::TaskKind::Media)) { state .download_coordinator .pause_media_with_ack(id.clone(), media_lifecycle_generation, tx) .await?; } else { let _ = tx.send(false); // Fallback if no task exists } let media_was_registered = rx.await.unwrap_or(false); // Wait for the writer to stop if matches!(active_kind, Some(crate::queue::TaskKind::Media)) && !media_was_registered { state .download_coordinator .finish_media(id.clone(), media_lifecycle_generation) .await; } state.queue_manager.release_permit(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_registered_id(&id).await; } use tauri::Emitter; let _ = app_handle.emit( "download-state", crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused), ); Ok(()) } fn ensure_aria2_gid_result( method: &str, expected_gid: &str, result: &serde_json::Value, ) -> Result<(), String> { match result.as_str() { Some(returned_gid) if returned_gid == expected_gid => Ok(()), Some(returned_gid) => Err(format!( "aria2.{method} returned unexpected gid {returned_gid}, expected {expected_gid}" )), None => Err(format!("aria2.{method} returned a non-string result")), } } async fn aria2_download_status(port: u16, secret: &str, gid: &str) -> Result { let result = rpc_call( port, secret, "aria2.tellStatus", serde_json::json!([gid, ["status"]]), ) .await .map_err(|error| format!("failed to query aria2 gid {gid}: {error}"))?; result .get("status") .and_then(serde_json::Value::as_str) .map(str::to_string) .ok_or_else(|| format!("aria2.tellStatus returned no status for gid {gid}")) } async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) { let state = app_handle.state::(); let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); let secret = state.aria2_secret.clone(); let mappings = state.queue_manager.aria2_gid_mappings(); for (gid, id) in mappings { let status = match rpc_call( port, &secret, "aria2.tellStatus", serde_json::json!([gid, ["status", "errorCode", "errorMessage"]]), ) .await { Ok(status) => status, Err(error) => { log::debug!( "aria2 reconnect reconciliation [{}]: could not query gid {}: {}", id, gid, error ); continue; } }; let status_name = status.get("status").and_then(|value| value.as_str()); let outcome = match status_name { Some("complete") => Some(crate::queue::PendingOutcome::Complete), Some("error") | Some("removed") => { let error_code = status .get("errorCode") .and_then(|value| value.as_str()) .filter(|value| !value.is_empty()); let error_message = status .get("errorMessage") .and_then(|value| value.as_str()) .filter(|value| !value.is_empty()) .unwrap_or("aria2 download ended while the event channel was disconnected"); Some(crate::queue::PendingOutcome::Error(match error_code { Some(code) => format!("aria2 error code {code}: {error_message}"), None => error_message.to_string(), })) } _ => None, }; if let Some(outcome) = outcome { state.queue_manager.handle_aria2_event(&gid, outcome).await; } } } async fn aria2_daemon_is_reachable(port: u16, secret: &str) -> bool { for attempt in 0..2 { if rpc_call( port, secret, "aria2.getVersion", serde_json::json!([]), ) .await .is_ok() { return true; } if attempt == 0 { tokio::time::sleep(Duration::from_millis(250)).await; } } false } /// Distinguish a temporary RPC/WebSocket outage from a daemon that actually /// exited. The latter invalidates Aria2 GIDs and permits; the former must leave /// them intact so reconnect reconciliation can recover the transfer. fn aria2_daemon_process_exited(app_handle: &tauri::AppHandle) -> bool { let guard = app_handle.state::(); let Ok(mut child) = guard.child.lock() else { return false; }; match child.as_mut() { Some(child) => match child.try_wait() { Ok(Some(_)) => true, Ok(None) => false, Err(error) => { log::warn!("could not verify aria2 process state after RPC outage: {error}"); false } }, None => true, } } fn aria2_gid_not_found(error: &str) -> bool { let lower = error.to_ascii_lowercase(); lower.contains("gid") && lower.contains("not found") } async fn force_remove_aria2_gid(port: u16, secret: &str, gid: &str) -> Result<(), String> { match rpc_call(port, secret, "aria2.forceRemove", serde_json::json!([gid])).await { Ok(result) => ensure_aria2_gid_result("forceRemove", gid, &result), Err(error) if aria2_gid_not_found(&error) => { log::info!("aria2 forceRemove: gid {} was already absent", gid); Ok(()) } Err(error) => match aria2_download_status(port, secret, gid).await { Ok(status) if matches!(status.as_str(), "complete" | "error" | "removed") => { log::info!( "aria2 forceRemove: gid {} raced to terminal state {}", gid, status ); Ok(()) } _ => Err(format!("failed to remove aria2 gid {gid}: {error}")), }, } } async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<(), String> { for _ in 0..30 { match aria2_download_status(port, secret, gid).await { Ok(status) if matches!(status.as_str(), "paused" | "complete" | "error" | "removed") => { return Ok(()); } Ok(_) => {} Err(error) if aria2_gid_not_found(&error) => return Ok(()), Err(error) => return Err(error), } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } Err(format!( "aria2 gid {gid} did not stop within 3 seconds after forceRemove" )) } #[tauri::command] #[allow(unused_variables)] fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) { #[cfg(target_os = "macos")] { use objc::runtime::Object; use objc::{class, msg_send, sel, sel_impl}; use std::ffi::CString; let _ = app_handle.run_on_main_thread(move || { unsafe { let app_class = class!(NSApplication); let app: *mut Object = msg_send![app_class, sharedApplication]; let dock_tile: *mut Object = msg_send![app, dockTile]; let label = if count > 0 { count.to_string() } else { "".to_string() }; let c_label = CString::new(label).unwrap(); let ns_string_class = class!(NSString); let ns_label: *mut Object = msg_send![ns_string_class, alloc]; let ns_label: *mut Object = msg_send![ns_label, initWithUTF8String: c_label.as_ptr()]; let _: () = msg_send![dock_tile, setBadgeLabel: ns_label]; let _: () = msg_send![ns_label, release]; } }); } } #[tauri::command] fn get_platform_info(state: tauri::State<'_, AppState>) -> crate::ipc::PlatformInfo { crate::ipc::PlatformInfo { os: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), target_triple: crate::platform::target_triple(), portable: state.storage_layout.is_portable(), } } #[cfg(target_os = "macos")] mod macos_sleep { use std::ffi::c_void; #[allow(clippy::duplicated_attributes)] #[link(name = "IOKit", kind = "framework")] #[link(name = "CoreFoundation", kind = "framework")] extern "C" { pub fn IOPMAssertionCreateWithDescription( AssertionType: *const c_void, Name: *const c_void, Details: *const c_void, HumanReadableReason: *const c_void, LocalizationBundlePath: *const c_void, Timeout: f64, TimeoutAction: *const c_void, AssertionID: *mut u32, ) -> i32; pub fn IOPMAssertionRelease(AssertionID: u32) -> i32; pub fn CFStringCreateWithCString( alloc: *const c_void, cStr: *const i8, encoding: u32, ) -> *const c_void; pub fn CFRelease(arg: *const c_void); } } pub enum SleepPreventer { #[cfg(target_os = "macos")] Mac { system_sleep_id: u32, network_client_id: u32 }, #[cfg(not(target_os = "macos"))] Other(keepawake::KeepAwake), } impl Drop for SleepPreventer { fn drop(&mut self) { #[cfg(target_os = "macos")] { let SleepPreventer::Mac { system_sleep_id, network_client_id } = self; unsafe { macos_sleep::IOPMAssertionRelease(*system_sleep_id); macos_sleep::IOPMAssertionRelease(*network_client_id); } } } } #[cfg(target_os = "macos")] fn create_sleep_preventer() -> Result { use std::ffi::CString; use std::ptr::null; unsafe { let create_cf_string = |s: &str| -> *const std::ffi::c_void { let cstr = CString::new(s).unwrap(); macos_sleep::CFStringCreateWithCString(null(), cstr.as_ptr(), 0x08000100) }; let type_sys = create_cf_string("PreventSystemSleep"); let type_net = create_cf_string("NetworkClientActive"); let name = create_cf_string("Firelink active download"); let mut sys_id: u32 = 0; let mut net_id: u32 = 0; let res1 = macos_sleep::IOPMAssertionCreateWithDescription( type_sys, name, null(), null(), null(), 0.0, null(), &mut sys_id ); let res2 = macos_sleep::IOPMAssertionCreateWithDescription( type_net, name, null(), null(), null(), 0.0, null(), &mut net_id ); macos_sleep::CFRelease(type_sys); macos_sleep::CFRelease(type_net); macos_sleep::CFRelease(name); if res1 == 0 && res2 == 0 { Ok(SleepPreventer::Mac { system_sleep_id: sys_id, network_client_id: net_id }) } else { if res1 == 0 { macos_sleep::IOPMAssertionRelease(sys_id); } if res2 == 0 { macos_sleep::IOPMAssertionRelease(net_id); } Err("Failed to create macOS sleep assertions".to_string()) } } } #[cfg(not(target_os = "macos"))] fn create_sleep_preventer() -> Result { keepawake::Builder::default() .idle(true) .reason("Firelink active download") .create() .map(SleepPreventer::Other) .map_err(|error| format!("failed to prevent system sleep: {error}")) } #[tauri::command] fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) -> Result<(), String> { let mut current_preventer = state .sleep_preventer .lock() .unwrap_or_else(|e| e.into_inner()); if prevent { if current_preventer.is_none() { *current_preventer = Some(create_sleep_preventer()?); } } else { *current_preventer = None; } Ok(()) } pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> { match action { crate::ipc::PostQueueAction::Shutdown => { system_shutdown::shutdown().map_err(|e| e.to_string()) } crate::ipc::PostQueueAction::Restart => { system_shutdown::reboot().map_err(|e| e.to_string()) } crate::ipc::PostQueueAction::Sleep => system_shutdown::sleep().map_err(|e| e.to_string()), crate::ipc::PostQueueAction::None => Err("Invalid action".to_string()), } } #[tauri::command] fn perform_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> { execute_system_action(action) } #[tauri::command] fn ack_schedule_trigger( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, 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" => { if let Ok(mut cached) = state.scheduler_settings.write() { if let Some(settings) = cached.as_mut() { if action == "start" { settings.scheduler_last_start_key = key; } else { settings.scheduler_last_stop_key = key; } } } Ok(()) } _ => Err("Unknown scheduler trigger action".to_string()), } } #[tauri::command] async fn get_pending_order( state: tauri::State<'_, AppState>, queue_id: Option, ) -> Result, AppError> { Ok(state.queue_manager.pending_order(queue_id.as_deref()).await) } fn enqueue_lifecycle_generation(item: &queue::EnqueueItem) -> Result { item.lifecycle_generation .as_deref() .map(|generation| { generation .parse::() .map_err(|_| "Invalid enqueue lifecycle generation".to_string()) }) .transpose() .map(|generation| generation.unwrap_or_default()) } async fn validate_enqueue_url(url: &str) -> Result<(), String> { let parsed = reqwest::Url::parse(url).map_err(|_| "SSRF blocked: Invalid URL".to_string())?; match parsed.scheme() { "http" | "https" | "ftp" | "sftp" => { resolve_and_validate_url_host(&parsed).await.map(|_| ()) } _ => Err("Unsupported URL scheme".to_string()), } } async fn validate_enqueue_uris(url: &str, mirrors: Option<&str>) -> Result<(), String> { for uri in collect_download_uris(url, mirrors) { validate_enqueue_url(&uri).await?; } Ok(()) } #[tauri::command] async fn enqueue_download( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, mut item: queue::EnqueueItem, ) -> Result { validate_enqueue_uris(&item.url, item.mirrors.as_deref()) .await .map_err(AppError::Internal)?; let id = item.id.clone(); item.filename = crate::download_ownership::canonical_download_filename(&item.filename); let accepted_filename = item.filename.clone(); let lifecycle_generation = enqueue_lifecycle_generation(&item).map_err(AppError::Internal)?; let previous_generation = state .queue_manager .reserve_enqueue_generation(&id, lifecycle_generation) .await .map_err(AppError::Internal)?; if let Err(error) = crate::download_ownership::register_expected( &app_handle, &item.id, &item.destination, &item.filename, ) { state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) .await; return Err(AppError::Internal(error)); } if let Err(error) = state .queue_manager .commit_reserved_enqueue(item.into_task(), lifecycle_generation) .await { let _ = crate::download_ownership::remove(&app_handle, &id); state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) .await; return Err(AppError::Internal(error)); } Ok(crate::ipc::EnqueueAccepted { id, filename: accepted_filename, }) } #[tauri::command] async fn cancel_enqueue_generation( state: tauri::State<'_, AppState>, id: String, generation: String, ) -> Result<(), AppError> { let generation = generation .parse::() .map_err(|_| AppError::Internal("Invalid enqueue lifecycle generation".to_string()))?; state .queue_manager .cancel_enqueue_generation(&id, generation) .await; Ok(()) } #[tauri::command] async fn enqueue_many( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, items: Vec, ) -> Result, AppError> { let mut results = Vec::with_capacity(items.len()); for mut item in items { let id = item.id.clone(); if let Err(error) = validate_enqueue_uris(&item.url, item.mirrors.as_deref()).await { results.push(crate::ipc::EnqueueResult { id, success: false, filename: None, error: Some(error), }); continue; } item.filename = crate::download_ownership::canonical_download_filename(&item.filename); let filename = item.filename.clone(); let lifecycle_generation = match enqueue_lifecycle_generation(&item) { Ok(generation) => generation, Err(error) => { results.push(crate::ipc::EnqueueResult { id, success: false, filename: None, error: Some(error), }); continue; } }; let previous_generation = match state .queue_manager .reserve_enqueue_generation(&id, lifecycle_generation) .await { Ok(previous) => previous, Err(error) => { results.push(crate::ipc::EnqueueResult { id, success: false, filename: None, error: Some(error), }); continue; } }; if let Err(error) = crate::download_ownership::register_expected( &app_handle, &item.id, &item.destination, &item.filename, ) { state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) .await; results.push(crate::ipc::EnqueueResult { id, success: false, filename: None, error: Some(error), }); continue; } if let Err(error) = state .queue_manager .commit_reserved_enqueue(item.into_task(), lifecycle_generation) .await { let _ = crate::download_ownership::remove(&app_handle, &id); state .queue_manager .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) .await; results.push(crate::ipc::EnqueueResult { id, success: false, filename: None, error: Some(error), }); continue; } results.push(crate::ipc::EnqueueResult { id, success: true, filename: Some(filename), error: None, }); } Ok(results) } #[tauri::command] async fn move_in_queue( state: tauri::State<'_, AppState>, id: String, queue_id: String, direction: crate::ipc::QueueDirection, ) -> Result, AppError> { Ok(state .queue_manager .move_in_queue(&id, &queue_id, direction) .await) } #[tauri::command] async fn move_many_in_queue( state: tauri::State<'_, AppState>, ids: Vec, queue_id: String, direction: crate::ipc::QueueDirection, ) -> Result, AppError> { Ok(state .queue_manager .move_many_in_queue(&ids, &queue_id, direction) .await) } #[tauri::command] async fn remove_from_queue( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result { let removed = state.queue_manager.remove_from_pending(&id).await; if removed { let _ = crate::download_ownership::remove(&app_handle, &id); state.queue_manager.release_registered_id(&id).await; } Ok(removed) } #[tauri::command] async fn set_concurrent_limit( state: tauri::State<'_, AppState>, limit: usize, ) -> Result<(), String> { state.queue_manager.set_capacity(limit.clamp(1, 12)); Ok(()) } pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option { let trimmed = limit.trim(); if trimmed.is_empty() { return None; } let re = regex::Regex::new(r"(?i)^(\d+(?:\.\d+)?)\s*([kmgt]?)i?b?(?:/s)?$").ok()?; let captures = re.captures(trimmed)?; let amount = captures.get(1)?.as_str().parse::().ok()?; if !amount.is_finite() || amount <= 0.0 { return None; } let unit = captures .get(2) .map(|m| m.as_str().to_ascii_uppercase()) .unwrap_or_default(); Some(if unit.is_empty() { format!("{amount}K") } else { format!("{amount}{unit}") }) } #[tauri::command] async fn set_global_speed_limit( state: tauri::State<'_, AppState>, limit: Option, ) -> Result<(), String> { let normalized_limit = limit .as_deref() .and_then(normalize_speed_limit_for_aria2); let limit_str = normalized_limit.clone().unwrap_or_else(|| "0".to_string()); rpc_call( state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), &state.aria2_secret, "aria2.changeGlobalOption", serde_json::json!([{"max-overall-download-limit": limit_str}]), ) .await .map(|_| { state .queue_manager .set_aria2_global_speed_limit(normalized_limit); }) .map_err(|e| { eprintln!("Failed to set global speed limit: {}", e); e }) } #[tauri::command] fn check_automation_permission() -> Result<(), String> { #[cfg(target_os = "macos")] { use objc::runtime::Object; use objc::{class, msg_send, sel, sel_impl}; use std::ffi::CString; use std::ptr::null_mut; unsafe { objc::rc::autoreleasepool(|| { let script = "tell application \"System Events\" to get name"; let c_script = CString::new(script).unwrap(); let ns_string_class = class!(NSString); let script_str: *mut Object = msg_send![ns_string_class, alloc]; let script_str: *mut Object = msg_send![script_str, initWithUTF8String: c_script.as_ptr()]; let ns_apple_script: *mut Object = msg_send![class!(NSAppleScript), alloc]; let ns_apple_script: *mut Object = msg_send![ns_apple_script, initWithSource: script_str]; let mut error_dict: *mut Object = null_mut(); let result: *mut Object = msg_send![ns_apple_script, executeAndReturnError: &mut error_dict]; let _: () = msg_send![script_str, release]; let _: () = msg_send![ns_apple_script, release]; if result.is_null() { return Err("Automation permission was not granted".to_string()); } Ok(()) }) } } #[cfg(not(target_os = "macos"))] Ok(()) } #[tauri::command] fn request_automation_permission() -> Result<(), String> { #[cfg(target_os = "macos")] { system_shutdown::request_permission_dialog() .map_err(|error| format!("Automation permission was not granted: {error}")) } #[cfg(not(target_os = "macos"))] Ok(()) } #[tauri::command] #[allow(unused_variables)] fn open_automation_settings(app_handle: tauri::AppHandle) -> Result<(), String> { #[cfg(target_os = "macos")] { use tauri_plugin_opener::OpenerExt; app_handle .opener() .open_url( "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", None::, ) .map_err(|e| format!("Failed to open Automation settings: {}", e))?; Ok(()) } #[cfg(not(target_os = "macos"))] Err("Automation settings are only available on macOS".to_string()) } #[tauri::command] fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result { use sysinfo::Disks; let disks = Disks::new_with_refreshed_list(); let resolved_dest = resolve_path(&path, &app_handle); // Find the disk that the path is mounted on let mut best_match: Option<&sysinfo::Disk> = None; let mut max_match_len = 0; for disk in disks.list() { let mount_point = disk.mount_point(); if crate::platform::path_is_within(&resolved_dest, mount_point) { let match_len = mount_point.as_os_str().len(); if match_len > max_match_len { max_match_len = match_len; best_match = Some(disk); } } } if let Some(disk) = best_match { let bytes = disk.available_space(); let size_str = if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / 1024.0 / 1024.0) } else { format!("{:.2} GB", bytes as f64 / 1024.0 / 1024.0 / 1024.0) }; Ok(size_str) } else { Ok("Unknown".to_string()) } } #[tauri::command] fn set_keychain_password( database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, password: String, ) -> Result<(), String> { if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { let connection = database.lock()?; crate::db::save_pairing_token_to_settings(&connection, &password, true)?; return Ok(()); } require_keychain_access(&state)?; crate::db::set_keychain_password(&id, &password) } #[tauri::command] fn get_keychain_password( state: tauri::State<'_, AppState>, id: String, ) -> Result { if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { return Err("portable pairing token is stored in portable settings".to_string()); } require_keychain_access(&state)?; crate::db::get_keychain_password(&id) } #[tauri::command] fn delete_keychain_password( state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { if state.storage_layout.is_portable() && id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID { return Ok(()); } require_keychain_access(&state)?; crate::db::delete_keychain_password(&id) } #[tauri::command] fn save_site_login( database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, url_pattern: String, username: String, password: String, ) -> Result<(), String> { require_keychain_access(&state)?; let connection = database.lock()?; crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password) } #[tauri::command] fn delete_site_login( database: tauri::State<'_, crate::db::DbState>, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { require_keychain_access(&state)?; let connection = database.lock()?; crate::db::delete_site_login(&connection, &id) } /// Arm credential-store access after the frontend has completed its startup /// decision. The process-local gate prevents any stale or early IPC caller /// from producing a native OS prompt before the explanation is visible. #[tauri::command] fn authorize_keychain_access(state: tauri::State<'_, AppState>) -> Result<(), String> { if state.storage_layout.is_portable() { return Ok(()); } state .keychain_access_authorized .store(true, Ordering::Release); Ok(()) } #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] struct PairingTokenHydration { token: String, token_changed: bool, persistent: bool, error: Option, } /// Hydrate the extension pairing token after the frontend is ready. Standard /// mode uses the OS credential store; portable mode intentionally keeps the /// token with the portable settings folder. #[tauri::command] fn hydrate_extension_pairing_token( database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { let connection = database.lock()?; if app_state.storage_layout.is_portable() { let token = crate::db::load_pairing_token_from_settings(&connection)? .unwrap_or_else(crate::db::generate_pairing_token); crate::db::save_pairing_token_to_settings(&connection, &token, true)?; if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = token.clone(); } return Ok(PairingTokenHydration { token, token_changed: false, persistent: true, error: None, }); } require_keychain_access(&app_state)?; let migration_error = crate::db::migrate_legacy_pairing_token(&connection).err(); let keychain_token = crate::db::get_keychain_password(crate::db::PAIRING_TOKEN_KEYCHAIN_ID) .ok() .filter(|value| !value.trim().is_empty()); let persistent = keychain_token.is_some(); let token = keychain_token.unwrap_or_else(crate::db::generate_pairing_token); if !persistent && crate::db::has_user_data(&connection)? { crate::db::record_notice(&connection, crate::db::TOKEN_CHANGED_NOTICE)?; } let token_changed = crate::db::has_pending_notice(&connection, crate::db::TOKEN_CHANGED_NOTICE)?; if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = token.clone(); } Ok(PairingTokenHydration { token, token_changed, persistent, error: migration_error.or_else(|| { (!persistent).then(|| { "Credential store access is unavailable; browser pairing is session-only.".to_string() }) }), }) } /// Return the per-launch pairing token without touching the OS credential /// store. Startup uses this while the frontend explains credential access and /// waits for an explicit user decision. #[tauri::command] fn get_session_pairing_token( app_state: tauri::State<'_, AppState>, ) -> Result { let token = app_state .extension_pairing_token .read() .map_err(|_| "Extension pairing token lock is unavailable".to_string())? .clone(); if token.trim().is_empty() { return Err("Session pairing token is not initialized".to_string()); } Ok(PairingTokenHydration { token, token_changed: false, persistent: false, error: None, }) } #[tauri::command] fn regenerate_pairing_token( database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { let connection = database.lock()?; let generated = crate::db::generate_pairing_token(); if app_state.storage_layout.is_portable() { crate::db::save_pairing_token_to_settings(&connection, &generated, true)?; } else { require_keychain_access(&app_state)?; if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) { let token = app_state .extension_pairing_token .read() .map_err(|_| "Extension pairing token lock is unavailable".to_string())? .clone(); return Ok(PairingTokenHydration { token, token_changed: false, persistent: false, error: Some(error), }); } if let Err(error) = crate::db::set_keychain_password( crate::db::PAIRING_TOKEN_KEYCHAIN_ID, &generated, ) { let token = app_state .extension_pairing_token .read() .map_err(|_| "Extension pairing token lock is unavailable".to_string())? .clone(); return Ok(PairingTokenHydration { token, token_changed: false, persistent: false, error: Some(error), }); } } if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = generated.clone(); } Ok(PairingTokenHydration { token: generated, token_changed: false, persistent: true, error: None, }) } #[tauri::command] fn grant_keychain_access( database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { let _grant_guard = begin_keychain_grant(&app_state)?; let connection = database.lock()?; if app_state.storage_layout.is_portable() { let token = if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)? { existing } else { let generated = crate::db::generate_pairing_token(); crate::db::save_pairing_token_to_settings(&connection, &generated, true)?; generated }; if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = token.clone(); } app_state .keychain_access_authorized .store(true, Ordering::Release); return Ok(PairingTokenHydration { token, token_changed: false, persistent: true, error: None, }); } if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) { let token = app_state .extension_pairing_token .read() .map_err(|_| "Extension pairing token lock is unavailable".to_string())? .clone(); return Ok(PairingTokenHydration { token, token_changed: false, persistent: false, error: Some(error), }); } let token = match crate::db::get_keychain_password(crate::db::PAIRING_TOKEN_KEYCHAIN_ID) { Ok(token) if !token.trim().is_empty() => token, _ => { let generated = crate::db::generate_pairing_token(); if let Err(error) = crate::db::set_keychain_password( crate::db::PAIRING_TOKEN_KEYCHAIN_ID, &generated, ) { let current = app_state .extension_pairing_token .read() .map_err(|_| "Extension pairing token lock is unavailable".to_string())? .clone(); return Ok(PairingTokenHydration { token: current, token_changed: false, persistent: false, error: Some(error), }); } generated } }; { if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = token.clone(); } } app_state .keychain_access_authorized .store(true, Ordering::Release); Ok(PairingTokenHydration { token, token_changed: false, persistent: true, error: None, }) } #[tauri::command] fn acknowledge_pairing_token_change( state: tauri::State<'_, crate::db::DbState>, ) -> Result<(), String> { let connection = state.lock()?; crate::db::acknowledge_pairing_token_notice(&connection) } #[tauri::command] fn db_save_settings( state: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, data: String, ) -> Result<(), String> { let connection = state.lock()?; let existing = crate::db::load_settings(&connection)?; let merged = crate::settings::preserve_scheduler_runtime_keys(existing.as_deref(), &data)?; let merged = if state.is_portable() { crate::settings::preserve_portable_pairing_token(existing.as_deref(), &merged)? } else { let sanitized = crate::db::strip_pairing_token_from_settings(&merged)?; crate::db::preserve_legacy_pairing_token(existing.as_deref(), &sanitized)? }; crate::db::save_settings(&connection, &merged)?; let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?; if let Ok(mut cached) = app_state.scheduler_settings.write() { *cached = Some(decoded); } Ok(()) } #[tauri::command] fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result, String> { let connection = state.lock()?; let settings = crate::db::load_settings(&connection)?; if state.is_portable() { return Ok(settings); } settings .map(|data| crate::db::strip_pairing_token_from_settings(&data)) .transpose() } #[tauri::command] fn db_get_all_downloads( state: tauri::State<'_, crate::db::DbState>, ) -> Result, String> { let connection = state.lock()?; crate::db::load_downloads(&connection) } #[tauri::command] fn db_replace_downloads( state: tauri::State<'_, crate::db::DbState>, data: String, ) -> Result<(), String> { let portable = state.is_portable(); let mut connection = state.lock()?; crate::db::replace_downloads(&mut connection, &data, portable) } #[tauri::command] fn db_get_all_queues(state: tauri::State<'_, crate::db::DbState>) -> Result, String> { let connection = state.lock()?; crate::db::load_queues(&connection) } #[tauri::command] fn db_replace_queues( state: tauri::State<'_, crate::db::DbState>, data: String, ) -> Result<(), String> { let mut connection = state.lock()?; crate::db::replace_queues(&mut connection, &data) } #[tauri::command] fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool { let resolved_dest = resolve_path(&path, &app_handle); if !is_safe_path(&resolved_dest, &app_handle) { return false; } resolved_dest.exists() } fn collect_log_files(log_dir: &std::path::Path) -> Result, String> { let directory_metadata = match std::fs::symlink_metadata(log_dir) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(error) => return Err(format!("failed to inspect log directory: {error}")), }; if directory_metadata.file_type().is_symlink() || !directory_metadata.is_dir() { return Ok(Vec::new()); } let canonical_log_dir = match std::fs::canonicalize(log_dir) { Ok(path) if path.is_dir() => path, Ok(_) | Err(_) => return Ok(Vec::new()), }; let entries = match std::fs::read_dir(log_dir) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(error) => return Err(format!("failed to read log directory: {error}")), }; let mut files = Vec::new(); for entry in entries { let path = entry .map_err(|error| format!("failed to inspect log directory entry: {error}"))? .path(); let metadata = std::fs::symlink_metadata(&path) .map_err(|error| format!("failed to inspect log file '{}': {error}", path.display()))?; if !metadata.file_type().is_file() || metadata.file_type().is_symlink() || !path .file_name() .is_some_and(|name| name.to_string_lossy().contains(".log")) { continue; } let canonical_path = std::fs::canonicalize(&path) .map_err(|error| format!("failed to resolve log file '{}': {error}", path.display()))?; if canonical_path.starts_with(&canonical_log_dir) { files.push(canonical_path); } } files.sort(); Ok(files) } async fn log_files(app_handle: &tauri::AppHandle) -> Result, String> { let log_dir = app_handle .state::() .storage_layout .log_dir() .to_path_buf(); collect_log_files(&log_dir) } pub(crate) fn redact_sensitive_text(line: &str) -> String { use std::sync::OnceLock; static SECRET: OnceLock = OnceLock::new(); static QUOTED_SECRET: OnceLock = OnceLock::new(); static HEADER: OnceLock = OnceLock::new(); static QUERY: OnceLock = OnceLock::new(); static USERINFO: OnceLock = OnceLock::new(); static FRAGMENT: OnceLock = OnceLock::new(); let secret = SECRET.get_or_init(|| { regex::Regex::new( r"(?i)(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)\s*[:=]\s*([^\r\n,;]+)", ) .expect("valid secret redaction regex") }); let quoted_secret = QUOTED_SECRET.get_or_init(|| { regex::Regex::new( r#"(?i)(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']"#, ) .expect("valid quoted secret redaction regex") }); let header = HEADER.get_or_init(|| { regex::Regex::new( r"(?i)(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]+", ) .expect("valid sensitive header redaction regex") }); let query = QUERY.get_or_init(|| { regex::Regex::new(r"([A-Za-z][A-Za-z0-9+.-]*://[^\s?]+)\?[^\s]+") .expect("valid URL query redaction regex") }); let userinfo = USERINFO.get_or_init(|| { regex::Regex::new(r"(?i)([A-Za-z][A-Za-z0-9+.-]*://)[^@\s/?#]+@") .expect("valid URL userinfo redaction regex") }); let fragment = FRAGMENT.get_or_init(|| { regex::Regex::new(r"([A-Za-z][A-Za-z0-9+.-]*://[^\s?#]+)#\S+") .expect("valid URL fragment redaction regex") }); let redacted = header.replace_all(line, "$1: [redacted]"); let redacted = quoted_secret.replace_all(&redacted, "$1$2$3$4[redacted]"); let redacted = secret.replace_all(&redacted, "$1=[redacted]"); let redacted = userinfo.replace_all(&redacted, "$1[redacted]@"); let redacted = query.replace_all(&redacted, "$1?[redacted]"); fragment .replace_all(&redacted, "$1#[redacted]") .into_owned() } fn redact_log_line(line: &str) -> String { redact_sensitive_text(line) } fn redact_log_line_for_output(line: &str) -> String { static HOME_PATHS: OnceLock<(String, String)> = OnceLock::new(); let (home, escaped_home) = HOME_PATHS.get_or_init(|| { let home = std::env::var("USERPROFILE") .or_else(|_| std::env::var("HOME")) .unwrap_or_default(); let home = if home.len() > 3 { home } else { String::new() }; let escaped_home = home.replace('\\', "\\\\"); (home, escaped_home) }); let mut redacted = line.to_string(); if !home.is_empty() { redacted = redacted.replace(home, ""); } if !escaped_home.is_empty() && escaped_home != home { redacted = redacted.replace(escaped_home, ""); } redact_log_line(&redacted) } fn redact_log_line_for_app(line: &str, app_handle: &tauri::AppHandle) -> String { use tauri::Manager; let without_home = app_handle .path() .home_dir() .ok() .map(|home| { let home = home.to_string_lossy(); line.replace(home.as_ref(), "~") }) .unwrap_or_else(|| line.to_string()); redact_log_line(&without_home) } #[tauri::command] async fn read_logs(app_handle: tauri::AppHandle, limit: usize) -> Result, 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(|line| redact_log_line_for_app(line, &app_handle)), ); } let keep = limit.clamp(1, 10_000); if lines.len() > keep { lines.drain(..lines.len() - keep); } Ok(lines) } #[tauri::command] async fn clear_logs(app_handle: tauri::AppHandle) -> Result<(), String> { for file in log_files(&app_handle).await? { tokio::fs::write(&file, "") .await .map_err(|e| format!("Failed to clear log file {:?}: {}", file, e))?; } Ok(()) } #[tauri::command] async fn export_logs( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, destination: Option, ) -> Result { 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.load(std::sync::atomic::Ordering::Relaxed), &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? { let file_name = file .file_name() .map(|name| name.to_string_lossy()) .unwrap_or_else(|| std::borrow::Cow::Borrowed("firelink.log")); output.push_str(&format!("===== {} =====\n", file_name)); 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_for_app(line, &app_handle)); output.push('\n'); } output.push('\n'); } if let Some(destination) = destination { let destination = std::path::PathBuf::from(destination.trim()); if destination.file_name().is_none() { return Err("log export destination must be a file path".to_string()); } tokio::fs::write(&destination, &output) .await .map_err(|error| format!("failed to write exported logs to '{}': {error}", destination.display()))?; } Ok(output) } #[tauri::command] fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> { if show { build_main_tray(&app_handle) } else { if app_handle.tray_by_id("main").is_some() { let _ = app_handle.remove_tray_by_id("main"); } Ok(()) } } fn build_main_tray(app_handle: &tauri::AppHandle) -> Result<(), String> { use tauri::menu::{Menu, MenuItem}; use tauri::tray::TrayIconBuilder; if app_handle.tray_by_id("main").is_some() { return Ok(()); } let show_i = MenuItem::with_id(app_handle, "show", "Show Firelink", true, None::<&str>) .map_err(|e| e.to_string())?; let pause_all_i = MenuItem::with_id(app_handle, "pause_all", "Pause All", true, None::<&str>) .map_err(|e| e.to_string())?; let resume_all_i = MenuItem::with_id(app_handle, "resume_all", "Resume All", true, None::<&str>) .map_err(|e| e.to_string())?; let quit_i = MenuItem::with_id(app_handle, "quit", "Quit", true, None::<&str>) .map_err(|e| e.to_string())?; let menu = Menu::with_items(app_handle, &[&show_i, &pause_all_i, &resume_all_i, &quit_i]) .map_err(|e| e.to_string())?; #[cfg(target_os = "macos")] let tray_icon_bytes = include_bytes!("../icons/trayTemplate.png").as_slice(); #[cfg(not(target_os = "macos"))] let tray_icon_bytes = include_bytes!("../icons/128x128.png").as_slice(); let tray_icon = tauri::image::Image::from_bytes(tray_icon_bytes).map_err(|e| e.to_string())?; #[allow(unused_mut)] let mut tray = TrayIconBuilder::with_id("main") .icon(tray_icon) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id.as_ref() { "show" => restore_main_window(app), "pause_all" => { use tauri::Emitter; let _ = app.emit("tray-action", "pause-all"); } "resume_all" => { use tauri::Emitter; let _ = app.emit("tray-action", "resume-all"); } "quit" => app.exit(0), _ => {} }) .on_tray_icon_event(|tray, event| { use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; if matches!( event, TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } ) { restore_main_window(tray.app_handle()); } }); #[cfg(target_os = "macos")] { tray = tray.icon_as_template(true); } tray.build(app_handle).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] fn set_extension_pairing_token( state: tauri::State<'_, AppState>, token: String, ) -> Result<(), String> { if token.is_empty() || token.len() > 512 { return Err("Invalid extension pairing token".to_string()); } let mut pairing_token = state .extension_pairing_token .write() .map_err(|_| "Extension pairing token lock is unavailable".to_string())?; *pairing_token = token; Ok(()) } #[tauri::command] fn get_extension_server_port(state: tauri::State<'_, AppState>) -> Option { state .extension_server_port .read() .ok() .and_then(|port| *port) } #[tauri::command] fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool) { state .extension_frontend_ready .store(ready, Ordering::Release); let coordinator = state.download_coordinator.clone(); tauri::async_runtime::spawn(async move { let _ = coordinator .send(download::DownloadCmd::FrontendReady(ready)) .await; }); } #[tauri::command] fn ack_extension_download( state: tauri::State<'_, AppState>, request_id: String, ) -> Result<(), String> { if request_id.len() != 32 || !request_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("Invalid extension request id".to_string()); } extension_server::acknowledge_extension_download(&state.extension_acks, &request_id); Ok(()) } #[cfg(test)] mod tests { use super::{ aggregate_media_byte_progress, aggregate_media_fraction, append_ytdlp_config_option, append_ytdlp_http_headers, build_media_format_options, collect_download_uris, drain_media_output_lines, filename_from_content_disposition, filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format, is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key, media_output_template, media_progress_args, media_progress_event_totals, media_progress_speed, cookie_scope_for_url, metadata_authentication_error, metadata_cookie_header_present, metadata_headers, metadata_response_error, normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, has_resumable_download_assets, should_cleanup_media_artifacts_after_failure, should_retry_without_browser_cookies, retry_metadata_with_cookies, should_retry_metadata_with_cookies, should_send_metadata_credentials, collect_log_files, FirelinkDeepLink, percent_decode_metadata_value, MediaProgress, MediaProgressEmitterState, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, observe_aria2_connections, observe_aria2_connections_with_epoch, Aria2ConnectionObservation, Aria2ConnectionSample, Aria2RecoveryReason, parse_media_playlist_metadata, validate_enqueue_url, validate_enqueue_uris, }; use serde_json::json; use std::time::{Duration, Instant}; #[tokio::test] async fn enqueue_url_validation_blocks_local_http_but_preserves_ftp() { assert_eq!( validate_enqueue_url("http://127.0.0.1/file.zip").await, Err("SSRF blocked: Private/local IP not allowed".to_string()) ); assert_eq!( validate_enqueue_url("ftp://127.0.0.1/file.zip").await, Err("SSRF blocked: Private/local IP not allowed".to_string()) ); assert_eq!( validate_enqueue_url("sftp://[::1]/file.zip").await, Err("SSRF blocked: Private/local IP not allowed".to_string()) ); assert_eq!( validate_enqueue_url("http://[::ffff:127.0.0.1]/file.zip").await, Err("SSRF blocked: Private/local IP not allowed".to_string()) ); assert_eq!( validate_enqueue_url("file:///tmp/file.zip").await, Err("Unsupported URL scheme".to_string()) ); } #[tokio::test] async fn enqueue_uri_validation_covers_mirrors_not_only_the_primary_url() { assert_eq!( validate_enqueue_uris( "http://192.0.2.1/file.zip", Some("http://127.0.0.1/internal\nfile:///etc/passwd"), ) .await, Err("SSRF blocked: Private/local IP not allowed".to_string()) ); assert_eq!( validate_enqueue_uris( "http://192.0.2.1/file.zip", Some("file:///etc/passwd"), ) .await, Err("Unsupported URL scheme".to_string()) ); } #[test] fn slow_nonzero_aria2_throughput_recovers_after_a_sustained_degradation() { let start = Instant::now(); let mut observation = Aria2ConnectionObservation::default(); assert_eq!( observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 10 * 1024 * 1024, 10.0 * 1024.0 * 1024.0, 16, 16, false, start, ), None ); observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 11 * 1024 * 1024, 10.0 * 1024.0 * 1024.0, 16, 16, false, start + Duration::from_secs(1), ); observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 12 * 1024 * 1024, 10.0 * 1024.0 * 1024.0, 16, 16, false, start + Duration::from_secs(2), ); assert_eq!( observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 11 * 1024 * 1024, 1024.0 * 1024.0, 16, 16, false, start + Duration::from_secs(31), ), None, "the first slow sample starts the degradation timer" ); assert_eq!( observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 12 * 1024 * 1024, 1024.0 * 1024.0, 16, 16, false, start + Duration::from_secs(62), ), Some(Aria2RecoveryReason::SlowThroughput) ); } #[test] fn aria2_recovery_uses_a_cooldown_instead_of_a_one_shot_latch() { let start = Instant::now(); let mut observation = Aria2ConnectionObservation::default(); let sample = |observation: &mut Aria2ConnectionObservation, now: Instant| { observe_aria2_connections( observation, "gid-1", "active", 100 * 1024 * 1024, 12 * 1024 * 1024, 1024.0 * 1024.0, 16, 16, false, now, ) }; observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 12 * 1024 * 1024, 10.0 * 1024.0 * 1024.0, 16, 16, false, start, ); observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 13 * 1024 * 1024, 10.0 * 1024.0 * 1024.0, 16, 16, false, start + Duration::from_secs(1), ); observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 14 * 1024 * 1024, 10.0 * 1024.0 * 1024.0, 16, 16, false, start + Duration::from_secs(2), ); sample(&mut observation, start + Duration::from_secs(31)); assert_eq!( sample(&mut observation, start + Duration::from_secs(62)), Some(Aria2RecoveryReason::SlowThroughput) ); assert_eq!(sample(&mut observation, start + Duration::from_secs(93)), None); assert_eq!( sample(&mut observation, start + Duration::from_secs(108)), Some(Aria2RecoveryReason::SlowThroughput), "a later degradation can recover again after the cooldown" ); } #[test] fn aria2_recovery_observation_resets_after_a_control_epoch_change() { let start = Instant::now(); let mut observation = Aria2ConnectionObservation::default(); for (offset, completed, speed) in [ (0, 10 * 1024 * 1024, 10.0 * 1024.0 * 1024.0), (1, 11 * 1024 * 1024, 10.0 * 1024.0 * 1024.0), (2, 12 * 1024 * 1024, 10.0 * 1024.0 * 1024.0), ] { observe_aria2_connections_with_epoch( &mut observation, Aria2ConnectionSample { gid: "gid-epoch", control_epoch: 1, status: "active", total: 100 * 1024 * 1024, completed, speed_bytes: speed, active_connections: 16, requested_connections: 16, speed_limited: false, now: start + Duration::from_secs(offset), }, ); } observe_aria2_connections_with_epoch( &mut observation, Aria2ConnectionSample { gid: "gid-epoch", control_epoch: 1, status: "active", total: 100 * 1024 * 1024, completed: 13 * 1024 * 1024, speed_bytes: 1024.0 * 1024.0, active_connections: 16, requested_connections: 16, speed_limited: false, now: start + Duration::from_secs(31), }, ); assert!(observation.saw_multiple_connections); assert_eq!( observe_aria2_connections_with_epoch( &mut observation, Aria2ConnectionSample { gid: "gid-epoch", control_epoch: 2, status: "active", total: 100 * 1024 * 1024, completed: 13 * 1024 * 1024, speed_bytes: 1024.0 * 1024.0, active_connections: 1, requested_connections: 16, speed_limited: false, now: start + Duration::from_secs(62), }, ), None ); assert_eq!(observation.control_epoch, 2); assert!(!observation.saw_multiple_connections); assert_eq!(observation.healthy_speed_samples, 0); } #[test] fn slow_recovery_requires_a_real_multi_connection_transfer() { let start = Instant::now(); let mut observation = Aria2ConnectionObservation::default(); for (offset, speed) in [(0, 10.0 * 1024.0 * 1024.0), (31, 1024.0 * 1024.0), (62, 1024.0 * 1024.0)] { assert_eq!( observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 10 * 1024 * 1024 + offset * 1024, speed, 1, 16, false, start + Duration::from_secs(offset), ), None ); } } #[test] fn slow_recovery_ignores_intentional_speed_limits() { let start = Instant::now(); let mut observation = Aria2ConnectionObservation::default(); for offset in 0..3 { observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, (10 + offset) * 1024 * 1024, 10.0 * 1024.0 * 1024.0, 16, 16, true, start + Duration::from_secs(offset), ); } assert_eq!( observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 14 * 1024 * 1024, 1024.0 * 1024.0, 16, 16, true, start + Duration::from_secs(31), ), None ); assert_eq!( observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 15 * 1024 * 1024, 1024.0 * 1024.0, 16, 16, true, start + Duration::from_secs(62), ), None ); } #[test] fn one_startup_spike_does_not_arm_slow_recovery() { let start = Instant::now(); let mut observation = Aria2ConnectionObservation::default(); observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 10 * 1024 * 1024, 10.0 * 1024.0 * 1024.0, 16, 16, false, start, ); for offset in [31_u64, 62, 93] { assert_eq!( observe_aria2_connections( &mut observation, "gid-1", "active", 100 * 1024 * 1024, 10 * 1024 * 1024 + offset * 1024, 1024.0 * 1024.0, 16, 16, false, start + Duration::from_secs(offset), ), None ); } } #[test] fn media_metadata_fallback_lets_ytdlp_choose_extension() { let destination = std::path::Path::new("/tmp/firelink"); let template = media_output_template(destination, "1234567890", None); assert_eq!( template, destination.join("%(title).200B [%(id)s].%(ext)s") ); } #[test] fn selected_media_format_keeps_requested_output_path() { let destination = std::path::Path::new("/tmp/firelink"); let template = media_output_template(destination, "clip.mp4", Some("best")); assert_eq!(template, destination.join("clip.mp4")); } #[test] fn parses_playlist_entries_and_skips_duplicates_or_unusable_entries() { let metadata = parse_media_playlist_metadata( &json!({ "id": "playlist-1", "title": "Example playlist", "playlist_count": 5, "entries": [ {"id": "one", "title": "One", "url": "https://www.youtube.com/watch?v=one"}, {"id": "one", "title": "Duplicate", "url": "https://www.youtube.com/watch?v=one"}, {"id": "missing", "title": "Missing"}, {"id": "two", "title": "Two", "url": "https://www.youtube.com/watch?v=wrong", "webpage_url": "https://www.youtube.com/watch?v=two"} ] }), 10, ) .unwrap(); assert_eq!(metadata.playlist_id.as_deref(), Some("playlist-1")); assert_eq!(metadata.title, "Example playlist"); assert_eq!(metadata.entries.len(), 2); assert_eq!(metadata.entries[0].url, "https://www.youtube.com/watch?v=one"); assert_eq!(metadata.entries[1].url, "https://www.youtube.com/watch?v=two"); assert_eq!(metadata.skipped_entries, 3); assert!(!metadata.truncated); } #[test] fn bounds_playlist_discovery_without_silently_claiming_all_entries_loaded() { let metadata = parse_media_playlist_metadata( &json!({ "id": "large-playlist", "title": "Large playlist", "playlist_count": 4, "entries": [ {"id": "one", "url": "https://www.youtube.com/watch?v=one"}, {"id": "two", "url": "https://www.youtube.com/watch?v=two"}, {"id": "three", "url": "https://www.youtube.com/watch?v=three"}, {"id": "four", "url": "https://www.youtube.com/watch?v=four"} ] }), 2, ) .unwrap(); assert_eq!(metadata.entries.len(), 2); assert_eq!(metadata.entry_count, Some(4)); assert_eq!(metadata.skipped_entries, 2); assert!(metadata.truncated); } #[test] fn rejects_playlists_without_usable_entries() { let error = parse_media_playlist_metadata( &json!({"title": "Empty", "entries": [{"id": "missing"}]}), 100, ) .expect_err("a playlist without downloadable URLs must fail clearly"); assert!(error.contains("no usable entries")); } #[test] fn metadata_rejects_final_http_errors_but_accepts_partial_content() { assert!(metadata_response_error(reqwest::StatusCode::PARTIAL_CONTENT).is_none()); assert_eq!( metadata_response_error(reqwest::StatusCode::NOT_FOUND).as_deref(), Some("Metadata request failed with HTTP 404 (Not Found)") ); } #[test] fn metadata_redirect_credentials_require_the_exact_origin() { let original = reqwest::Url::parse("https://example.com/file").unwrap(); let same_origin = reqwest::Url::parse("https://example.com/other").unwrap(); let subdomain = reqwest::Url::parse("https://cdn.example.com/file").unwrap(); let different_port = reqwest::Url::parse("https://example.com:8443/file").unwrap(); let downgraded = reqwest::Url::parse("http://example.com/file").unwrap(); assert!(should_send_metadata_credentials( Some(&original), Some(&original), 0 )); assert!(should_send_metadata_credentials( Some(&original), Some(&same_origin), 1 )); assert!(!should_send_metadata_credentials( Some(&original), Some(&subdomain), 1 )); assert!(!should_send_metadata_credentials( Some(&original), Some(&different_port), 1 )); assert!(!should_send_metadata_credentials( Some(&original), Some(&downgraded), 1 )); } #[cfg(unix)] #[test] fn log_collection_rejects_symlink_files_and_directories() { use std::os::unix::fs::symlink; let root = tempfile::tempdir().unwrap(); let log_dir = root.path().join("logs"); let outside = tempfile::tempdir().unwrap(); std::fs::create_dir_all(&log_dir).unwrap(); std::fs::write(log_dir.join("firelink.log"), "safe").unwrap(); std::fs::write(outside.path().join("secret.log"), "redacted-fixture").unwrap(); symlink( outside.path().join("secret.log"), log_dir.join("attacker.log"), ) .unwrap(); let files = collect_log_files(&log_dir).unwrap(); assert_eq!(files, vec![std::fs::canonicalize(log_dir.join("firelink.log")).unwrap()]); let redirected_logs = root.path().join("redirected-logs"); symlink(outside.path(), &redirected_logs).unwrap(); assert!(collect_log_files(&redirected_logs).unwrap().is_empty()); } #[test] fn metadata_defers_captured_cookies_until_the_origin_challenges() { let headers = "Cookie: oversized=secret\nReferer: https://example.com/page"; assert!(metadata_cookie_header_present( Some(headers), Some("session=secret"), None )); let initial_headers = metadata_headers(Some(headers), Some("session=secret"), false); assert!(initial_headers.get(reqwest::header::COOKIE).is_none()); assert_eq!( initial_headers .get(reqwest::header::REFERER) .and_then(|value| value.to_str().ok()), Some("https://example.com/page") ); let authenticated_headers = metadata_headers(Some(headers), Some("session=secret"), true); assert_eq!( authenticated_headers .get(reqwest::header::COOKIE) .and_then(|value| value.to_str().ok()), Some("session=secret") ); } #[test] fn metadata_cookie_scopes_select_only_the_matching_origin() { let scopes = vec![ crate::extension_server::ExtensionCookieScope { url: "https://mail.google.com/".to_string(), cookies: "mail=session".to_string(), }, crate::extension_server::ExtensionCookieScope { url: "https://accounts.google.com/".to_string(), cookies: "account=session".to_string(), }, crate::extension_server::ExtensionCookieScope { url: "https://googleusercontent.com/".to_string(), cookies: "googleusercontent=session".to_string(), }, ]; assert_eq!( cookie_scope_for_url("https://mail.google.com/mail/u/0/", Some(&scopes)), Some("mail=session") ); assert_eq!( cookie_scope_for_url("https://accounts.google.com/v3/signin/identifier", Some(&scopes)), Some("account=session") ); assert_eq!( cookie_scope_for_url("https://mail-attachment.googleusercontent.com/file", Some(&scopes)), Some("googleusercontent=session") ); assert_eq!( cookie_scope_for_url("https://attacker.example/redirect", Some(&scopes)), None ); assert!(!metadata_cookie_header_present(None, None, Some(&scopes[..0]))); assert!(metadata_cookie_header_present(None, None, Some(&scopes))); } #[test] fn metadata_cookie_retry_is_limited_to_one_auth_challenge() { let mut send_cookies = false; let mut cookie_retry_attempted = false; let mut current_url = "https://cdn.example/file".to_string(); let mut redirects = 3; assert!(should_retry_metadata_with_cookies( reqwest::StatusCode::FORBIDDEN, true, false )); assert!(should_retry_metadata_with_cookies( reqwest::StatusCode::UNAUTHORIZED, true, false )); assert!(!should_retry_metadata_with_cookies( reqwest::StatusCode::OK, true, false )); assert!(!should_retry_metadata_with_cookies( reqwest::StatusCode::FORBIDDEN, true, true )); assert!(retry_metadata_with_cookies( reqwest::StatusCode::FORBIDDEN, true, &mut send_cookies, &mut cookie_retry_attempted, "https://origin.example/file", &mut current_url, &mut redirects, )); assert!(send_cookies); assert!(cookie_retry_attempted); assert_eq!(current_url, "https://origin.example/file"); assert_eq!(redirects, 0); assert!(!retry_metadata_with_cookies( reqwest::StatusCode::FORBIDDEN, true, &mut send_cookies, &mut cookie_retry_attempted, "https://origin.example/file", &mut current_url, &mut redirects, )); } #[test] fn recognizes_resume_sidecars_without_treating_the_primary_file_as_partial() { let directory = tempfile::tempdir().unwrap(); let primary = directory.path().join("download.bin"); std::fs::write(&primary, b"partial").unwrap(); assert!(!has_resumable_download_assets(&primary)); std::fs::write(directory.path().join("download.bin.aria2"), b"control").unwrap(); assert!(has_resumable_download_assets(&primary)); } #[test] fn ytdlp_progress_args_force_progress_in_quiet_print_mode() { let args = media_progress_args(); assert!(args.iter().any(|arg| arg == "--progress")); assert!(args.windows(2).any(|pair| { pair[0] == "--progress-template" && pair[1] == format!("download:{MEDIA_PROGRESS_PREFIX}%(progress)j") })); } #[test] fn ytdlp_config_values_cannot_inject_extra_lines() { assert_eq!( sanitize_ytdlp_config_value("user\n--exec\rmalicious"), "user--execmalicious" ); } #[test] fn ytdlp_media_headers_include_captured_cookies_once() { let mut config = String::new(); append_ytdlp_http_headers( &mut config, Some("Referer: https://example.com/video"), Some("session=abc; preference=high\r\n--proxy=http://bad.invalid"), ) .unwrap(); assert_eq!( config, "--add-header 'Referer: https://example.com/video'\n--add-header 'Cookie: session=abc; preference=high--proxy=http://bad.invalid'\n" ); } #[test] fn ytdlp_config_options_quote_embedded_single_quotes() { let mut config = String::new(); append_ytdlp_config_option(&mut config, "--username", "sam's account"); assert_eq!(config, "--username 'sam'\\''s account'\n"); } #[test] fn ytdlp_media_headers_reject_invalid_lines() { let mut config = String::new(); let error = append_ytdlp_http_headers(&mut config, Some("not a header"), None) .expect_err("invalid header line should be rejected"); assert!(error.contains("invalid HTTP header")); } #[test] fn media_metadata_cache_key_includes_request_headers_and_cookies() { let base = media_metadata_cache_key( "https://example.com/watch?v=1", &Some("firefox".to_string()), &Some("Custom UA A".to_string()), &None, &None, &Some("User-Agent: Browser A".to_string()), &Some("session=one".to_string()), &None, ); let changed_headers = media_metadata_cache_key( "https://example.com/watch?v=1", &Some("firefox".to_string()), &Some("Custom UA A".to_string()), &None, &None, &Some("User-Agent: Browser B".to_string()), &Some("session=one".to_string()), &None, ); let changed_cookies = media_metadata_cache_key( "https://example.com/watch?v=1", &Some("firefox".to_string()), &Some("Custom UA A".to_string()), &None, &None, &Some("User-Agent: Browser A".to_string()), &Some("session=two".to_string()), &None, ); let changed_user_agent = media_metadata_cache_key( "https://example.com/watch?v=1", &Some("firefox".to_string()), &Some("Custom UA B".to_string()), &None, &None, &Some("User-Agent: Browser A".to_string()), &Some("session=one".to_string()), &None, ); let without_browser = media_metadata_cache_key( "https://example.com/watch?v=1", &None, &Some("Custom UA A".to_string()), &None, &None, &Some("User-Agent: Browser A".to_string()), &Some("session=one".to_string()), &None, ); assert_ne!(base, changed_headers); assert_ne!(base, changed_cookies); assert_ne!(base, changed_user_agent); assert_ne!(base, without_browser); } #[test] fn retryable_media_failures_preserve_resumable_artifacts() { assert!(!should_cleanup_media_artifacts_after_failure( "The response status is not successful. status=503", 0, 1 )); assert!(should_cleanup_media_artifacts_after_failure( "The response status is not successful. status=503", 1, 1 )); assert!(should_cleanup_media_artifacts_after_failure( "HTTP 404 Not Found", 0, 3 )); } #[test] fn metadata_filename_prefers_content_disposition_filename() { assert_eq!( filename_from_content_disposition( "attachment; filename*=UTF-8''OnionHop-3.5-macOS-arm64.dmg; filename=ignored.bin" ), Some("OnionHop-3.5-macOS-arm64.dmg".to_string()) ); assert_eq!( filename_from_content_disposition("attachment; filename=OnionHop-3.5-macOS-arm64.dmg"), Some("OnionHop-3.5-macOS-arm64.dmg".to_string()) ); } #[test] fn metadata_filename_reads_redirect_disposition_query_before_opaque_path() { let redirected = "https://release-assets.githubusercontent.com/github-production-release-asset/1117828249/7aae36e6-00ec-4e7d-8dec-f14ace170bdb?rscd=attachment%3B+filename%3DOnionHop-3.5-macOS-arm64.dmg"; assert_eq!( filename_from_url_disposition_query(redirected), Some("OnionHop-3.5-macOS-arm64.dmg".to_string()) ); assert_eq!( filename_from_url_path(redirected), Some("7aae36e6-00ec-4e7d-8dec-f14ace170bdb".to_string()) ); } #[test] fn metadata_filename_decodes_url_path_segments_before_sanitizing() { assert_eq!( filename_from_url_path("https://example.com/files/Rick%20and%20Morty%20-%20S09E08.mkv"), Some("Rick and Morty - S09E08.mkv".to_string()) ); assert_eq!( filename_from_url_path("https://example.com/files/%E2%98%83%20episode.mkv"), Some("☃ episode.mkv".to_string()) ); } #[test] fn metadata_filename_preserves_invalid_utf8_and_rejects_decoded_dot_segments() { assert_eq!( percent_decode_metadata_value("literal%☃"), Some("literal%☃".to_string()) ); assert_eq!( filename_from_url_path("https://example.com/files/file%FF.mkv"), Some("file%FF.mkv".to_string()) ); assert_eq!( filename_from_url_path("https://example.com/files/file%ZZ.mkv"), Some("file%ZZ.mkv".to_string()) ); assert_eq!( filename_from_url_path("https://example.com/files/%2e%2e"), None ); assert_eq!( filename_from_url_path("https://example.com/files/a%2Fb.mkv"), Some("b.mkv".to_string()) ); assert_eq!( filename_from_url_path("https://accounts.google.com/v3/signin/identifier"), None ); assert_eq!( filename_from_url_path("https://mail.google.com/mail/u/0/"), None ); assert_eq!( filename_from_url_path("https://example.com/files/123"), Some("123".to_string()) ); } #[test] fn metadata_rejects_google_sign_in_interstitials() { let message = metadata_authentication_error( "https://accounts.google.com/v3/signin/identifier?continue=https%3A%2F%2Fmail.google.com%2F", Some("text/html; charset=UTF-8"), ) .expect("Google sign-in HTML must not become a download"); assert!(message.contains("Google sign-in page")); assert!(metadata_authentication_error( "https://accounts.google.com/drive/download/report.zip", Some("application/zip"), ) .is_none()); assert!(metadata_authentication_error( "https://downloads.example.com/signin/identifier", Some("text/html"), ) .is_none()); } #[test] fn normalizes_bare_global_speed_limits_as_kib_per_second() { assert_eq!( normalize_speed_limit_for_aria2("1024"), Some("1024K".to_string()) ); assert_eq!( normalize_speed_limit_for_aria2("512K"), Some("512K".to_string()) ); assert_eq!( normalize_speed_limit_for_aria2("1.5 MB/s"), Some("1.5M".to_string()) ); assert_eq!(normalize_speed_limit_for_aria2("0"), None); 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 redacts_spaced_credentials_and_non_http_url_queries() { let line = "token: Bearer spaced secret; ftp://example.com/file?credential=spaced-secret"; let redacted = redact_log_line(line); assert!(!redacted.contains("Bearer spaced secret")); assert!(!redacted.contains("credential=spaced-secret")); assert!(redacted.contains("token=[redacted]")); assert!(redacted.contains("ftp://example.com/file?[redacted]")); } #[test] fn redacts_live_log_output_before_webview_delivery() { let line = "Cookie: session=abc https://example.com/file?signature=secret"; let redacted = redact_log_line_for_output(line); assert!(!redacted.contains("session=abc")); assert!(!redacted.contains("signature=secret")); assert!(redacted.contains("[redacted]")); } #[test] fn redacts_proxy_credentials_pairing_tokens_and_url_fragments() { let line = "Proxy-Authorization: Basic abc\npairing token: pair-secret\nhttp://user:pass@example.com/file#signature=secret"; let redacted = redact_log_line(line); assert!(!redacted.contains("Basic abc")); assert!(!redacted.contains("pair-secret")); assert!(!redacted.contains("user:pass")); assert!(!redacted.contains("signature=secret")); assert!(redacted.contains("http://[redacted]@example.com/file#[redacted]")); } #[test] fn redacts_quoted_json_credentials() { let line = r#"{"api_key":"json-secret","cookie":"session=secret"}"#; let redacted = redact_log_line(line); assert!(!redacted.contains("json-secret")); assert!(!redacted.contains("session=secret")); assert!(redacted.contains("[redacted]")); } #[test] fn collects_primary_url_and_unique_mirrors_in_order() { let uris = collect_download_uris( "https://primary.example/file.zip", Some( "\nhttps://mirror-one.example/file.zip\n\ https://primary.example/file.zip\n\ https://mirror-two.example/file.zip\n", ), ); assert_eq!( uris, vec![ "https://primary.example/file.zip", "https://mirror-one.example/file.zip", "https://mirror-two.example/file.zip", ] ); } #[test] fn parses_valid_firelink_download_urls() { let deep_link = url::Url::parse( "firelink://add?url=https%3A%2F%2Fexample.com%2Fone.zip%0Aftp%3A%2F%2Fexample.com%2Ftwo.zip", ) .unwrap(); assert_eq!( parse_firelink_deep_link(&deep_link), FirelinkDeepLink::Add(vec![ "https://example.com/one.zip".to_string(), "ftp://example.com/two.zip".to_string(), ]) ); } #[test] fn accepts_exact_launch_without_downloads() { let deep_link = url::Url::parse("firelink://launch").unwrap(); assert_eq!( parse_firelink_deep_link(&deep_link), FirelinkDeepLink::Launch ); } #[test] fn rejects_launch_variants_and_nested_schemes() { let links = [ url::Url::parse("firelink://open?url=https%3A%2F%2Fexample.com").unwrap(), url::Url::parse("firelink://add?url=file%3A%2F%2F%2Ftmp%2Fsecret").unwrap(), url::Url::parse("other://add?url=https%3A%2F%2Fexample.com").unwrap(), url::Url::parse("firelink://launch?url=https%3A%2F%2Fexample.com").unwrap(), url::Url::parse("firelink://launch/path").unwrap(), url::Url::parse("firelink://user@launch").unwrap(), url::Url::parse("firelink://add/path?url=https%3A%2F%2Fexample.com").unwrap(), ]; assert!(links .iter() .all(|link| parse_firelink_deep_link(link) == FirelinkDeepLink::Invalid)); } #[test] fn excludes_youtube_storyboard_mhtml_formats() { let storyboard = json!({ "format_id": "sb0", "ext": "mhtml", "protocol": "mhtml", "format_note": "storyboard", "vcodec": "none", "acodec": "none" }); assert!(is_excluded_yt_dlp_format(&storyboard)); } #[test] fn builds_compact_media_options_without_storyboards() { let formats = vec![ json!({ "format_id": "sb0", "ext": "mhtml", "protocol": "mhtml", "format_note": "storyboard", "vcodec": "none", "acodec": "none" }), json!({ "format_id": "137", "ext": "mp4", "height": 1080, "format_note": "1080p", "vcodec": "avc1.640028", "acodec": "none", "filesize": 100_000_000_u64 }), json!({ "format_id": "140", "ext": "m4a", "vcodec": "none", "acodec": "mp4a.40.2", "filesize": 10_000_000_u64 }), ]; let options = build_media_format_options(&formats, Some(600.0)); assert!(!options.iter().any(|format| format.ext == "mhtml")); assert!(!options.iter().any(|format| format.resolution == "Best")); assert!(!options.iter().any(|format| format.resolution == "1440p")); assert!(options.iter().any(|format| { format.resolution == "1080p" && format.ext == "mkv" && format.format_label == "MKV • H.264 + AAC" && format.filesize == Some(110_000_000) })); assert!(options.iter().any(|format| { format.resolution == "1080p" && format.ext == "mp4" && format.format_label == "MP4 • H.264 + AAC" && format.filesize == Some(110_000_000) })); assert!(options.iter().any(|format| { format.resolution == "Audio only" && format.format_label == "M4A • AAC" })); } #[test] fn estimates_missing_video_size_from_bitrate_and_uses_exact_stream_ids() { let formats = vec![ json!({ "format_id": "301", "ext": "mp4", "height": 1080, "fps": 60, "vcodec": "avc1.64002A", "acodec": "none", "tbr": 6_400.0 }), json!({ "format_id": "251", "ext": "webm", "vcodec": "none", "acodec": "opus", "filesize": 28_000_000_u64 }), ]; let options = build_media_format_options(&formats, Some(1_800.0)); let option = options .iter() .find(|format| format.resolution == "1080p" && format.ext == "mkv") .expect("1080p MKV option"); assert_eq!(option.format_id, "301+251"); assert_eq!(option.filesize, None); assert_eq!(option.filesize_approx, Some(1_468_000_000)); } #[test] fn keeps_every_available_video_height_including_nonstandard_qualities() { let formats = vec![ json!({ "format_id": "401", "ext": "webm", "height": 4320, "vcodec": "av01.0.17M.08", "acodec": "none" }), json!({ "format_id": "17", "ext": "mp4", "height": 144, "vcodec": "avc1.42E01E", "acodec": "mp4a.40.2" }), json!({ "format_id": "five-k", "ext": "webm", "resolution": "5120X2880", "vcodec": "av01.0.17M.08", "acodec": "none" }), json!({ "format_id": "pal", "ext": "mp4", "format_note": "Premium 576p", "vcodec": "avc1.4d401f", "acodec": "none" }), ]; let options = build_media_format_options(&formats, Some(60.0)); assert!(options.iter().any(|format| format.resolution == "4320p")); assert!(options.iter().any(|format| format.resolution == "2880p")); assert!(options.iter().any(|format| format.resolution == "576p")); assert!(options.iter().any(|format| format.resolution == "144p")); } #[test] fn returns_no_media_options_for_webpage_only_metadata() { let formats = vec![json!({ "format_id": "0", "ext": "html", "protocol": "https", "format": "default webpage", "vcodec": "none", "acodec": "none" })]; assert!(build_media_format_options(&formats, Some(30.0)).is_empty()); } #[test] fn classifies_browser_cookie_database_errors() { assert!(is_browser_cookie_extraction_error( "ERROR: Could not copy Chrome cookie database. See https://github.com/yt-dlp/yt-dlp/issues/7271" )); assert!(is_browser_cookie_extraction_error( "failed to read browser cookie data" )); assert!(!is_browser_cookie_extraction_error( "ERROR: Sign in to confirm you are not a bot" )); assert!(!is_browser_cookie_extraction_error( "ERROR: requested format is not available" )); } #[test] fn retries_once_without_browser_cookies_only_for_cookie_database_failures() { let cookie_database_error = "ERROR: Could not copy Chrome cookie database. See https://github.com/yt-dlp/yt-dlp/issues/7271"; assert!(should_retry_without_browser_cookies( Some("chrome"), cookie_database_error, false )); assert!(!should_retry_without_browser_cookies( Some("chrome"), cookie_database_error, true )); assert!(!should_retry_without_browser_cookies( Some("none"), cookie_database_error, false )); assert!(!should_retry_without_browser_cookies( None, cookie_database_error, false )); assert!(!should_retry_without_browser_cookies( Some("chrome"), "ERROR: Sign in to confirm you are not a bot", false )); } #[test] #[ignore = "requires network and a local yt-dlp executable"] fn filters_live_youtube_metadata_from_env() { let url = std::env::var("FIRELINK_LIVE_YOUTUBE_URL") .expect("set FIRELINK_LIVE_YOUTUBE_URL to a YouTube watch URL"); let output = std::process::Command::new("yt-dlp") .args([ "--dump-json", "--no-warnings", "--no-playlist", "--socket-timeout", "20", "--retries", "3", "--extractor-retries", "3", "--compat-options", "no-youtube-unavailable-videos", "--", &url, ]) .output() .expect("failed to run yt-dlp"); assert!( output.status.success(), "yt-dlp failed: {}", String::from_utf8_lossy(&output.stderr) ); let value: serde_json::Value = serde_json::from_slice(&output.stdout).expect("yt-dlp did not emit valid JSON"); let formats = value .get("formats") .and_then(|v| v.as_array()) .expect("yt-dlp JSON did not include formats"); let raw_mhtml_count = formats .iter() .filter(|format| json_lower(format, "ext") == "mhtml") .count(); let duration = value.get("duration").and_then(|duration| duration.as_f64()); let options = build_media_format_options(formats, duration); eprintln!( "raw formats: {}, raw mhtml: {}, normalized options: {}", formats.len(), raw_mhtml_count, options.len() ); assert!(!options.is_empty()); assert!(options.iter().all(|format| format.ext != "mhtml")); assert!(options .iter() .all(|format| !format.format_label.to_lowercase().contains("mhtml"))); } #[test] fn parses_structured_ytdlp_progress() { let line = format!( "{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,\"total_bytes\":10485760,\"speed\":1048576,\"eta\":5,\"_speed_str\":\"1.00MiB/s\",\"_eta_str\":\"00:05\",\"_total_bytes_str\":\"10.00MiB\"}}" ); assert_eq!( parse_media_progress_line(&line), Some(MediaProgress { fraction: 0.5, speed: "1.00MiB/s".to_string(), eta: "00:05".to_string(), size: Some("10.00MiB".to_string()), downloaded_bytes: Some(5242880.0), total_bytes: Some(10485760.0), total_is_estimate: false, }) ); } #[test] fn keeps_structured_estimated_total_out_of_stable_byte_progress() { let line = format!( "{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,\"total_bytes\":null,\"total_bytes_estimate\":10485760,\"_total_bytes_estimate_str\":\"~10.00MiB\"}}" ); assert_eq!( parse_media_progress_line(&line), Some(MediaProgress { fraction: 0.5, speed: "-".to_string(), eta: "-".to_string(), size: None, downloaded_bytes: Some(5242880.0), total_bytes: None, total_is_estimate: true, }) ); } #[test] fn ignores_estimated_media_totals_without_fragment_metadata() { let line = format!( "{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":512,\"total_bytes_estimate\":1024,\"_percent_str\":\"50.0%\"}}" ); let progress = parse_media_progress_line(&line).expect("structured progress should parse"); assert_eq!(progress.fraction, 0.5); assert_eq!(progress.size, None); assert_eq!(progress.downloaded_bytes, Some(512.0)); assert_eq!(progress.total_bytes, None); assert!(progress.total_is_estimate); } #[test] fn parses_chunked_structured_ytdlp_progress() { let mut buffer = String::new(); let first = format!("{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,"); let second = "\"total_bytes\":10485760,\"_percent\":50.0,\"_speed_str\":\"1.00MiB/s\"}\n"; assert!(drain_media_output_lines(&mut buffer, &first).is_empty()); let lines = drain_media_output_lines(&mut buffer, second); assert_eq!(lines.len(), 1); assert_eq!( parse_media_progress_line(&lines[0]).map(|progress| progress.fraction), Some(0.5) ); assert!(buffer.is_empty()); } #[test] fn parses_structured_ytdlp_numeric_percent_without_total() { let line = format!( "{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,\"_percent\":37.5,\"_speed_str\":\"1.00MiB/s\"}}" ); assert_eq!( parse_media_progress_line(&line).map(|progress| progress.fraction), Some(0.375) ); } #[test] fn parses_ffmpeg_snapshot_version_without_collapsing_to_n() { let output = "ffmpeg version N-125385-ge2e889d9da-https://www.martin-riedl.de Copyright (c) 2000-2026 the FFmpeg developers"; assert_eq!( parse_ffmpeg_version(output), Some("N-125385-ge2e889d9da".to_string()) ); } #[test] fn parses_ffmpeg_release_version_without_build_suffix() { let output = "ffmpeg version 8.1.2-static https://example.invalid Copyright (c) 2000-2026 the FFmpeg developers"; assert_eq!(parse_ffmpeg_version(output), Some("8.1.2".to_string())); } #[test] fn uses_fragment_progress_instead_of_temporary_hls_size_estimates() { let line = format!( "{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":1024,\"total_bytes_estimate\":1024,\"fragment_index\":0,\"fragment_count\":354,\"_percent_str\":\"100.0%\"}}" ); let progress = parse_media_progress_line(&line).expect("structured progress should parse"); assert_eq!(progress.fraction, 0.0); assert_eq!(progress.size, None); assert_eq!(progress.total_bytes, None); assert!(progress.total_is_estimate); } #[test] fn advances_tracks_only_after_a_completed_track_restarts() { let mut current_track = 0.0; let mut last_fraction = 0.0; assert_eq!( aggregate_media_fraction(2.0, &mut current_track, &mut last_fraction, 0.5), 0.25 ); assert_eq!( aggregate_media_fraction(2.0, &mut current_track, &mut last_fraction, 1.0), 0.5 ); assert_eq!( aggregate_media_fraction(2.0, &mut current_track, &mut last_fraction, 0.0), 0.5 ); assert_eq!( aggregate_media_fraction(2.0, &mut current_track, &mut last_fraction, 0.4), 0.7 ); assert_eq!(current_track, 1.0); } #[test] fn aggregates_media_byte_progress_across_tracks() { let mut state = MediaProgressEmitterState::new(); let first = MediaProgress { fraction: 0.99, speed: "-".to_string(), eta: "-".to_string(), size: Some("100B".to_string()), downloaded_bytes: Some(99.0), total_bytes: Some(100.0), total_is_estimate: false, }; let second = MediaProgress { fraction: 0.01, speed: "-".to_string(), eta: "-".to_string(), size: Some("200B".to_string()), downloaded_bytes: Some(2.0), total_bytes: Some(200.0), total_is_estimate: true, }; aggregate_media_fraction( 2.0, &mut state.current_track, &mut state.last_fraction, first.fraction, ); assert_eq!( aggregate_media_byte_progress(&first, false, &mut state), Some((99, 100, false)) ); aggregate_media_fraction( 2.0, &mut state.current_track, &mut state.last_fraction, second.fraction, ); assert_eq!( aggregate_media_byte_progress(&second, true, &mut state), Some((101, 300, true)) ); } #[test] fn preserves_single_track_totals_when_byte_aggregation_is_unavailable() { let progress = MediaProgress { fraction: 0.5, speed: "-".to_string(), eta: "-".to_string(), size: Some("~2.00 KB".to_string()), downloaded_bytes: Some(1024.0), total_bytes: Some(2048.0), total_is_estimate: true, }; assert_eq!( media_progress_event_totals(&progress, None, 1.0), (Some(2048.0), Some(true)) ); assert_eq!( media_progress_event_totals(&progress, None, 2.0), (None, None) ); assert_eq!( media_progress_event_totals(&progress, Some((1024, 4096, false)), 1.0), (Some(4096.0), Some(false)) ); } #[test] fn freezes_a_media_track_estimate_across_progress_updates() { let mut state = MediaProgressEmitterState::new(); let first = MediaProgress { fraction: 0.25, speed: "-".to_string(), eta: "-".to_string(), size: Some("~100B".to_string()), downloaded_bytes: Some(25.0), total_bytes: Some(100.0), total_is_estimate: true, }; let later = MediaProgress { fraction: 0.75, speed: "-".to_string(), eta: "-".to_string(), size: Some("~200B".to_string()), downloaded_bytes: Some(150.0), total_bytes: Some(200.0), total_is_estimate: true, }; assert_eq!( aggregate_media_byte_progress(&first, false, &mut state), Some((25, 100, true)) ); assert_eq!( aggregate_media_byte_progress(&later, false, &mut state), Some((150, 100, true)) ); let exact = MediaProgress { fraction: 0.9, speed: "-".to_string(), eta: "-".to_string(), size: Some("200B".to_string()), downloaded_bytes: Some(180.0), total_bytes: Some(200.0), total_is_estimate: false, }; assert_eq!( aggregate_media_byte_progress(&exact, false, &mut state), Some((180, 200, false)) ); } #[test] fn preserves_estimated_bytes_when_a_split_track_exceeds_its_estimate() { let mut state = MediaProgressEmitterState::new(); let first = MediaProgress { fraction: 0.75, speed: "-".to_string(), eta: "-".to_string(), size: Some("~100B".to_string()), downloaded_bytes: Some(150.0), total_bytes: Some(100.0), total_is_estimate: true, }; let second = MediaProgress { fraction: 0.01, speed: "-".to_string(), eta: "-".to_string(), size: Some("50B".to_string()), downloaded_bytes: Some(1.0), total_bytes: Some(50.0), total_is_estimate: false, }; assert_eq!( aggregate_media_byte_progress(&first, false, &mut state), Some((150, 100, true)) ); assert_eq!( aggregate_media_byte_progress(&second, true, &mut state), Some((151, 150, true)) ); } #[test] fn derives_main_window_speed_from_downloaded_byte_delta() { let first = MediaProgress { fraction: 0.25, speed: "fallback".to_string(), eta: "-".to_string(), size: None, downloaded_bytes: Some(1_000_000.0), total_bytes: None, total_is_estimate: false, }; let second = MediaProgress { fraction: 0.5, speed: "fallback".to_string(), eta: "-".to_string(), size: None, downloaded_bytes: Some(3_097_152.0), total_bytes: None, total_is_estimate: false, }; let start = Instant::now(); let mut sampler = MediaSpeedSampler::default(); assert_eq!( media_progress_speed(&first, start, &mut sampler), ("fallback".to_string(), "-".to_string()) ); assert_eq!( media_progress_speed(&second, start + Duration::from_secs(1), &mut sampler), ("2.0 MB/s".to_string(), "1s".to_string()) ); } #[test] fn smooths_media_speed_across_short_stalls() { let start = Instant::now(); let mut sampler = MediaSpeedSampler::default(); let mut progress = MediaProgress { fraction: 0.25, speed: "-".to_string(), eta: "-".to_string(), size: None, downloaded_bytes: Some(1_000_000.0), total_bytes: None, total_is_estimate: false, }; assert_eq!( media_progress_speed(&progress, start, &mut sampler), ("-".to_string(), "-".to_string()) ); progress.fraction = 0.5; progress.downloaded_bytes = Some(3_000_000.0); assert_eq!( media_progress_speed(&progress, start + Duration::from_secs(1), &mut sampler), ("1.9 MB/s".to_string(), "2s".to_string()) ); progress.downloaded_bytes = Some(3_000_000.0); assert_eq!( media_progress_speed(&progress, start + Duration::from_secs(2), &mut sampler), ("976.6 KB/s".to_string(), "3s".to_string()) ); } #[test] fn parses_aria2_external_downloader_progress() { let line = "[#2d2636 12MiB/34MiB(34%) CN:1 DL:910KiB ETA:25s]"; assert_eq!( parse_media_progress_line(line), Some(MediaProgress { fraction: 0.34, speed: "910KiB/s".to_string(), eta: "25s".to_string(), size: Some("34MiB".to_string()), downloaded_bytes: Some(12.0 * 1024.0 * 1024.0), total_bytes: Some(34.0 * 1024.0 * 1024.0), total_is_estimate: false, }) ); } #[test] fn retains_legacy_ytdlp_progress_fallback() { let line = "[download] 42.5% of ~10.00MiB at 2.00MiB/s ETA 00:03"; assert_eq!( parse_media_progress_line(line), Some(MediaProgress { fraction: 0.425, speed: "2.00MiB/s".to_string(), eta: "00:03".to_string(), size: Some("10.00MiB".to_string()), downloaded_bytes: Some(4456448.0), total_bytes: Some(10485760.0), total_is_estimate: true, }) ); } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Aria2RecoveryReason { ZeroProgress, SlowThroughput, ConnectionPoolCollapse, } impl Aria2RecoveryReason { fn as_str(self) -> &'static str { match self { Self::ZeroProgress => "zero-stall", Self::SlowThroughput => "slow-throughput", Self::ConnectionPoolCollapse => "connection-collapse", } } } #[derive(Default)] struct Aria2ConnectionObservation { gid: String, control_epoch: u64, saw_multiple_connections: bool, healthy_speed_samples: u8, degraded_since: Option, no_progress_since: Option, last_refreshed_at: Option, peak_speed_bytes: f64, last_completed: u64, } struct Aria2ConnectionSample<'a> { gid: &'a str, control_epoch: u64, status: &'a str, total: u64, completed: u64, speed_bytes: f64, active_connections: i32, requested_connections: i32, speed_limited: bool, now: Instant, } const ARIA2_CONNECTION_RECOVERY_DELAY: Duration = Duration::from_secs(30); const ARIA2_CONNECTION_RECOVERY_COOLDOWN: Duration = Duration::from_secs(45); const ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024; const ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY: f64 = 64.0 * 1024.0; const ARIA2_DEGRADED_SPEED_FRACTION: f64 = 0.20; const ARIA2_MIN_HEALTHY_SPEED_SAMPLES: u8 = 3; #[cfg(test)] // Keep the test scenarios explicit while the production path uses the typed sample. #[allow(clippy::too_many_arguments)] fn observe_aria2_connections( observation: &mut Aria2ConnectionObservation, gid: &str, status: &str, total: u64, completed: u64, speed_bytes: f64, active_connections: i32, requested_connections: i32, speed_limited: bool, now: Instant, ) -> Option { observe_aria2_connections_with_epoch( observation, Aria2ConnectionSample { gid, control_epoch: 0, status, total, completed, speed_bytes, active_connections, requested_connections, speed_limited, now, }, ) } fn observe_aria2_connections_with_epoch( observation: &mut Aria2ConnectionObservation, sample: Aria2ConnectionSample<'_>, ) -> Option { let Aria2ConnectionSample { gid, control_epoch, status, total, completed, speed_bytes, active_connections, requested_connections, speed_limited, now, } = sample; if observation.gid != gid || observation.control_epoch != control_epoch { *observation = Aria2ConnectionObservation { gid: gid.to_string(), control_epoch, last_completed: completed, ..Default::default() }; } let remaining = total.saturating_sub(completed); let mut reason = None; if status == "active" && total > completed { let speed_bytes = speed_bytes.max(0.0); // A decaying peak keeps the trigger sensitive to a recent healthy // rate without permanently comparing against a brief startup burst. observation.peak_speed_bytes = (observation.peak_speed_bytes * 0.995).max(speed_bytes); if completed > observation.last_completed { observation.no_progress_since = None; } else if speed_bytes <= 0.0 { let stalled_since = observation.no_progress_since.get_or_insert(now); if now.duration_since(*stalled_since) >= ARIA2_CONNECTION_RECOVERY_DELAY { reason = Some(Aria2RecoveryReason::ZeroProgress); } } else { observation.no_progress_since = None; } let multi_connection_candidate = requested_connections > 1 && remaining >= ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY; if multi_connection_candidate { if active_connections > 1 && speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY && speed_bytes >= observation.peak_speed_bytes * 0.5 { observation.healthy_speed_samples = observation .healthy_speed_samples .saturating_add(1); } let slow_throughput = !speed_limited && observation.saw_multiple_connections && observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES && observation.peak_speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY && speed_bytes > 0.0 && speed_bytes < observation.peak_speed_bytes * ARIA2_DEGRADED_SPEED_FRACTION; if slow_throughput { let degraded_since = observation.degraded_since.get_or_insert(now); if now.duration_since(*degraded_since) >= ARIA2_CONNECTION_RECOVERY_DELAY && reason.is_none() { reason = Some(Aria2RecoveryReason::SlowThroughput); } } else { observation.degraded_since = None; } if active_connections > 1 { observation.saw_multiple_connections = true; } else if !speed_limited && observation.saw_multiple_connections { let degraded_since = observation.degraded_since.get_or_insert(now); if now.duration_since(*degraded_since) >= ARIA2_CONNECTION_RECOVERY_DELAY && reason.is_none() { reason = Some(Aria2RecoveryReason::ConnectionPoolCollapse); } } } else { observation.degraded_since = None; } } else { observation.degraded_since = None; observation.no_progress_since = None; } observation.last_completed = completed; let recovery_allowed = observation.last_refreshed_at.is_none_or(|last| { now.duration_since(last) >= ARIA2_CONNECTION_RECOVERY_COOLDOWN }); if !recovery_allowed { return None; } if let Some(reason) = reason { observation.last_refreshed_at = Some(now); // Start a fresh observation window after every attempt. This permits // repeated healing while preventing a refresh loop every poll tick. match reason { Aria2RecoveryReason::ZeroProgress => observation.no_progress_since = Some(now), Aria2RecoveryReason::SlowThroughput | Aria2RecoveryReason::ConnectionPoolCollapse => { observation.degraded_since = Some(now) } } Some(reason) } else { None } } static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); static LOG_STREAM_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); #[tauri::command] fn toggle_log_pause(pause: bool) { LOG_PAUSED.store(pause, std::sync::atomic::Ordering::Relaxed); } #[tauri::command] fn is_log_paused() -> bool { LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed) } #[tauri::command] fn set_log_stream_active(active: bool) { LOG_STREAM_ACTIVE.store(active, std::sync::atomic::Ordering::Release); } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { ensure_reqwest_crypto_provider(); let storage_mode = crate::storage::StorageMode::detect(); let setup_storage_mode = storage_mode.clone(); let log_target = match &storage_mode { crate::storage::StorageMode::Standard => tauri_plugin_log::Target::new( tauri_plugin_log::TargetKind::LogDir { file_name: None }, ), crate::storage::StorageMode::Portable { root } => tauri_plugin_log::Target::new( tauri_plugin_log::TargetKind::Folder { path: root.join("data").join("logs"), file_name: None, }, ), }; let extension_pairing_token = Arc::new(RwLock::new(String::new())); let server_pairing_token = extension_pairing_token.clone(); let extension_frontend_ready = Arc::new(AtomicBool::new(false)); let server_frontend_ready = extension_frontend_ready.clone(); let extension_acks = Arc::new(Mutex::new(HashMap::new())); let server_extension_acks = extension_acks.clone(); let extension_server_port = Arc::new(RwLock::new(None)); let server_extension_port = extension_server_port.clone(); let (extension_server_shutdown_tx, extension_server_shutdown_rx) = tokio::sync::watch::channel(false); let initial_aria2_port = 6800; // Will be determined dynamically in background let aria2_port = Arc::new(std::sync::atomic::AtomicU16::new(initial_aria2_port)); let aria2_port_clone = Arc::clone(&aria2_port); let aria2_secret = uuid::Uuid::new_v4().to_string(); tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); window.show().unwrap(); window.set_focus().unwrap(); } })) .plugin(tauri_plugin_deep_link::init()) .manage(Aria2DaemonGuard::new()) .setup(move |app| { let storage_layout = crate::storage::StorageLayout::resolve( app.handle(), setup_storage_mode.clone(), )?; let main_window_config = app .config() .app .windows .iter() .find(|window| window.label == "main") .cloned() .ok_or_else(|| "main window configuration is missing".to_string())?; let mut main_window_builder = tauri::WebviewWindowBuilder::from_config( app.handle(), &main_window_config, ) .map_err(|error| format!("failed to prepare main window: {error}"))?; if storage_layout.is_portable() { main_window_builder = main_window_builder.data_directory(storage_layout.webview_dir().to_path_buf()); } let mut sys = sysinfo::System::new_all(); sys.refresh_all(); log::info!("=== System Information ==="); log::info!("OS: {} {}", sysinfo::System::name().unwrap_or_else(|| "Unknown".to_string()), sysinfo::System::os_version().unwrap_or_else(|| "Unknown".to_string())); let arch = sysinfo::System::cpu_arch(); log::info!("Architecture: {}", if arch.is_empty() { "Unknown" } else { &arch }); log::info!("CPU: {} ({} cores)", sys.cpus().first().map(|c| c.brand()).unwrap_or("Unknown"), sys.cpus().len()); log::info!("Memory: {} MB total", sys.total_memory() / 1024 / 1024); log::info!("App Version: {}", env!("CARGO_PKG_VERSION")); log::info!("=========================="); build_main_tray(app.handle()) .map_err(|error| format!("failed to create tray menu: {error}"))?; let database = crate::db::init(&storage_layout) .map_err(|error| format!("failed to initialize persistence: {error}"))?; let initial_pairing_token = { // Generate a temporary session token for the extension server on startup. // The frontend will hydrate the real token via IPC once it mounts, // avoiding any macOS system prompts before the UI is fully visible. format!( "{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple() ) }; { let mut pairing_token = extension_pairing_token .write() .map_err(|_| "extension pairing token lock is unavailable".to_string())?; *pairing_token = initial_pairing_token; } app.manage(database); let persisted_settings = crate::settings::load_settings(app.handle()).ok(); let logs_enabled = persisted_settings .as_ref() .is_some_and(|settings| settings.logs_enabled); LOG_PAUSED.store(!logs_enabled, std::sync::atomic::Ordering::Relaxed); if logs_enabled { log::info!("=== System Information ==="); log::info!( "OS: {} {}", sysinfo::System::name().unwrap_or_else(|| "Unknown".to_string()), sysinfo::System::os_version().unwrap_or_else(|| "Unknown".to_string()) ); let arch = sysinfo::System::cpu_arch(); log::info!( "Architecture: {}", if arch.is_empty() { "Unknown" } else { &arch } ); log::info!( "CPU: {} ({} cores)", sys.cpus().first().map(|c| c.brand()).unwrap_or("Unknown"), sys.cpus().len() ); log::info!("Memory: {} MB total", sys.total_memory() / 1024 / 1024); log::info!("App Version: {}", env!("CARGO_PKG_VERSION")); log::info!("=========================="); } let max_concurrent = { persisted_settings .as_ref() .map(|settings| settings.max_concurrent_downloads) .unwrap_or(crate::queue::DEFAULT_MAX_CONCURRENT) }; let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone())); let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent)); let initial_global_speed_limit = persisted_settings .as_ref() .and_then(|settings| normalize_speed_limit_for_aria2(&settings.global_speed_limit)); queue_manager.set_aria2_global_speed_limit(initial_global_speed_limit); let dispatcher_mgr = Arc::clone(&queue_manager); tauri::async_runtime::spawn(async move { dispatcher_mgr.run_dispatcher().await; }); let queue_manager_poll = Arc::clone(&queue_manager); app.manage(AppState { download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()), storage_layout, keychain_access_authorized: Arc::new(AtomicBool::new(false)), keychain_grant_in_progress: Arc::new(AtomicBool::new(false)), extension_pairing_token, extension_frontend_ready, extension_acks, extension_server_port, extension_server_shutdown: extension_server_shutdown_tx.clone(), aria2_port: aria2_port.clone(), aria2_secret: aria2_secret.clone(), media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), sleep_preventer: Arc::new(Mutex::new(None)), scheduler_settings: Arc::clone(&scheduler_settings), queue_manager, }); // Build the window only after all command state is registered. This // prevents the frontend from racing startup and invoking IPC before // the database and portable storage layout are available. main_window_builder .build() .map_err(|error| format!("failed to create main window: {error}"))?; #[cfg(target_os = "windows")] if let Some(window) = app.get_webview_window("main") { window .set_decorations(false) .map_err(|error| format!("failed to disable Windows native frame: {error}"))?; } let deep_link_app = app.handle().clone(); #[cfg(target_os = "linux")] if let Err(error) = app.deep_link().register_all() { log::warn!("Could not register firelink:// handler: {error}"); } app.deep_link().on_open_url(move |event| { dispatch_deep_links(deep_link_app.clone(), event.urls()); }); match app.deep_link().get_current() { Ok(Some(urls)) => dispatch_deep_links(app.handle().clone(), urls), Ok(None) => {} Err(error) => eprintln!("Failed to read startup deep link: {error}"), } crate::scheduler::spawn_scheduler(app.handle().clone(), scheduler_settings); let global_speed_limit = persisted_settings .as_ref() .map(|settings| settings.global_speed_limit.clone()) .unwrap_or_default(); let aria2_secret_clone = aria2_secret.clone(); let app_handle_bg = app.handle().clone(); tauri::async_runtime::spawn(async move { let mut ws_port = 6800; match resolve_bundled_binary_path(&app_handle_bg, "aria2c") { Ok(binary_path) => { let mut success = false; for attempt_port in 6800..6900 { let mut cmd = std::process::Command::new(&binary_path); crate::platform::hide_child_console(&mut cmd); crate::engines::apply_aria2_environment(&mut cmd, &binary_path); let mut config_file = tempfile::Builder::new().prefix("aria2-").suffix(".conf").tempfile().expect("failed to create aria2 config file"); use std::io::Write; let config_content = format!("rpc-secret={}\n", aria2_secret_clone); config_file.write_all(config_content.as_bytes()).expect("failed to write aria2 config file"); let config_path = config_file.into_temp_path(); cmd.arg("--enable-rpc=true") .arg(format!("--conf-path={}", config_path.display())) .arg(format!("--rpc-listen-port={}", attempt_port)) .arg("--rpc-listen-all=false") .arg("--continue=true") .arg("--retry-wait=2") .arg("--allow-overwrite=false") .arg("--summary-interval=1") .arg("--console-log-level=warn") .arg("--download-result=hide") .arg("--max-concurrent-downloads=9999") .arg("--check-certificate=true") .arg(format!("--stop-with-process={}", std::process::id())); if let Some(limit) = normalize_speed_limit_for_aria2(&global_speed_limit) { cmd.arg(format!("--max-overall-download-limit={}", limit)); } cmd.stdout(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::piped()); match cmd.spawn() { Ok(mut child) => { // Give it a moment to fail if port is in use tokio::time::sleep(std::time::Duration::from_millis(200)).await; if let Ok(Some(_)) = child.try_wait() { // Process exited, likely port collision, try next continue; } log::info!("aria2c spawned successfully on port {}", attempt_port); aria2_port_clone.store(attempt_port, std::sync::atomic::Ordering::Relaxed); ws_port = attempt_port; success = true; let daemon_app = app_handle_bg.clone(); if let Some(stderr) = child.stderr.take() { std::thread::spawn(move || { use std::io::BufRead; let reader = std::io::BufReader::new(stderr); for line in reader.lines().map_while(Result::ok) { let trimmed = line.trim().to_string(); if let Ok(mut stderr_lock) = daemon_app.state::().last_stderr.lock() { stderr_lock.push_str(&trimmed); stderr_lock.push('\n'); let excess = stderr_lock.len().saturating_sub(8192); if excess > 0 { let _ = stderr_lock.drain(..excess); } } let lower = trimmed.to_lowercase(); if lower.contains("error") || lower.contains("critical") { log::error!("aria2c stderr: {}", trimmed); } } }); } let guard = app_handle_bg.state::(); *guard.child.lock().unwrap() = Some(child); *guard.config_path.lock().unwrap() = Some(config_path); let mut last_err = String::new(); let start = std::time::Instant::now(); let mut ready = false; while start.elapsed() < std::time::Duration::from_secs(5) { match rpc_call(attempt_port, &aria2_secret_clone, "aria2.getVersion", serde_json::json!([])).await { Ok(ver) => { let v = ver.get("version").and_then(|v| v.as_str()).unwrap_or("unknown"); log::info!("aria2 daemon ready (version {}) on port {}", v, attempt_port); ready = true; break; } Err(e) => { last_err = e; tokio::time::sleep(std::time::Duration::from_millis(100)).await; } } } if !ready { let err = if last_err.is_empty() { "aria2 daemon did not become ready within 5 seconds".to_string() } else { format!("aria2 did not become ready: {last_err}") }; log::error!("{}", err); *guard.startup_error.lock().unwrap() = Some(err); } break; } Err(e) => { log::error!("Failed to spawn aria2c: {}", e); let guard = app_handle_bg.state::(); *guard.startup_error.lock().unwrap() = Some(format!("Failed to spawn aria2c: {e}")); break; } } } if !success { let guard = app_handle_bg.state::(); *guard.startup_error.lock().unwrap() = Some("Failed to find open port for aria2c".to_string()); } } Err(e) => { log::error!("Failed to resolve aria2c binary: {}", e); let guard = app_handle_bg.state::(); *guard.startup_error.lock().unwrap() = Some(format!("Failed to resolve aria2c: {e}")); } } let mut ws_retries = 0; loop { if ws_retries == 10 { log::error!("Aria2 WebSocket is still unavailable after repeated reconnection attempts; continuing to retry."); let guard = app_handle_bg.state::(); *guard.startup_error.lock().unwrap() = Some("Max WebSocket reconnection attempts reached.".to_string()); // Keep retrying. The daemon or local WebSocket can // recover after a prolonged outage; permanently // stopping this task strands active transfers. ws_retries = 0; } tokio::time::sleep(std::time::Duration::from_secs(2)).await; let ws_url = format!("ws://127.0.0.1:{}/jsonrpc", ws_port); if let Ok((ws_stream, _)) = tokio_tungstenite::connect_async(&ws_url).await { ws_retries = 0; // reset on success if let Ok(mut startup_error) = app_handle_bg .state::() .startup_error .lock() { if startup_error.as_deref() == Some("Max WebSocket reconnection attempts reached.") { *startup_error = None; } } reconcile_aria2_downloads(&app_handle_bg).await; use futures_util::StreamExt; let (_, mut read) = ws_stream.split(); while let Some(msg) = read.next().await { if let Ok(tokio_tungstenite::tungstenite::Message::Text(text)) = msg { if let Ok(json) = serde_json::from_str::(&text) { if let Some(method) = json.get("method").and_then(|m| m.as_str()) { if let Some(params) = json.get("params").and_then(|p| p.as_array()) { if let Some(event) = params.first().and_then(|p| p.as_object()) { if let Some(gid) = event.get("gid").and_then(|g| g.as_str()) { let state = app_handle_bg.state::(); let outcome = match method { "aria2.onDownloadComplete" => Some(crate::queue::PendingOutcome::Complete), "aria2.onDownloadError" => { let mut msg = event.get("error_message").and_then(|m| m.as_str()).unwrap_or("aria2 download error").to_string(); let aria2_port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); let aria2_secret = state.aria2_secret.clone(); if let Ok(status) = rpc_call(aria2_port, &aria2_secret, "aria2.tellStatus", serde_json::json!([gid, ["errorCode", "errorMessage"]])).await { let err_msg = status .get("errorMessage") .and_then(|m| m.as_str()) .filter(|m| !m.is_empty()); let err_code = status .get("errorCode") .and_then(|m| m.as_str()) .filter(|m| !m.is_empty()); match (err_code, err_msg) { (Some(code), Some(message)) => { msg = format!("aria2 error code {code}: {message}"); } (Some(code), None) => { msg = format!("aria2 error code {code}: {msg}"); } (None, Some(message)) => { msg = message.to_string(); } (None, None) => {} } } Some(crate::queue::PendingOutcome::Error(msg)) } _ => None, }; if let Some(outcome) = outcome { Arc::clone(&state.queue_manager) .handle_aria2_event(gid, outcome) .await; } } } } } } } } } ws_retries += 1; let state = app_handle_bg.state::(); let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); if !aria2_daemon_is_reachable(port, &state.aria2_secret).await && aria2_daemon_process_exited(&app_handle_bg) { state.queue_manager.clear_aria2_permits().await; } tokio::time::sleep(std::time::Duration::from_secs(4)).await; } }); let app_handle_poll = app.handle().clone(); let poll_port = aria2_port.clone(); let poll_secret = aria2_secret.clone(); let poll_mgr = Arc::clone(&queue_manager_poll); tauri::async_runtime::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); let mut observations: HashMap = HashMap::new(); loop { interval.tick().await; // Terminal cleanup removes a download's GID mapping. Do // not retain one observation per historical download for // the lifetime of the poller. let mapped_ids: HashSet = poll_mgr .aria2_gid_mappings() .into_iter() .map(|(_, id)| id) .collect(); observations.retain(|id, _| mapped_ids.contains(id)); let params = serde_json::json!([["gid", "status", "totalLength", "completedLength", "downloadSpeed", "connections", "errorMessage"]]); if let Ok(active_list) = rpc_call(poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellActive", params).await { if let Some(active_arr) = active_list.as_array() { let mut seen_ids = HashSet::new(); let mut seen_gids = HashSet::new(); for status_info in active_arr { let gid = status_info.get("gid").and_then(|s| s.as_str()).unwrap_or(""); let id = poll_mgr .aria2_gids .read() .unwrap() .get(gid) .map(|mapping| mapping.id.clone()); if let Some(id) = id { seen_ids.insert(id.clone()); seen_gids.insert(gid.to_string()); let status = status_info.get("status").and_then(|value| value.as_str()).unwrap_or(""); let total = status_info.get("totalLength").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); let completed = status_info.get("completedLength").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0.0); let active_connections = status_info.get("connections").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); let requested_connections = poll_mgr .aria2_requested_connections(&id) .await .unwrap_or(1) .max(1); let speed_limited = poll_mgr.aria2_speed_limited(&id).await; let control_epoch = poll_mgr.current_aria2_control_epoch(&id).await; let now = Instant::now(); let observation = observations.entry(id.clone()).or_default(); let recovery_reason = observe_aria2_connections_with_epoch( observation, Aria2ConnectionSample { gid, control_epoch, status, total, completed, speed_bytes, active_connections, requested_connections, speed_limited, now, }, ); let fraction = if total > 0 { completed as f64 / total as f64 } else { 0.0 }; let speed = crate::download::format_speed(speed_bytes); let eta = if speed_bytes > 0.0 && total > completed { crate::download::format_duration((total - completed) as f64 / speed_bytes) } else { "-".to_string() }; let size = if total > 0 { Some(crate::download::format_size(total as f64)) } else { None }; use tauri::Emitter; let _ = app_handle_poll.emit("download-progress", DownloadProgressEvent { id: id.clone(), fraction, speed, eta, size, size_is_final: false, downloaded_bytes: Some(completed as f64), total_bytes: (total > 0).then_some(total as f64), total_is_estimate: Some(false), }); if let Some(reason) = recovery_reason { log::warn!( "aria2 connection recovery [{}]: gid {} reason={} speed={}B/s active_connections={} requested_connections={}", id, gid, reason.as_str(), speed_bytes, active_connections, requested_connections ); if let Err(error) = poll_mgr .refresh_aria2_connections(&id, gid, control_epoch) .await { log::warn!( "aria2 connection recovery [{}] for gid {} failed: {}", id, gid, error ); } } } } // A completion notification can be lost while the // WebSocket is reconnecting. Completed GIDs also // disappear from tellActive, so reconcile mapped // GIDs that were not present in this active list. // Paused and waiting downloads are intentionally // left alone; only terminal statuses are applied. for (gid, id) in poll_mgr.aria2_gid_mappings() { if seen_gids.contains(&gid) { continue; } let status = match rpc_call( poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellStatus", serde_json::json!([gid, ["status", "errorCode", "errorMessage"]]), ) .await { Ok(status) => status, Err(error) => { log::debug!( "aria2 poller reconciliation [{}]: could not query gid {}: {}", id, gid, error ); continue; } }; let status_name = status .get("status") .and_then(|value| value.as_str()) .unwrap_or(""); let outcome = match status_name { "complete" => Some(crate::queue::PendingOutcome::Complete), "error" | "removed" => { let error_code = status .get("errorCode") .and_then(|value| value.as_str()) .filter(|value| !value.is_empty()); let error_message = status .get("errorMessage") .and_then(|value| value.as_str()) .filter(|value| !value.is_empty()) .unwrap_or("aria2 download ended outside the active poll"); Some(crate::queue::PendingOutcome::Error(match error_code { Some(code) => format!("aria2 error code {code}: {error_message}"), None => error_message.to_string(), })) } _ => None, }; if let Some(outcome) = outcome { log::info!( "aria2 poller reconciliation [{}]: gid {} reported terminal status {} outside tellActive", id, gid, status_name ); poll_mgr.handle_aria2_event(&gid, outcome).await; } } observations.retain(|id, _| seen_ids.contains(id)); } } } }); let ext_app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { while let Err(error) = extension_server::start_server( ext_app_handle.clone(), server_pairing_token.clone(), server_frontend_ready.clone(), server_extension_acks.clone(), server_extension_port.clone(), extension_server_shutdown_rx.clone(), ).await { log::error!("Browser extension server unavailable: {error}"); tokio::time::sleep(std::time::Duration::from_secs(10)).await; } }); Ok(()) }) .plugin( tauri_plugin_log::Builder::new() .targets([ tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), log_target, tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview) .filter(|_| { LOG_STREAM_ACTIVE.load(std::sync::atomic::Ordering::Acquire) }), ]) .level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info }) .filter(|metadata| { if LOG_PAUSED.load(std::sync::atomic::Ordering::Relaxed) { return false; } let target = metadata.target(); !target.starts_with("webview::") && !target.starts_with("hyper") && !target.starts_with("reqwest") && !target.starts_with("rustls") && !target.starts_with("h2") && !target.starts_with("tower") }) .max_file_size(10_000_000) .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3)) .timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal) .format(|out, message, record| { let redacted = redact_log_line_for_output(&message.to_string()); out.finish(format_args!( "[{}][{}][{}] {}", chrono::Local::now().format("%Y-%m-%d][%H:%M:%S"), record.level(), record.target(), redacted )) }) .build(), ) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_clipboard_manager::init()) .on_window_event(|window, event| { if window.label() == "main" { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window.hide(); } } }) .invoke_handler(tauri::generate_handler![ get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status, get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, pause_download, resume_download, fetch_metadata, fetch_media_metadata, fetch_media_playlist_metadata, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, get_free_space, perform_system_action, ack_schedule_trigger, check_automation_permission, request_automation_permission, open_automation_settings, set_keychain_password, get_keychain_password, delete_keychain_password, save_site_login, delete_site_login, hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access, authorize_keychain_access, acknowledge_pairing_token_change, check_file_exists, toggle_tray_icon, set_extension_pairing_token, get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_global_speed_limit, remove_download, detach_download_for_reconfigure, enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order, 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, read_logs, export_logs, toggle_log_pause, is_log_paused, clear_logs, set_log_stream_active ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { if let tauri::RunEvent::ExitRequested { .. } = event { let state = app_handle.state::(); let _ = state.extension_server_shutdown.send(true); } }); } mod db; mod extension_server; mod scheduler;