mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 20:40:12 +00:00
262633f441
- Fix 'Unknown size' for YouTube videos by improving media size estimation fallback - Optimize yt-dlp metadata fetch by isolating PATH to bundled binaries - Fix dull slider color in Add window using Tailwind accent class - Fix Add window failing to fetch metadata automatically upon re-opening via paste
3649 lines
138 KiB
Rust
3649 lines
138 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 {
|
|
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 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;
|
|
}
|
|
|
|
let Some(height) = format_height(value) else {
|
|
return false;
|
|
};
|
|
|
|
let tolerance = if target >= 2160 { 600 } else if target >= 1440 { 400 } else if target >= 1080 { 300 } else if target >= 720 { 200 } else { 100 };
|
|
height <= target && height.saturating_add(tolerance) >= 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 estimated_merged_size(video: Option<&serde_json::Value>, audio: Option<&serde_json::Value>) -> (Option<u64>, Option<u64>) {
|
|
let v_bytes = video.and_then(media_sized_bytes);
|
|
let a_bytes = audio.and_then(media_sized_bytes);
|
|
|
|
match (v_bytes, a_bytes) {
|
|
(Some((v_size, v_approx)), Some((a_size, a_approx))) => {
|
|
split_size_estimate(Some((v_size.saturating_add(a_size), v_approx || a_approx)))
|
|
}
|
|
(Some((v_size, _v_approx)), None) => split_size_estimate(Some((v_size, true))),
|
|
(None, Some((a_size, _a_approx))) => split_size_estimate(Some((a_size, true))),
|
|
(None, None) => (None, None),
|
|
}
|
|
}
|
|
|
|
fn raw_media_format(value: &serde_json::Value) -> 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(media_sized_bytes(value));
|
|
|
|
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]) -> 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 best_video = best_matching_format(&clean_formats, has_video_stream);
|
|
let best_audio = best_audio_format(&clean_formats, None);
|
|
let (filesize, filesize_approx) = estimated_merged_size(best_video, best_audio);
|
|
options.push(MediaFormat {
|
|
format_id: "bestvideo+bestaudio/best".to_string(),
|
|
resolution: "Best".to_string(),
|
|
ext: "mkv".to_string(),
|
|
format_label: joined_format_label("mkv", best_video.and_then(|format| json_str(format, "vcodec")), best_audio.and_then(|format| json_str(format, "acodec"))),
|
|
fps: best_video.and_then(|format| json_f64(format, "fps")),
|
|
filesize,
|
|
filesize_approx,
|
|
});
|
|
|
|
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 = best_audio_format(&clean_formats, None);
|
|
let (filesize, filesize_approx) = estimated_merged_size(Some(video), audio);
|
|
options.push(MediaFormat {
|
|
format_id: 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 = 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);
|
|
options.push(MediaFormat {
|
|
format_id: 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 = 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);
|
|
options.push(MediaFormat {
|
|
format_id: 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(media_sized_bytes(audio));
|
|
options.push(MediaFormat {
|
|
format_id: "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(media_sized_bytes(audio));
|
|
options.push(MediaFormat {
|
|
format_id: "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(media_sized_bytes(audio));
|
|
options.push(MediaFormat {
|
|
format_id: "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)).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 fraction = 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()
|
|
}
|
|
}
|
|
|
|
async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
|
|
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);
|
|
|
|
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(¤t_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(¤t_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(¤t_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(¤t_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(¤t_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 { 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);
|
|
|
|
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()
|
|
}
|
|
|
|
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()));
|
|
if let Some((cached_at, metadata)) = cache.lock().await.get(&cache_key).cloned() {
|
|
if cached_at.elapsed() <= MEDIA_METADATA_CACHE_TTL {
|
|
return Ok(metadata);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
if let Some((cached_at, metadata)) = cache.lock().await.get(&cache_key).cloned() {
|
|
if cached_at.elapsed() <= MEDIA_METADATA_CACHE_TTL {
|
|
return Ok(metadata);
|
|
}
|
|
}
|
|
|
|
let metadata = fetch_media_metadata_uncached(
|
|
app_handle,
|
|
url,
|
|
cookie_browser,
|
|
username,
|
|
password,
|
|
)
|
|
.await?;
|
|
|
|
if metadata.formats.is_empty() {
|
|
return Err("yt-dlp returned no usable media formats for this URL".to_string());
|
|
}
|
|
|
|
cache
|
|
.lock()
|
|
.await
|
|
.insert(cache_key, (Instant::now(), metadata.clone()));
|
|
|
|
Ok(metadata)
|
|
}
|
|
|
|
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> {
|
|
// Resolve bundled deno and ffmpeg binaries and create a temporary PATH for yt-dlp
|
|
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 bin_dir = tempfile::tempdir().map_err(|e| format!("failed to create bundling temp dir: {e}"))?;
|
|
{
|
|
use std::os::unix::fs::symlink;
|
|
symlink(&deno_path, bin_dir.path().join("deno")).map_err(|e| format!("failed to symlink deno: {e}"))?;
|
|
symlink(&ffmpeg_path, bin_dir.path().join("ffmpeg")).map_err(|e| format!("failed to symlink ffmpeg: {e}"))?;
|
|
}
|
|
let bin_dir_str = bin_dir.path().to_string_lossy().to_string();
|
|
let path_env = format!("{}:/usr/bin:/bin", bin_dir_str);
|
|
|
|
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", &path_env)
|
|
.arg("--ffmpeg-location").arg(&bin_dir_str)
|
|
.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 = value.get("duration").and_then(|v| v.as_f64()).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))
|
|
.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.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
|
|
return false;
|
|
}
|
|
|
|
if !path.is_absolute() {
|
|
return true;
|
|
}
|
|
|
|
let mut allowed_prefixes = Vec::new();
|
|
use tauri::Manager;
|
|
if let Ok(home) = app_handle.path().home_dir() {
|
|
allowed_prefixes.push(home.join("Downloads"));
|
|
allowed_prefixes.push(home.join("Music"));
|
|
allowed_prefixes.push(home.join("Movies"));
|
|
allowed_prefixes.push(home.join("Pictures"));
|
|
allowed_prefixes.push(home.join("Documents"));
|
|
allowed_prefixes.push(home.join("Desktop"));
|
|
}
|
|
allowed_prefixes.push(std::path::PathBuf::from("/Volumes"));
|
|
|
|
for prefix in allowed_prefixes {
|
|
if path.starts_with(&prefix) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
|
use tauri_plugin_opener::OpenerExt;
|
|
|
|
let resolved_dest = resolve_path(&path, &app);
|
|
|
|
if !is_safe_path(&resolved_dest, &app) {
|
|
return Err("Path traversal blocked".to_string());
|
|
}
|
|
|
|
app.opener().open_path(resolved_dest.to_string_lossy().as_ref(), None::<String>).map_err(|e| format!("Failed to open file: {}", e))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn show_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
|
use tauri_plugin_opener::OpenerExt;
|
|
|
|
let resolved_dest = resolve_path(&path, &app);
|
|
|
|
if !is_safe_path(&resolved_dest, &app) {
|
|
return Err("Path traversal blocked".to_string());
|
|
}
|
|
|
|
app.opener().reveal_item_in_dir(resolved_dest.to_string_lossy().as_ref()).map_err(|e| format!("Failed to reveal in folder: {}", e))
|
|
}
|
|
|
|
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 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_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 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("~/") {
|
|
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;
|
|
|
|
fn parse_firelink_urls(deep_links: impl IntoIterator<Item = url::Url>) -> Vec<String> {
|
|
let mut captured = Vec::new();
|
|
|
|
for deep_link in deep_links {
|
|
if deep_link.scheme() != "firelink" || deep_link.host_str() != Some("add") {
|
|
continue;
|
|
}
|
|
|
|
let Some(raw_urls) = deep_link
|
|
.query_pairs()
|
|
.find_map(|(key, value)| (key == "url").then(|| value.into_owned()))
|
|
else {
|
|
continue;
|
|
};
|
|
if raw_urls.is_empty() || raw_urls.chars().count() >= MAX_DEEP_LINK_PAYLOAD_LEN {
|
|
continue;
|
|
}
|
|
|
|
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 {
|
|
return captured;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 urls = parse_firelink_urls(deep_links);
|
|
if urls.is_empty() {
|
|
return;
|
|
}
|
|
|
|
restore_main_window(&app_handle);
|
|
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 diagnostics ──────────────
|
|
|
|
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 NORMAL_VERSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
|
const YTDLP_FIRST_RUN_GRACE: std::time::Duration = std::time::Duration::from_secs(40);
|
|
|
|
let result = match tokio::time::timeout(NORMAL_VERSION_TIMEOUT, &mut output_future).await {
|
|
Ok(result) => Ok(result),
|
|
Err(_) if sidecar_name == "yt-dlp" && !ytdlp_expects_internal_dir(&binary_path) => {
|
|
log::info!(
|
|
"Bundled yt-dlp version check exceeded {} seconds; allowing {} seconds for standalone first-run initialization",
|
|
NORMAL_VERSION_TIMEOUT.as_secs(),
|
|
YTDLP_FIRST_RUN_GRACE.as_secs()
|
|
);
|
|
tokio::time::timeout(YTDLP_FIRST_RUN_GRACE, &mut output_future)
|
|
.await
|
|
.map_err(|_| NORMAL_VERSION_TIMEOUT + YTDLP_FIRST_RUN_GRACE)
|
|
}
|
|
Err(_) => Err(NORMAL_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 ytdlp_expects_internal_dir(binary_path: &std::path::Path) -> bool {
|
|
binary_path
|
|
.parent()
|
|
.is_some_and(|parent| parent.join("_internal").is_dir())
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
fn arch_suffix() -> &'static str {
|
|
if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }
|
|
}
|
|
|
|
async fn check_aria2(app_handle: &tauri::AppHandle, port: u16, secret: &str) -> EngineStatusItem {
|
|
let sidecar_name = "aria2c";
|
|
let expected_sidecar = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
|
|
|
|
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 = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
|
|
|
|
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 = 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 = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
|
|
|
|
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 = format!("{}-{}-apple-darwin", sidecar_name, arch_suffix());
|
|
|
|
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> {
|
|
let full_name = format!(
|
|
"{}-{}-apple-darwin",
|
|
binary_name,
|
|
if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" },
|
|
);
|
|
|
|
// Production: use the resource copy as the canonical engine location.
|
|
if let Ok(resource_dir) = app_handle.path().resource_dir() {
|
|
for candidate in [
|
|
resource_dir.join("binaries").join(&full_name),
|
|
resource_dir.join(&full_name),
|
|
] {
|
|
if candidate.is_file() {
|
|
log::info!("Resolved bundled '{}' at: {:?}", binary_name, candidate);
|
|
return Ok(candidate);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Packaged macOS fallback: resolve directly from Contents/MacOS to
|
|
// Contents/Resources when Tauri's resource_dir is unavailable.
|
|
if let Ok(exe_path) = std::env::current_exe() {
|
|
if let Some(contents_dir) = exe_path.parent().and_then(std::path::Path::parent) {
|
|
let candidate = contents_dir
|
|
.join("Resources")
|
|
.join("binaries")
|
|
.join(&full_name);
|
|
if candidate.is_file() {
|
|
log::info!("Resolved bundled '{}' at: {:?}", binary_name, candidate);
|
|
return Ok(candidate);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dev mode: search relative to CWD
|
|
if let Ok(cwd) = std::env::current_dir() {
|
|
let search_dirs = [
|
|
cwd.join("binaries"),
|
|
cwd.join("src-tauri").join("binaries"),
|
|
];
|
|
for dir in &search_dirs {
|
|
let candidate = dir.join(&full_name);
|
|
if candidate.is_file() {
|
|
let abs = candidate.canonicalize().map_err(|e| {
|
|
format!("Failed to canonicalize '{}': {}", full_name, e)
|
|
})?;
|
|
log::info!("Resolved bundled '{}' at: {:?}", binary_name, abs);
|
|
return Ok(abs);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compatibility fallback for older bundles produced with externalBin.
|
|
if let Ok(exe_path) = std::env::current_exe() {
|
|
if let Some(exe_dir) = exe_path.parent() {
|
|
let candidate = exe_dir.join(binary_name);
|
|
if candidate.is_file() {
|
|
log::info!("Resolved legacy bundled '{}' at: {:?}", binary_name, candidate);
|
|
return Ok(candidate);
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(format!(
|
|
"Could not find bundled binary '{}' (expected name: {})",
|
|
binary_name, full_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 = std::path::Path::new(&filename.replace('\\', "/"))
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("download")
|
|
.to_string();
|
|
|
|
|
|
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);
|
|
|
|
// Create a temp directory with bare-name symlinks so yt-dlp finds the
|
|
// bundled binaries via PATH when told --downloader aria2c (bare name).
|
|
let bin_dir = tempfile::tempdir().map_err(|e| format!("failed to create bundling temp dir: {e}"))?;
|
|
{
|
|
use std::os::unix::fs::symlink;
|
|
symlink(&aria2c_path, bin_dir.path().join("aria2c"))
|
|
.map_err(|e| format!("failed to symlink aria2c: {e}"))?;
|
|
symlink(&ffmpeg_path, bin_dir.path().join("ffmpeg"))
|
|
.map_err(|e| format!("failed to symlink ffmpeg: {e}"))?;
|
|
symlink(&deno_path, bin_dir.path().join("deno"))
|
|
.map_err(|e| format!("failed to symlink deno: {e}"))?;
|
|
}
|
|
let bin_dir_str = bin_dir.path().to_string_lossy().to_string();
|
|
// Minimal PATH: bundled dir first, then only essential system paths.
|
|
// No user-writable or Homebrew paths that could shadow our binaries.
|
|
let path_env = format!("{}:/usr/bin:/bin", bin_dir_str);
|
|
|
|
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")
|
|
.arg("--downloader-args").arg("aria2c:-c -x 16 -s 16 -k 1M --summary-interval=1")
|
|
.arg("--ffmpeg-location").arg(&bin_dir_str)
|
|
.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", &path_env);
|
|
|
|
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() {
|
|
if !p.is_empty() {
|
|
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) {
|
|
if progress.fraction < last_fraction && (last_fraction - progress.fraction) > 0.5 {
|
|
current_track += 1.0;
|
|
last_speed_sample = None;
|
|
}
|
|
last_fraction = progress.fraction;
|
|
let speed = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample);
|
|
|
|
let overall_fraction = ((current_track + progress.fraction) / total_tracks).min(1.0);
|
|
|
|
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) {
|
|
if progress.fraction < last_fraction && (last_fraction - progress.fraction) > 0.5 {
|
|
current_track += 1.0;
|
|
last_speed_sample = None;
|
|
}
|
|
last_fraction = progress.fraction;
|
|
let speed = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample);
|
|
|
|
let overall_fraction = ((current_track + progress.fraction) / total_tracks).min(1.0);
|
|
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(());
|
|
}
|
|
|
|
use tauri::Emitter;
|
|
let _ = app_handle.emit(
|
|
"download-state",
|
|
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
|
|
);
|
|
|
|
if let Ok(download_id) = Uuid::parse_str(&id) {
|
|
let _ = state
|
|
.download_coordinator
|
|
.send(download::DownloadCmd::Pause(download_id))
|
|
.await;
|
|
}
|
|
let media_result = state.download_coordinator.pause_media(id.clone()).await;
|
|
if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
|
|
state.queue_manager.release_permit(&id).await;
|
|
}
|
|
media_result
|
|
}
|
|
|
|
#[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" => {
|
|
let acquired = state.queue_manager.ensure_aria2_permit(&id).await;
|
|
let result = match rpc_call(
|
|
state.aria2_port,
|
|
&state.aria2_secret,
|
|
"aria2.unpause",
|
|
serde_json::json!([gid]),
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(error) => {
|
|
if acquired {
|
|
state.queue_manager.release_permit(&id).await;
|
|
}
|
|
return Err(format!("failed to resume aria2 gid {gid}: {error}"));
|
|
}
|
|
};
|
|
if let Err(error) = ensure_aria2_gid_result("unpause", &gid, &result) {
|
|
if acquired {
|
|
state.queue_manager.release_permit(&id).await;
|
|
}
|
|
return Err(error);
|
|
}
|
|
log::info!("aria2 resume [{}]: unpaused gid {}", id, gid);
|
|
}
|
|
"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,
|
|
filepath: Option<String>,
|
|
) -> Result<(), String> {
|
|
log::info!("remove_download called for id: {}", id);
|
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
|
state.queue_manager.release_registered_id(&id).await;
|
|
|
|
let 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);
|
|
if let Ok(download_id) = Uuid::parse_str(&id) {
|
|
state
|
|
.download_coordinator
|
|
.send(download::DownloadCmd::Cancel(download_id))
|
|
.await?;
|
|
}
|
|
state.download_coordinator.pause_media(id.clone()).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 let Some(path) = filepath {
|
|
if !path.is_empty() {
|
|
let p = std::path::Path::new(&path);
|
|
if is_safe_path(p, &app_handle) {
|
|
if p.exists() {
|
|
let _ = tokio::fs::remove_file(p).await;
|
|
}
|
|
let aria2_path = format!("{}.aria2", path);
|
|
let p_aria2 = std::path::Path::new(&aria2_path);
|
|
if p_aria2.exists() {
|
|
let _ = tokio::fs::remove_file(p_aria2).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) {
|
|
let mut current_preventer = state.sleep_preventer.lock().unwrap_or_else(|e| e.into_inner());
|
|
if prevent {
|
|
if current_preventer.is_none() {
|
|
if let Ok(keepawake) = keepawake::Builder::default().display(true).reason("Downloading files").create() {
|
|
*current_preventer = Some(keepawake);
|
|
}
|
|
}
|
|
} else {
|
|
*current_preventer = None;
|
|
}
|
|
}
|
|
|
|
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]
|
|
async fn get_pending_order(state: tauri::State<'_, AppState>) -> Result<Vec<String>, AppError> {
|
|
Ok(state.queue_manager.pending_order().await)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn enqueue_download(
|
|
app_handle: tauri::AppHandle,
|
|
state: tauri::State<'_, AppState>,
|
|
item: queue::EnqueueItem,
|
|
) -> Result<String, AppError> {
|
|
let id = item.id.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(id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn enqueue_many(
|
|
app_handle: tauri::AppHandle,
|
|
state: tauri::State<'_, AppState>,
|
|
items: Vec<queue::EnqueueItem>,
|
|
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
|
|
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,
|
|
direction: crate::ipc::QueueDirection,
|
|
) -> Result<Vec<String>, AppError> {
|
|
Ok(state.queue_manager.move_in_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 request_automation_permission() -> Result<(), String> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
use cocoa::foundation::NSString;
|
|
use cocoa::base::{nil, id};
|
|
use objc::{msg_send, sel, sel_impl, class};
|
|
|
|
unsafe {
|
|
objc::rc::autoreleasepool(|| {
|
|
let script_str = NSString::alloc(nil).init_str("tell application \"Finder\" 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 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 resolved_dest.starts_with(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,
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn hydrate_extension_pairing_token(
|
|
state: tauri::State<'_, crate::db::DbState>,
|
|
) -> Result<PairingTokenHydration, String> {
|
|
let mut connection = state.lock()?;
|
|
let (token, token_changed) = crate::db::hydrate_pairing_token(&mut connection)?;
|
|
Ok(PairingTokenHydration { token, token_changed })
|
|
}
|
|
|
|
#[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>, data: String) -> Result<(), String> {
|
|
let connection = state.lock()?;
|
|
crate::db::save_settings(&connection, &data)
|
|
}
|
|
|
|
#[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()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> {
|
|
let resolved_dest = resolve_path(&path, &app_handle);
|
|
if !is_safe_path(&resolved_dest, &app_handle) {
|
|
return Err("Path traversal blocked".to_string());
|
|
}
|
|
if resolved_dest.exists() {
|
|
std::fs::remove_file(resolved_dest).map_err(|e| e.to_string())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn export_logs(app_handle: tauri::AppHandle, dest_path: String) -> Result<String, String> {
|
|
use tauri::Manager;
|
|
let log_dir = app_handle.path().app_log_dir().map_err(|e| e.to_string())?;
|
|
let log_file = log_dir.join("firelink.log");
|
|
let src = if log_file.exists() {
|
|
log_file
|
|
} else {
|
|
let mut found = None;
|
|
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
if entry.path().extension().is_some_and(|e| e == "log") {
|
|
found = Some(entry.path());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
found.ok_or_else(|| "No log file found in app log directory".to_string())?
|
|
};
|
|
tokio::fs::copy(&src, &dest_path).await.map_err(|e| e.to_string())?;
|
|
Ok(dest_path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> {
|
|
use tauri::tray::TrayIconBuilder;
|
|
use tauri::menu::{Menu, MenuItem};
|
|
|
|
if show {
|
|
if app_handle.tray_by_id("main").is_none() {
|
|
let quit_i = MenuItem::with_id(&app_handle, "quit", "Quit", true, None::<&str>).map_err(|e| e.to_string())?;
|
|
let show_i = MenuItem::with_id(&app_handle, "show", "Show Firelink", true, None::<&str>).map_err(|e| e.to_string())?;
|
|
let menu = Menu::with_items(&app_handle, &[&show_i, &quit_i]).map_err(|e| e.to_string())?;
|
|
|
|
let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png"))
|
|
.map_err(|e| e.to_string())?;
|
|
let _tray = TrayIconBuilder::with_id("main")
|
|
.icon(tray_icon)
|
|
.icon_as_template(true)
|
|
.menu(&menu)
|
|
.show_menu_on_left_click(false)
|
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
|
"quit" => {
|
|
app.exit(0);
|
|
}
|
|
"show" => {
|
|
restore_main_window(app);
|
|
}
|
|
_ => {}
|
|
})
|
|
.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());
|
|
}
|
|
})
|
|
.build(&app_handle)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
} else {
|
|
if let Some(_tray) = app_handle.tray_by_id("main") {
|
|
let _ = app_handle.remove_tray_by_id("main");
|
|
}
|
|
}
|
|
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 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::{
|
|
build_media_format_options, collect_download_uris, is_excluded_yt_dlp_format, json_lower,
|
|
media_progress_speed, normalize_speed_limit_for_aria2, parse_firelink_urls,
|
|
parse_media_progress_line, MediaProgress, MEDIA_PROGRESS_PREFIX,
|
|
};
|
|
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 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_urls([deep_link]),
|
|
vec![
|
|
"https://example.com/one.zip",
|
|
"ftp://example.com/two.zip",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unexpected_deep_links_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(),
|
|
];
|
|
|
|
assert!(parse_firelink_urls(links).is_empty());
|
|
}
|
|
|
|
#[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);
|
|
|
|
assert!(!options.iter().any(|format| format.ext == "mhtml"));
|
|
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]
|
|
#[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 options = build_media_format_options(formats);
|
|
|
|
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 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,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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_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| {
|
|
use tauri::tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent};
|
|
use tauri::menu::{Menu, MenuItem};
|
|
|
|
let show_i = MenuItem::with_id(app, "show", "Show Firelink", true, None::<&str>).unwrap();
|
|
let pause_all_i = MenuItem::with_id(app, "pause_all", "Pause All", true, None::<&str>).unwrap();
|
|
let resume_all_i = MenuItem::with_id(app, "resume_all", "Resume All", true, None::<&str>).unwrap();
|
|
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap();
|
|
let menu = Menu::with_items(app, &[&show_i, &pause_all_i, &resume_all_i, &quit_i]).unwrap();
|
|
|
|
let tray_icon = tauri::image::Image::from_bytes(include_bytes!("../icons/trayTemplate.png")).unwrap();
|
|
let _tray = TrayIconBuilder::with_id("main")
|
|
.icon(tray_icon)
|
|
.icon_as_template(true)
|
|
.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| {
|
|
if matches!(
|
|
event,
|
|
TrayIconEvent::Click {
|
|
button: MouseButton::Left,
|
|
button_state: MouseButtonState::Up,
|
|
..
|
|
}
|
|
) {
|
|
restore_main_window(tray.app_handle());
|
|
}
|
|
})
|
|
.build(app)
|
|
.unwrap();
|
|
|
|
let database = crate::db::init(app.handle())
|
|
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
|
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 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_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)),
|
|
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();
|
|
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());
|
|
|
|
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("--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(),
|
|
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 }),
|
|
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
|
|
])
|
|
.level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { log::LevelFilter::Info })
|
|
.max_file_size(10_000_000)
|
|
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
|
|
.format(move |out, message, _record| {
|
|
let msg = message.to_string();
|
|
if msg.contains("[download]") && msg.contains('%') {
|
|
return;
|
|
}
|
|
out.finish(format_args!("{}\n", msg));
|
|
})
|
|
.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, open_file, show_in_folder,
|
|
pause_download, resume_download, fetch_metadata, fetch_media_metadata,
|
|
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
|
|
request_automation_permission, open_automation_settings,
|
|
set_keychain_password, get_keychain_password, delete_keychain_password,
|
|
hydrate_extension_pairing_token, acknowledge_pairing_token_change,
|
|
check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token,
|
|
set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
|
|
detach_download_for_reconfigure,
|
|
enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order,
|
|
commands::reveal_in_file_manager, commands::open_downloaded_file, commands::trash_download_assets,
|
|
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
|
|
parity::create_category_directories,
|
|
db_save_settings, db_load_settings, db_get_all_downloads, db_replace_downloads,
|
|
db_get_all_queues, db_replace_queues,
|
|
export_logs
|
|
])
|
|
.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;
|