Files
Firelink/src-tauri/src/lib.rs
T
NimBold fec20db57c fix(backend): absolutely eliminate keychain access on startup
This entirely removes 'sanitize_current_settings_and_restore_token' from 'db::init' and removes the keychain hydration from 'setup()'. The backend will now never access the keychain during bootstrapping, relying purely on the frontend to explicitly trigger token hydration and migration via Tauri IPC when the UI mounts. This guarantees the macOS system prompt will never block the app from opening, and will only appear after the window is fully visible.
2026-06-24 22:14:08 +03:30

4280 lines
159 KiB
Rust

#![allow(unexpected_cfgs)]
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use tauri::{Manager, Emitter};
use regex::Regex;
use serde::Serialize;
use ts_rs::TS;
use uuid::Uuid;
use tauri_plugin_deep_link::DeepLinkExt;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
#[derive(Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct MetadataResponse {
url: String,
filename: String,
size: String,
#[ts(type = "number")]
size_bytes: u64,
}
#[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<f64>,
#[ts(type = "number | null")]
pub filesize: Option<u64>,
#[ts(type = "number | null")]
pub filesize_approx: Option<u64>,
}
#[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<u64>,
pub thumbnail: Option<String>,
pub formats: Vec<MediaFormat>,
}
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 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<u64> {
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<f64> {
value.get(key).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse::<f64>().ok())))
}
fn media_filesize(value: &serde_json::Value) -> Option<u64> {
json_u64(value, "filesize").or_else(|| json_u64(value, "filesize_approx"))
}
fn media_exact_filesize(value: &serde_json::Value) -> Option<u64> {
json_u64(value, "filesize")
}
fn media_approx_filesize(value: &serde_json::Value) -> Option<u64> {
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<f64>,
) -> 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<u64>, Option<u64>) {
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<u64> {
if let Some(height) = json_u64(value, "height") {
return Some(height);
}
if let Some(resolution) = json_str(value, "resolution") {
if let Some((_, height)) = resolution.split_once('x') {
if let Ok(parsed) = height.parse::<u64>() {
return Some(parsed);
}
}
}
let note = json_lower(value, "format_note");
for height in [4320_u64, 2160, 1440, 1080, 720, 480, 360, 240, 144] {
if note.contains(&format!("{height}p")) {
return Some(height);
}
}
None
}
fn matches_media_height(value: &serde_json::Value, target: u64) -> bool {
if !has_video_stream(value) {
return false;
}
let note = json_lower(value, "format_note");
if note.contains(&format!("{target}p")) {
return true;
}
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<String> {
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<f64>,
) -> (Option<u64>, Option<u64>) {
let video_has_audio = video.is_some_and(|format| has_audio_stream(format));
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<f64>,
) -> Option<MediaFormat> {
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<f64>,
) -> Vec<MediaFormat> {
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<u64> = [2160_u64, 1440, 1080, 720, 480, 360]
.into_iter()
.filter(|height| clean_formats.iter().any(|format| matches_media_height(format, *height)))
.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<String>,
downloaded_bytes: Option<f64>,
}
fn progress_json_number(progress: &serde_json::Value, key: &str) -> Option<f64> {
progress.get(key).and_then(|value| {
value
.as_f64()
.or_else(|| value.as_str().and_then(|text| text.parse::<f64>().ok()))
})
}
fn progress_json_string(progress: &serde_json::Value, key: &str) -> Option<String> {
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 parse_media_progress_line(line: &str) -> Option<MediaProgress> {
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 total = progress_json_number(&progress, "total_bytes")
.or_else(|| progress_json_number(&progress, "total_bytes_estimate"))
.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_string(&progress, "_percent_str")
.and_then(|percent| {
percent
.trim_end_matches('%')
.trim()
.parse::<f64>()
.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 size = progress_json_string(&progress, "_total_bytes_str")
.or_else(|| progress_json_string(&progress, "_total_bytes_estimate_str"))
.or_else(|| {
(total > 0.0).then(|| crate::download::format_size(total))
});
return Some(MediaProgress {
fraction: fraction.clamp(0.0, 1.0),
speed,
eta,
size,
downloaded_bytes: (downloaded > 0.0).then_some(downloaded),
});
}
static ARIA2_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
static YTDLP_PCT_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
static YTDLP_SPD_RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
static YTDLP_ETA_RE: std::sync::OnceLock<Regex> = 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::<f64>().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());
return Some(MediaProgress {
fraction: fraction.clamp(0.0, 1.0),
speed,
eta,
size,
downloaded_bytes: None,
});
}
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::<f64>().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());
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: None,
downloaded_bytes: None,
})
}
fn media_progress_speed(progress: &MediaProgress, now: Instant, last_sample: &mut Option<(Instant, f64)>) -> String {
let Some(downloaded_bytes) = progress.downloaded_bytes else {
return progress.speed.clone();
};
let Some((last_at, last_bytes)) = *last_sample else {
*last_sample = Some((now, downloaded_bytes));
return progress.speed.clone();
};
*last_sample = Some((now, downloaded_bytes));
if downloaded_bytes < last_bytes {
return progress.speed.clone();
}
let elapsed = now.duration_since(last_at).as_secs_f64();
if elapsed <= 0.05 {
return progress.speed.clone();
}
let bytes_per_second = (downloaded_bytes - last_bytes) / elapsed;
if bytes_per_second > 0.0 {
crate::download::format_speed(bytes_per_second)
} else {
progress.speed.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)
}
async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
cleanup_media_artifacts(out_path, true).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 {
let _ = tokio::fs::remove_file(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 {
let _ = tokio::fs::remove_file(path).await;
}
}
}
#[tauri::command]
async fn fetch_metadata(url: String, user_agent: Option<String>, username: Option<String>, password: Option<String>) -> Result<MetadataResponse, String> {
let mut current_url = url.clone();
let mut redirects = 0;
let res;
loop {
if redirects >= 5 {
return Err("Too many redirects".to_string());
}
let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
if let Some(ref ua) = user_agent {
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/120.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/120.0.0.0 Safari/537.36");
}
let mut resolved_addr = None;
if let Ok(parsed) = reqwest::Url::parse(&current_url) {
if let Some(host) = parsed.host_str() {
let port = parsed.port_or_known_default().unwrap_or(80);
if let Ok(addrs) = std::net::ToSocketAddrs::to_socket_addrs(&(host, port)) {
if let Some(addr) = addrs.into_iter().next() {
let ip = addr.ip();
if ip.is_loopback() || ip.is_multicast() || ip.is_unspecified() {
return Err("SSRF blocked: Private/local IP not allowed".to_string());
}
if let std::net::IpAddr::V4(ipv4) = ip {
if ipv4.is_private() || ipv4.is_link_local() {
return Err("SSRF blocked: Private/local IP not allowed".to_string());
}
}
resolved_addr = Some((host.to_string(), addr));
}
}
}
}
if let Some((host, addr)) = resolved_addr {
builder = builder.resolve(&host, addr);
}
let client = builder.build().map_err(|e| e.to_string())?;
let mut head_req = client.head(&current_url);
if let Some(ref user) = username {
if !user.is_empty() {
head_req = head_req.basic_auth(user, password.as_deref());
}
}
let mut current_res = head_req.send().await.map_err(|e| e.to_string())?;
if !current_res.status().is_success() && !current_res.status().is_redirection() {
let mut get_req = client.get(&current_url);
if let Some(ref user) = username {
if !user.is_empty() {
get_req = get_req.basic_auth(user, password.as_deref());
}
}
current_res = get_req.send().await.map_err(|e| e.to_string())?;
}
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(&current_url) {
if let Ok(new_url) = parsed_base.join(loc_str) {
current_url = new_url.to_string();
redirects += 1;
continue;
}
}
}
}
}
res = current_res;
break;
}
let mut filename = String::new();
if let Some(cd) = res.headers().get(reqwest::header::CONTENT_DISPOSITION) {
if let Ok(cd_str) = cd.to_str() {
if let Some(idx) = cd_str.find("filename=") {
let rest = &cd_str[idx + 9..];
let raw_filename = rest.trim_matches(|c| c == '"' || c == '\'');
let normalized = raw_filename.replace('\\', "/");
filename = std::path::Path::new(&normalized)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
}
}
}
if filename.is_empty() {
if let Ok(parsed) = reqwest::Url::parse(&current_url) {
if let Some(mut segments) = parsed.path_segments() {
if let Some(last) = segments.next_back() {
let normalized = last.replace('\\', "/");
filename = std::path::Path::new(&normalized)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
}
}
}
}
if filename.is_empty() {
filename = "download".to_string();
}
let mut size_str = "Unknown".to_string();
let mut 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::<u64>() {
size_bytes = 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);
}
}
}
}
Ok(MetadataResponse { url: current_url, filename, size: size_str, size_bytes })
}
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;
static MEDIA_METADATA_CACHE: OnceLock<tokio::sync::Mutex<HashMap<u64, (Instant, MediaMetadata)>>> = OnceLock::new();
static MEDIA_METADATA_LOCKS: OnceLock<tokio::sync::Mutex<HashMap<u64, std::sync::Arc<tokio::sync::Mutex<()>>>>> = OnceLock::new();
fn media_metadata_cache_key(
url: &str,
cookie_browser: &Option<String>,
username: &Option<String>,
password: &Option<String>,
) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
cookie_browser.hash(&mut hasher);
username.hash(&mut hasher);
password.hash(&mut hasher);
hasher.finish()
}
async fn release_media_metadata_lock(
cache_key: u64,
request_lock: &std::sync::Arc<tokio::sync::Mutex<()>>,
) {
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}"))
}
#[tauri::command]
async fn fetch_media_metadata(
app_handle: tauri::AppHandle,
url: String,
cookie_browser: Option<String>,
username: Option<String>,
password: Option<String>,
) -> Result<MediaMetadata, String> {
let cache_key = media_metadata_cache_key(&url, &cookie_browser, &username, &password);
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);
let result = fetch_media_metadata_uncached(
app_handle,
url,
cookie_browser,
username,
password,
)
.await;
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(&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(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
}
async fn fetch_media_metadata_uncached(app_handle: tauri::AppHandle, url: String, cookie_browser: Option<String>, username: Option<String>, password: Option<String>) -> Result<MediaMetadata, String> {
// 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 {
if !browser.is_empty() {
cmd = cmd.arg("--cookies-from-browser").arg(&browser);
}
}
let mut config_file = tempfile::Builder::new().prefix("ytdlp-").suffix(".conf").tempfile().map_err(|e| e.to_string())?;
let mut config_content = String::new();
if let Some(user) = username {
if !user.is_empty() {
config_content.push_str(&format!("--username\n{}\n", user));
}
}
if let Some(pass) = password {
if !pass.is_empty() {
config_content.push_str(&format!("--password\n{}\n", pass));
}
}
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 = tokio::time::timeout(MEDIA_METADATA_TIMEOUT, cmd.output())
.await
.map_err(|_| {
format!(
"yt-dlp timed out after {}s while fetching media metadata",
MEDIA_METADATA_TIMEOUT.as_secs()
)
})?
.map_err(|e| format!("Failed to execute yt-dlp: {}", e))?;
if output.status.success() {
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<String, String> {
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<String, String> {
use tauri_plugin_shell::ShellExt;
let binary_path = resolve_bundled_binary_path(&app_handle, "ffmpeg")?;
let output = app_handle.shell().command(&binary_path)
.arg("-version")
.output()
.await
.map_err(|e| format!("Failed to execute ffmpeg: {}", e))?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
let first_line = text.lines().next().unwrap_or("");
let re = regex::Regex::new(r"(?i)version\s+([\d\.]+)").unwrap();
let clean = re.captures(first_line)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| {
let parts: Vec<&str> = first_line.split_whitespace().collect();
parts.get(2).unwrap_or(&first_line).split('-').next().unwrap_or("").to_string()
});
Ok(clean)
} else {
let err = String::from_utf8_lossy(&output.stderr);
Err(format!("ffmpeg error: {}", err))
}
}
#[tauri::command]
async fn test_deno(app_handle: tauri::AppHandle) -> Result<String, String> {
use tauri_plugin_shell::ShellExt;
let binary_path = resolve_bundled_binary_path(&app_handle, "deno")?;
let output = app_handle.shell().command(&binary_path)
.arg("--version")
.output()
.await
.map_err(|e| format!("Failed to execute deno: {}", e))?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
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 {
let err = String::from_utf8_lossy(&output.stderr);
Err(format!("deno error: {}", err))
}
}
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))
}
fn canonicalize_with_missing_components(path: &std::path::Path) -> Option<std::path::PathBuf> {
let mut existing = path;
let mut missing = Vec::new();
while !existing.exists() {
missing.push(existing.file_name()?.to_owned());
existing = existing.parent()?;
}
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<std::path::PathBuf> {
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<String, String> {
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 = canonical.to_string_lossy().to_string();
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<std::path::PathBuf>, 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<Option<std::process::Child>>,
startup_error: Mutex<Option<String>>,
last_stderr: Mutex<String>,
}
impl Aria2DaemonGuard {
fn new() -> Self {
Self {
child: Mutex::new(None),
startup_error: Mutex::new(None),
last_stderr: Mutex::new(String::new()),
}
}
}
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 download;
pub mod queue;
#[allow(dead_code)]
pub mod ipc;
mod parity;
pub mod error;
pub mod commands;
pub mod download_ownership;
pub mod retry;
mod engines;
mod platform;
mod settings;
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 extension_pairing_token: extension_server::SharedExtensionToken,
pub extension_frontend_ready: extension_server::SharedFrontendReady,
pub extension_server_port: extension_server::SharedServerPort,
pub extension_server_shutdown: tokio::sync::watch::Sender<bool>,
pub aria2_port: u16,
pub aria2_secret: String,
pub media_semaphore: Arc<tokio::sync::Semaphore>,
pub sleep_preventer: Arc<Mutex<Option<keepawake::KeepAwake>>>,
pub scheduler_settings: Arc<RwLock<Option<crate::ipc::PersistedSettings>>>,
pub queue_manager: Arc<queue::QueueManager>,
}
#[derive(Clone, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct DownloadProgressEvent {
id: String,
fraction: f64,
speed: String,
eta: String,
size: Option<String>,
size_is_final: bool,
}
#[derive(Debug, Clone, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct EngineStatusItem {
name: String,
kind: String,
expected_sidecar: String,
resolved_path: Option<String>,
version: Option<String>,
ready: bool,
error: Option<String>,
stderr_tail: Option<String>,
remediation_hint: Option<String>,
rpc_port: Option<u16>,
daemon_alive: Option<bool>,
rpc_ready: Option<bool>,
last_stderr_tail: Option<String>,
expects_internal_dir: Option<bool>,
has_internal_dir: Option<bool>,
has_python_framework: Option<bool>,
}
#[derive(Debug, Clone, Serialize, TS)]
#[ts(export, export_to = "../../src/bindings/")]
pub struct EngineStatusResult {
pub engines: Vec<EngineStatusItem>,
}
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 Ok(home) = app_handle.path().home_dir() {
resolved = home.join(stripped);
}
} else if path == "~" {
if let Ok(home) = app_handle.path().home_dir() {
resolved = home;
}
}
resolved
}
pub(crate) fn collect_download_uris(url: &str, mirrors: Option<&str>) -> Vec<String> {
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<String>),
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<url::Url>) {
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::<AppState>().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<serde_json::Value, String> {
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<String, String> {
let guard = app_handle.state::<Aria2DaemonGuard>();
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,
&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<String>, Option<String>, Option<String>) {
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);
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::<Vec<_>>().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<String>, Option<String>, Option<String>);
fn version_check_cache(
) -> &'static std::sync::Mutex<std::collections::HashMap<String, VersionCheckResult>> {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, VersionCheckResult>>,
> = 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 {
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();
format!("{}:{modified}:{}", binary_path.display(), args.join("\u{1f}"))
}
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<String> {
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| p.to_string_lossy().to_string());
let (startup_err, daemon_stderr) = {
let guard = app_handle.state::<Aria2DaemonGuard>();
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| p.to_string_lossy().to_string());
let (has_internal_dir, has_python_framework) = if let Some(ref path) = resolved_path {
let parent = std::path::Path::new(path).parent().map(|p| p.to_path_buf());
if let Some(parent) = parent {
let internal = crate::engines::ytdlp_internal_dir(std::path::Path::new(path))
.unwrap_or_else(|| parent.join("_internal"));
let hi = internal.is_dir();
let hp = if hi {
internal.join("Python.framework").is_dir() || internal.join("Python").exists()
} else {
false
};
(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_framework {
error = Some("_internal/Python.framework was not found beside yt-dlp sidecar".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_framework),
}
}
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| p.to_string_lossy().to_string());
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| {
text.lines().next().and_then(|first| {
let re = regex::Regex::new(r"(?i)version\s+([\d\.]+)").unwrap();
if let Some(caps) = re.captures(first) {
caps.get(1).map(|m| m.as_str().to_string())
} else {
let parts: Vec<&str> = first.split_whitespace().collect();
parts.get(2).map(|v| v.split('-').next().unwrap_or(v).to_string())
}
})
});
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,
}
}
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| p.to_string_lossy().to_string());
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<EngineStatusResult, String> {
let port = state.aria2_port;
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<EngineStatusItem, String> {
Ok(check_aria2(&app_handle, state.aria2_port, &state.aria2_secret).await)
}
#[tauri::command]
async fn get_ytdlp_engine_status(app_handle: tauri::AppHandle) -> Result<EngineStatusItem, String> {
Ok(check_ytdlp(&app_handle).await)
}
#[tauri::command]
async fn get_ffmpeg_engine_status(app_handle: tauri::AppHandle) -> Result<EngineStatusItem, String> {
Ok(check_ffmpeg(&app_handle).await)
}
#[tauri::command]
async fn get_deno_engine_status(app_handle: tauri::AppHandle) -> Result<EngineStatusItem, String> {
Ok(check_deno(&app_handle).await)
}
fn resolve_bundled_binary_path(app_handle: &tauri::AppHandle, binary_name: &str) -> Result<std::path::PathBuf, String> {
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<String>,
cookie_source: Option<String>,
speed_limit: Option<String>,
username: Option<String>,
password: Option<String>,
headers: Option<String>,
proxy: Option<String>,
user_agent: Option<String>,
max_tries: Option<i32>,
cancel_rx: &mut tokio::sync::watch::Receiver<bool>,
) -> Result<(), String> {
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 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 mut config_content = String::new();
if let Some(user) = username {
if !user.is_empty() {
config_content.push_str(&format!("--username\n{}\n", user));
}
}
if let Some(pass) = password {
if !pass.is_empty() {
config_content.push_str(&format!("--password\n{}\n", pass));
}
}
if let Some(headers) = headers {
for header in headers.lines().map(str::trim).filter(|header| !header.is_empty()) {
config_content.push_str(&format!("--add-header\n{}\n", header));
}
}
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::{BackoffOutcome, MAX_RETRIES, backoff_and_emit_cancel, is_transient_network_error};
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 current_track: f64 = 0.0;
let mut last_fraction: f64 = 0.0;
let mut last_speed_sample: Option<(Instant, f64)> = None;
let mut last_progress_at = std::time::Instant::now()
.checked_sub(std::time::Duration::from_millis(200))
.unwrap_or_else(std::time::Instant::now);
// 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 mut strike = 0_usize;
let mut processing_started = false;
while strike <= MAX_RETRIES {
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
let mut cmd = app_handle.shell().command(&ytdlp_path)
.arg("--newline")
.arg("--progress-delta").arg("0.2")
.arg("--progress-template")
.arg(format!("download:{MEDIA_PROGRESS_PREFIX}%(progress)j"))
.arg("--no-check-formats")
.arg("--socket-timeout").arg("20")
.arg("--retries").arg("3")
.arg("--extractor-retries").arg("3")
.arg("--downloader").arg(&aria2c_path)
.arg("--downloader-args").arg("aria2c:-c -x 16 -s 16 -k 1M --summary-interval=1")
.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")
.arg("--continue")
.arg("--compat-options").arg("no-youtube-unavailable-videos")
.arg("-o").arg(out_path.to_string_lossy().to_string())
.env("PATH", &trusted_path);
if let Some(limit) = speed_limit.as_ref() {
if !limit.is_empty() {
cmd = cmd.arg("--limit-rate").arg(limit);
}
}
if let Some(p) = proxy.as_ref().filter(|s| !s.is_empty()) {
if p == "none" {
cmd = cmd.arg("--proxy").arg("");
} else {
cmd = cmd.arg("--proxy").arg(p);
}
}
if let Some(cs) = 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(tries) = max_tries {
cmd = cmd.arg("--retries").arg(tries.to_string());
}
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 {
cmd = cmd.arg("--merge-output-format").arg("mkv");
}
}
cmd = cmd.arg("--").arg(&url);
let (mut rx, child) = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?;
log::info!("yt-dlp spawned for id: {} (strike {})", id, strike);
let mut stderr_tail = String::new();
let failure_reason = loop {
tokio::select! {
_ = cancel_rx.changed() => {
let _ = child.kill();
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 line = String::from_utf8_lossy(&line_bytes);
if let Some(progress) = parse_media_progress_line(&line) {
let previous_track = current_track;
let overall_fraction = aggregate_media_fraction(
total_tracks,
&mut current_track,
&mut last_fraction,
progress.fraction,
);
if current_track != previous_track {
last_speed_sample = None;
}
let speed = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample);
let now = std::time::Instant::now();
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) {
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
id: id.to_string(),
fraction: overall_fraction,
speed,
eta: progress.eta,
size: progress.size,
size_is_final: false,
});
last_progress_at = now;
}
}
}
Some(tauri_plugin_shell::process::CommandEvent::Stderr(line_bytes)) => {
let line = String::from_utf8_lossy(&line_bytes);
if let Some(progress) = parse_media_progress_line(&line) {
let previous_track = current_track;
let overall_fraction = aggregate_media_fraction(
total_tracks,
&mut current_track,
&mut last_fraction,
progress.fraction,
);
if current_track != previous_track {
last_speed_sample = None;
}
let speed = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample);
let now = std::time::Instant::now();
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) {
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
id: id.to_string(),
fraction: overall_fraction,
speed,
eta: progress.eta,
size: progress.size,
size_is_final: false,
});
last_progress_at = now;
}
}
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,
});
}
let lower = line.to_lowercase();
if lower.contains("error") || lower.contains("critical") {
log::error!("yt-dlp stderr [{}]: {}", id, line.trim());
}
stderr_tail.push_str(&line);
if stderr_tail.len() > STDERR_TAIL {
stderr_tail = stderr_tail.split_off(stderr_tail.len() - STDERR_TAIL);
}
}
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 payload.code == Some(0) {
log::info!("yt-dlp completed successfully for id: {}", id);
if let Ok(metadata) = tokio::fs::metadata(&out_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,
});
}
}
return Ok(());
}
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 !(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 active_kind = state.queue_manager.active_kind(&id).await;
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().filter(|gid| !gid.starts_with("native:")) {
let status = aria2_download_status(
state.aria2_port,
&state.aria2_secret,
gid,
)
.await?;
match status.as_str() {
"paused" => {
log::info!("aria2 pause [{}]: gid {} was already paused", id, gid);
}
"active" | "waiting" => {
let result = rpc_call(
state.aria2_port,
&state.aria2_secret,
"aria2.forcePause",
serde_json::json!([gid]),
)
.await
.map_err(|error| format!("failed to pause aria2 gid {gid}: {error}"))?;
ensure_aria2_gid_result("forcePause", gid, &result)?;
log::info!("aria2 pause [{}]: gid {} paused", id, gid);
}
terminal => {
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;
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(());
}
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(), tx)
.await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
state
.download_coordinator
.send(download::DownloadCmd::PauseWithAck(download_id, tx))
.await?;
} else {
let _ = tx.send(());
}
rx.await
.map_err(|_| "download worker stopped without acknowledging pause".to_string())?;
if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
state.queue_manager.release_permit(&id).await;
}
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<bool, String> {
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.release_registered_id(&id).await;
return Ok(false);
};
if gid.starts_with("native:") {
state.queue_manager.forget_aria2_gid(&id).await;
log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id);
state.queue_manager.release_registered_id(&id).await;
return Ok(false);
}
let status = aria2_download_status(state.aria2_port, &state.aria2_secret, &gid).await?;
match status.as_str() {
"paused" => {
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;
let aria2_secret = state.aria2_secret.clone();
let id_clone = id.clone();
let gid_clone = gid.clone();
tauri::async_runtime::spawn(async move {
let acquired = queue_manager.ensure_aria2_permit(&id_clone).await;
let result = match rpc_call(
aria2_port,
&aria2_secret,
"aria2.unpause",
serde_json::json!([gid_clone]),
)
.await
{
Ok(result) => result,
Err(error) => {
if acquired {
queue_manager.release_permit(&id_clone).await;
}
log::error!("failed to resume aria2 gid {}: {}", gid_clone, error);
return;
}
};
if let Err(error) = ensure_aria2_gid_result("unpause", &gid_clone, &result) {
if acquired {
queue_manager.release_permit(&id_clone).await;
}
log::error!("failed to resume aria2 gid {}: {}", gid_clone, error);
return;
}
log::info!("aria2 resume [{}]: unpaused gid {}", id_clone, gid_clone);
});
return Ok(true);
}
"active" | "waiting" => {
state.queue_manager.ensure_aria2_permit(&id).await;
log::info!(
"aria2 resume [{}]: gid {} already {}; no duplicate job created",
id,
gid,
status
);
}
"complete" | "error" | "removed" => {
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
);
return Ok(false);
}
other => {
return Err(format!("aria2 gid {gid} returned unknown status {other}"));
}
}
use tauri::Emitter;
let _ = app_handle.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(id, crate::ipc::DownloadStatus::Downloading),
);
Ok(true)
}
#[tauri::command]
async fn remove_download(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
id: String,
delete_assets: bool,
) -> Result<(), String> {
log::info!("remove_download called for id: {}", id);
let primary_path = crate::download_ownership::primary_path_for_id(&app_handle, &id)?;
let active_kind = state.queue_manager.active_kind(&id).await;
state.queue_manager.remove_from_pending(&id).await;
state.queue_manager.cancel_aria2_retries(&id).await;
let retry_add_guard = state.queue_manager.lock_aria2_retry_add().await;
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
let removal_result = async {
force_remove_aria2_gid(state.aria2_port, &state.aria2_secret, gid).await?;
wait_for_aria2_stopped(state.aria2_port, &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.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 {
drop(retry_add_guard);
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(), tx).await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
let command = if delete_assets {
download::DownloadCmd::CancelWithAck(download_id, tx)
} else {
download::DownloadCmd::PauseWithAck(download_id, tx)
};
state.download_coordinator.send(command).await?;
} else {
let _ = tx.send(());
}
let _ = rx.await;
if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
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),
);
if delete_assets {
if let Some(path) = primary_path.as_deref() {
remove_download_assets(path, &app_handle).await?;
}
}
crate::download_ownership::remove(&app_handle, &id)?;
state.queue_manager.release_registered_id(&id).await;
Ok(())
}
async fn remove_download_assets(
primary: &std::path::Path,
app_handle: &tauri::AppHandle,
) -> Result<(), String> {
if !is_safe_path(primary, app_handle) {
return Err("Download asset path is outside an allowed download location".to_string());
}
if primary.exists() {
trash::delete(primary)
.map_err(|error| format!("failed to move downloaded file to Trash: {error}"))?;
}
for suffix in [".aria2", ".part", ".ytdl"] {
let candidate = std::path::PathBuf::from(format!("{}{}", primary.display(), suffix));
if candidate.exists() && is_safe_path(&candidate, app_handle) {
tokio::fs::remove_file(&candidate)
.await
.map_err(|error| format!("failed to remove '{}': {error}", candidate.display()))?;
}
}
cleanup_media_processing_artifacts(primary).await;
Ok(())
}
#[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 active_kind = state.queue_manager.active_kind(&id).await;
state.queue_manager.remove_from_pending(&id).await;
state.queue_manager.cancel_aria2_retries(&id).await;
let retry_add_guard = state.queue_manager.lock_aria2_retry_add().await;
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
let removal_result = async {
rpc_call(
state.aria2_port,
&state.aria2_secret,
"aria2.forcePause",
serde_json::json!([gid]),
)
.await?;
wait_for_aria2_stopped(state.aria2_port, &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.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 {
drop(retry_add_guard);
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(), tx).await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
state.download_coordinator.send(crate::download::DownloadCmd::PauseWithAck(download_id, tx)).await?;
} else {
let _ = tx.send(()); // Fallback if no task exists
}
let _ = rx.await; // Wait for the writer to stop
if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
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<String, String> {
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}"))
}
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(), "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]
fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) {
#[cfg(target_os = "macos")]
{
use cocoa::appkit::NSApp;
use cocoa::base::{nil, id};
use cocoa::foundation::NSString;
use objc::{msg_send, sel, sel_impl};
unsafe {
let app = NSApp();
let dock_tile: id = msg_send![app, dockTile];
let label = if count > 0 { count.to_string() } else { "".to_string() };
let ns_label = NSString::alloc(nil).init_str(&label);
let _: () = msg_send![dock_tile, setBadgeLabel: ns_label];
}
}
}
#[tauri::command]
fn get_platform_info() -> 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(),
}
}
#[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() {
let keepawake = keepawake::Builder::default()
.idle(true)
.reason("Firelink active download")
.create()
.map_err(|error| format!("failed to prevent system sleep: {error}"))?;
*current_preventer = Some(keepawake);
}
} else {
*current_preventer = None;
}
Ok(())
}
pub(crate) fn execute_system_action(action: crate::ipc::PostQueueAction) -> Result<(), String> {
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<String>,
) -> Result<Vec<String>, AppError> {
Ok(state.queue_manager.pending_order(queue_id.as_deref()).await)
}
#[tauri::command]
async fn enqueue_download(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
mut item: queue::EnqueueItem,
) -> Result<crate::ipc::EnqueueAccepted, AppError> {
let id = item.id.clone();
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
let accepted_filename = item.filename.clone();
crate::download_ownership::register_expected(
&app_handle,
&item.id,
&item.destination,
&item.filename,
)?;
if let Err(e) = state.queue_manager.push(item.into_task()).await {
let _ = crate::download_ownership::remove(&app_handle, &id);
state.queue_manager.release_registered_id(&id).await;
return Err(AppError::Internal(e));
}
Ok(crate::ipc::EnqueueAccepted {
id,
filename: accepted_filename,
})
}
#[tauri::command]
async fn enqueue_many(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
mut items: Vec<queue::EnqueueItem>,
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
for item in &mut items {
item.filename = crate::download_ownership::canonical_download_filename(&item.filename);
}
for item in &items {
crate::download_ownership::register_expected(
&app_handle,
&item.id,
&item.destination,
&item.filename,
)?;
}
let tasks = items.into_iter().map(queue::EnqueueItem::into_task).collect();
let results = state.queue_manager.enqueue_many(tasks).await;
for result in &results {
if !result.success {
let _ = crate::download_ownership::remove(&app_handle, &result.id);
state.queue_manager.release_registered_id(&result.id).await;
}
}
Ok(results)
}
#[tauri::command]
async fn move_in_queue(
state: tauri::State<'_, AppState>,
id: String,
queue_id: String,
direction: crate::ipc::QueueDirection,
) -> Result<Vec<String>, AppError> {
Ok(state
.queue_manager
.move_in_queue(&id, &queue_id, direction)
.await)
}
#[tauri::command]
async fn remove_from_queue(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
id: String,
) -> Result<bool, AppError> {
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);
Ok(())
}
fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
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::<f64>().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<String>) -> Result<(), String> {
let limit_str = limit
.as_deref()
.and_then(normalize_speed_limit_for_aria2)
.unwrap_or_else(|| "0".to_string());
rpc_call(
state.aria2_port,
&state.aria2_secret,
"aria2.changeGlobalOption",
serde_json::json!([{"max-overall-download-limit": limit_str}])
).await.map(|_| ()).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 cocoa::base::{id, nil};
use cocoa::foundation::NSString;
use objc::{class, msg_send, sel, sel_impl};
unsafe {
objc::rc::autoreleasepool(|| {
let script_str =
NSString::alloc(nil).init_str("tell application \"System Events\" to get name");
let ns_apple_script: id = msg_send![class!(NSAppleScript), alloc];
let ns_apple_script: id = msg_send![ns_apple_script, initWithSource: script_str];
let mut error_dict: id = nil;
let result: id = msg_send![ns_apple_script, executeAndReturnError: &mut error_dict];
if result == nil {
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]
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::<String>)
.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<String, String> {
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(id: String, password: String) -> Result<(), String> {
crate::db::set_keychain_password(&id, &password)
}
#[tauri::command]
fn get_keychain_password(id: String) -> Result<String, String> {
crate::db::get_keychain_password(&id)
}
#[tauri::command]
fn delete_keychain_password(id: String) -> Result<(), String> {
crate::db::delete_keychain_password(&id)
}
#[derive(Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
struct PairingTokenHydration {
token: String,
token_changed: bool,
persistent: bool,
error: Option<String>,
}
#[tauri::command]
fn hydrate_extension_pairing_token(
database: tauri::State<'_, crate::db::DbState>,
app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> {
let mut connection = database.lock()?;
let skip_keychain = !crate::db::is_keychain_access_granted(&connection).unwrap_or(false);
match crate::db::hydrate_pairing_token(&mut connection, skip_keychain) {
Ok((token, token_changed)) => {
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = token.clone();
}
Ok(PairingTokenHydration {
token,
token_changed,
persistent: !skip_keychain,
error: None,
})
}
Err(error) => {
let token = app_state
.extension_pairing_token
.read()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: false,
error: Some(error),
})
}
}
}
#[tauri::command]
fn grant_keychain_access(
database: tauri::State<'_, crate::db::DbState>,
app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> {
let mut connection = database.lock()?;
// Explicitly force migration of any legacy token to the keychain
let _ = crate::db::sanitize_current_settings_and_restore_token(&connection, true);
match crate::db::hydrate_pairing_token(&mut connection, false) {
Ok((token, token_changed)) => {
// Update the extension server's token in memory
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = token.clone();
}
Ok(PairingTokenHydration {
token,
token_changed,
persistent: true,
error: None,
})
}
Err(error) => {
let token = app_state
.extension_pairing_token
.read()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: false,
error: Some(error),
})
}
}
}
#[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)?;
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<Option<String>, String> {
let connection = state.lock()?;
crate::db::load_settings(&connection)
}
#[tauri::command]
fn db_get_all_downloads(
state: tauri::State<'_, crate::db::DbState>,
) -> Result<Vec<String>, 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 mut connection = state.lock()?;
crate::db::replace_downloads(&mut connection, &data)
}
#[tauri::command]
fn db_get_all_queues(
state: tauri::State<'_, crate::db::DbState>,
) -> Result<Vec<String>, 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()
}
async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, String> {
use tauri::Manager;
let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?;
let mut files = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.is_file()
&& path
.file_name()
.is_some_and(|name| name.to_string_lossy().contains(".log"))
{
files.push(path);
}
}
}
files.sort();
Ok(files)
}
fn redact_log_line(line: &str) -> String {
use std::sync::OnceLock;
static SECRET: OnceLock<regex::Regex> = OnceLock::new();
static HEADER: OnceLock<regex::Regex> = OnceLock::new();
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
let secret = SECRET.get_or_init(|| {
regex::Regex::new(
r"(?i)(authorization|cookie|password|token|secret)\s*[:=]\s*([^\s,;]+)",
)
.expect("valid secret redaction regex")
});
let header = HEADER.get_or_init(|| {
regex::Regex::new(r"(?i)(authorization|cookie)\s*:\s*[^\r\n]+")
.expect("valid sensitive header redaction regex")
});
let query = QUERY.get_or_init(|| {
regex::Regex::new(r"(https?://[^\s?]+)\?[^\s]+")
.expect("valid URL query redaction regex")
});
let redacted = header.replace_all(line, "$1: [redacted]");
let redacted = secret.replace_all(&redacted, "$1=[redacted]");
query.replace_all(&redacted, "$1?[redacted]").into_owned()
}
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<Vec<String>, String> {
let mut lines = Vec::new();
for file in log_files(&app_handle).await? {
let content = tokio::fs::read_to_string(&file)
.await
.map_err(|error| format!("failed to read '{}': {error}", file.display()))?;
lines.extend(
content
.lines()
.map(|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? {
let _ = tokio::fs::write(&file, "").await;
}
Ok(())
}
#[tauri::command]
async fn export_logs(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
dest_path: String,
) -> Result<String, String> {
let mut output = format!(
"Firelink support logs\nVersion: {}\nOS: {} {}\nArchitecture: {}\nGenerated: {}\n\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::FAMILY,
std::env::consts::ARCH,
chrono::Utc::now().to_rfc3339(),
);
let (aria2, ytdlp, ffmpeg, deno) = tokio::join!(
check_aria2(&app_handle, state.aria2_port, &state.aria2_secret),
check_ytdlp(&app_handle),
check_ffmpeg(&app_handle),
check_deno(&app_handle),
);
output.push_str("Engine status:\n");
for engine in [aria2, ytdlp, ffmpeg, deno] {
output.push_str(&format!(
"- {}: {}{}\n",
engine.name,
if engine.ready { "ready" } else { "unavailable" },
engine
.version
.as_deref()
.map(|version| format!(" ({version})"))
.unwrap_or_default()
));
if let Some(error) = engine.error {
output.push_str(&format!(" Error: {}\n", redact_log_line(&error)));
}
}
if let Ok(settings) = crate::settings::load_settings(&app_handle) {
output.push_str(&format!(
"\nRuntime settings:\n- Max concurrent downloads: {}\n- Per-server connections: {}\n- Automatic retries: {}\n- Proxy mode: {:?}\n- Scheduler enabled: {}\n\n",
settings.max_concurrent_downloads,
settings.per_server_connections,
settings.max_automatic_retries,
settings.proxy_mode,
settings.scheduler.enabled,
));
}
for file in log_files(&app_handle).await? {
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');
}
tokio::fs::write(&dest_path, output)
.await
.map_err(|e| e.to_string())?;
Ok(dest_path)
}
#[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())?;
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<u16> {
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;
});
}
#[cfg(test)]
mod tests {
use super::{
aggregate_media_fraction, build_media_format_options, collect_download_uris,
is_excluded_yt_dlp_format, json_lower, media_progress_speed,
normalize_speed_limit_for_aria2, parse_firelink_deep_link, parse_media_progress_line,
FirelinkDeepLink,
redact_log_line, MediaProgress, MEDIA_PROGRESS_PREFIX,
};
use serde_json::json;
use std::time::{Duration, Instant};
#[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 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]
#[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),
})
);
}
#[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%\"}}"
);
assert_eq!(
parse_media_progress_line(&line).map(|progress| progress.fraction),
Some(0.0)
);
}
#[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 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),
};
let second = MediaProgress {
fraction: 0.5,
speed: "fallback".to_string(),
eta: "-".to_string(),
size: None,
downloaded_bytes: Some(3_097_152.0),
};
let start = Instant::now();
let mut sample = None;
assert_eq!(media_progress_speed(&first, start, &mut sample), "fallback");
assert_eq!(
media_progress_speed(&second, start + Duration::from_secs(1), &mut sample),
"2.0 MB/s"
);
}
#[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: None,
})
);
}
#[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: None,
downloaded_bytes: None,
})
);
}
}
static LOG_PAUSED: 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)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
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_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 aria2_port = std::net::TcpListener::bind("127.0.0.1:0")
.and_then(|listener| listener.local_addr())
.map(|addr| addr.port())
.unwrap_or(6800);
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 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(app.handle())
.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 max_concurrent = {
crate::settings::load_settings(app.handle())
.map(|settings| settings.max_concurrent_downloads)
.unwrap_or(crate::queue::DEFAULT_MAX_CONCURRENT)
};
let scheduler_settings = Arc::new(RwLock::new(
crate::settings::load_settings(app.handle()).ok(),
));
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent));
let dispatcher_mgr = Arc::clone(&queue_manager);
tauri::async_runtime::spawn(async move {
dispatcher_mgr.run_dispatcher().await;
});
let queue_manager_clone = Arc::clone(&queue_manager);
let queue_manager_poll = Arc::clone(&queue_manager);
app.manage(AppState {
download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()),
extension_pairing_token,
extension_frontend_ready,
extension_server_port,
extension_server_shutdown: extension_server_shutdown_tx.clone(),
aria2_port,
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,
});
// Backend listener: release permits + emit terminal state for
// native (and aria2-fallback) downloads. Idempotent for Media/aria2
// which already release via finish_runner/handle_aria2_event.
let completion_app = app.handle().clone();
let completion_mgr = Arc::clone(&queue_manager_clone);
tauri::async_runtime::spawn(async move {
use tauri::Listener;
let rx_complete = completion_app.listen("download-complete", move |event| {
let raw_id = event.payload();
let id: String = serde_json::from_str(raw_id)
.unwrap_or_else(|_| raw_id.trim_matches('"').to_string());
let mgr = Arc::clone(&completion_mgr);
tauri::async_runtime::spawn(async move {
mgr.apply_completion(&id, crate::queue::PendingOutcome::Complete).await;
});
});
let completion_app2 = completion_app.clone();
let completion_mgr2 = Arc::clone(&queue_manager_clone);
let rx_failed = completion_app2.listen("download-failed", move |event| {
let raw_id = event.payload();
let id: String = serde_json::from_str(raw_id)
.unwrap_or_else(|_| raw_id.trim_matches('"').to_string());
let mgr = Arc::clone(&completion_mgr2);
tauri::async_runtime::spawn(async move {
mgr.apply_completion(&id, crate::queue::PendingOutcome::Error("download failed".to_string())).await;
});
});
// Keep the task alive; the listeners are unregistered on drop.
std::future::pending::<()>().await;
let _ = rx_complete;
let _ = rx_failed;
});
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 = crate::settings::load_settings(app.handle())
.map(|settings| settings.global_speed_limit)
.unwrap_or_default();
match resolve_bundled_binary_path(app.handle(), "aria2c") {
Ok(binary_path) => {
let mut cmd = std::process::Command::new(&binary_path);
cmd.arg("--enable-rpc=true")
.arg(format!("--rpc-listen-port={}", aria2_port))
.arg(format!("--rpc-secret={}", aria2_secret))
.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");
if let Some(global_speed_limit) = normalize_speed_limit_for_aria2(&global_speed_limit) {
cmd.arg(format!("--max-overall-download-limit={}", global_speed_limit));
}
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::piped());
match cmd.spawn() {
Ok(mut child) => {
log::info!("aria2c spawned successfully on port {}", aria2_port);
let daemon_app = app.handle().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::<Aria2DaemonGuard>().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.state::<Aria2DaemonGuard>();
*guard.child.lock().unwrap() = Some(child);
let port = aria2_port;
let secret = aria2_secret.clone();
let start = std::time::Instant::now();
let ready = tauri::async_runtime::block_on(async {
let mut last_err = String::new();
loop {
if start.elapsed() > std::time::Duration::from_secs(5) {
return 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}")
});
}
match rpc_call(port, &secret, "aria2.getVersion", serde_json::json!([])).await {
Ok(ver) => return Ok(ver),
Err(e) => {
last_err = e;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
}
});
match ready {
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, port);
}
Err(e) => {
log::error!("aria2 daemon readiness check failed: {}", e);
let guard = app.state::<Aria2DaemonGuard>();
*guard.startup_error.lock().unwrap() = Some(e);
}
}
}
Err(e) => {
log::error!("Failed to spawn aria2c: {}", e);
let guard = app.state::<Aria2DaemonGuard>();
*guard.startup_error.lock().unwrap() = Some(format!("Failed to spawn aria2c: {e}"));
}
}
}
Err(e) => {
log::error!("Failed to resolve aria2c binary: {}", e);
let guard = app.state::<Aria2DaemonGuard>();
*guard.startup_error.lock().unwrap() = Some(format!("Failed to resolve aria2c: {e}"));
}
}
let app_handle_ws = app.handle().clone();
let ws_port = aria2_port;
tauri::async_runtime::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).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 {
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::<serde_json::Value>(&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_ws.state::<AppState>();
let outcome = match method {
"aria2.onDownloadComplete" => Some(crate::queue::PendingOutcome::Complete),
"aria2.onDownloadError" => {
let msg = event.get("error_message").and_then(|m| m.as_str()).unwrap_or("aria2 download error").to_string();
Some(crate::queue::PendingOutcome::Error(msg))
}
_ => None,
};
if let Some(outcome) = outcome {
Arc::clone(&state.queue_manager)
.handle_aria2_event(gid, outcome)
.await;
}
}
}
}
}
}
}
}
}
// Connection lost, loop and reconnect
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
}
});
let app_handle_poll = app.handle().clone();
let poll_port = aria2_port;
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));
loop {
interval.tick().await;
let params = serde_json::json!([["gid", "status", "totalLength", "completedLength", "downloadSpeed", "errorMessage"]]);
if let Ok(active_list) = rpc_call(poll_port, &poll_secret, "aria2.tellActive", params).await {
if let Some(active_arr) = active_list.as_array() {
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).cloned();
if let Some(id) = id {
let total = status_info.get("totalLength").and_then(|s| s.as_str()).unwrap_or("0").parse::<u64>().unwrap_or(0);
let completed = status_info.get("completedLength").and_then(|s| s.as_str()).unwrap_or("0").parse::<u64>().unwrap_or(0);
let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::<f64>().unwrap_or(0.0);
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,
fraction,
speed,
eta,
size,
size_is_final: false,
});
}
}
}
}
}
});
let ext_app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Err(error) = extension_server::start_server(
ext_app_handle,
server_pairing_token.clone(),
server_frontend_ready.clone(),
server_extension_port.clone(),
extension_server_shutdown_rx,
).await {
eprintln!("Browser extension server unavailable: {error}");
}
});
Ok(())
})
.plugin(
tauri_plugin_log::Builder::new()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: None }),
])
.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)
.build(),
)
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::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,
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,
hydrate_extension_pairing_token, grant_keychain_access, acknowledge_pairing_token_change,
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
detach_download_for_reconfigure,
enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order,
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
])
.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::<AppState>();
let _ = state.extension_server_shutdown.send(true);
}
});
}
mod extension_server;
mod db;
mod scheduler;