// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ use tauri::{Manager, Emitter}; use tokio::process::Command as AsyncCommand; use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; use regex::Regex; use serde::Serialize; #[derive(Serialize)] struct MetadataResponse { filename: String, size: String, size_bytes: u64, } #[tauri::command] async fn fetch_metadata(url: String, user_agent: Option, username: Option, password: Option) -> Result { let mut builder = reqwest::Client::builder(); if let Some(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 client = builder.build().map_err(|e| e.to_string())?; if let Ok(parsed) = reqwest::Url::parse(&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)) { for addr in addrs { 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()); } match ip { std::net::IpAddr::V4(ipv4) if ipv4.is_private() || ipv4.is_link_local() => { return Err("SSRF blocked: Private/local IP not allowed".to_string()); } _ => {} } } } } } let mut head_req = client.head(&url); if let Some(ref user) = username { if !user.is_empty() { head_req = head_req.basic_auth(user, password.as_deref()); } } let mut res = head_req.send().await.map_err(|e| e.to_string())?; if !res.status().is_success() { let mut get_req = client.get(&url); if let Some(ref user) = username { if !user.is_empty() { get_req = get_req.basic_auth(user, password.as_deref()); } } res = get_req.send().await.map_err(|e| e.to_string())?; } 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..]; filename = rest.trim_matches('"').to_string(); } } } if filename.is_empty() { if let Ok(parsed) = reqwest::Url::parse(&url) { if let Some(segments) = parsed.path_segments() { if let Some(last) = segments.last() { filename = last.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::() { 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 }) } #[tauri::command] async fn fetch_media_metadata(app_handle: tauri::AppHandle, url: String, cookie_browser: Option, username: Option, password: Option) -> Result { println!("fetch_media_metadata called for: {}", url); let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let ytdlp_path = resource_dir.join("binaries").join("yt-dlp"); let mut cmd = AsyncCommand::new(&ytdlp_path); cmd.arg("-J") .arg("--no-warnings") .arg("--no-playlist") .arg("--no-check-formats") .arg("--socket-timeout").arg("20") .arg("--retries").arg("3") .arg("--extractor-retries").arg("3") .arg("--compat-options").arg("no-youtube-unavailable-videos") .arg("--js-runtimes").arg("deno,node"); if let Some(browser) = cookie_browser { if !browser.is_empty() { 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.arg("--config-locations").arg(&config_path); } cmd.arg(&url); // We use tokio AsyncCommand so it doesn't block the async thread let output = cmd.output() .await .map_err(|e| format!("Failed to execute yt-dlp: {}", e))?; if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).to_string(); Ok(text) } else { let err = String::from_utf8_lossy(&output.stderr); Err(format!("yt-dlp error: {}", err)) } } #[tauri::command] fn greet(name: &str) -> String { println!("greet called with name: {}", name); format!("Hello, {}! You've been greeted from Rust!", name) } #[tauri::command] async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result { println!("test_ytdlp called!"); let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let ytdlp_path = resource_dir.join("binaries").join("yt-dlp"); println!("Resolved yt-dlp path: {:?}", ytdlp_path); let output = AsyncCommand::new(&ytdlp_path) .arg("--version") .output() .await .map_err(|e| { println!("Failed to execute: {}", e); format!("Failed to execute yt-dlp: {}", e) })?; println!("yt-dlp execution finished with status: {}", output.status); if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); println!("yt-dlp output: {}", text); Ok(text) } else { let err = String::from_utf8_lossy(&output.stderr); println!("yt-dlp error output: {}", err); Err(format!("yt-dlp error: {}", err)) } } #[tauri::command] async fn test_aria2c(app_handle: tauri::AppHandle) -> Result { println!("test_aria2c called!"); let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let aria2c_path = resource_dir.join("binaries").join("aria2c"); println!("Resolved aria2c path: {:?}", aria2c_path); let output = AsyncCommand::new(&aria2c_path) .arg("--version") .output() .await .map_err(|e| { println!("Failed to execute: {}", e); format!("Failed to execute aria2c: {}", e) })?; println!("aria2c execution finished with status: {}", output.status); if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); // aria2c prints a lot, just get the first line for the version let first_line = text.lines().next().unwrap_or("").to_string(); let clean = first_line.replace("aria2 version ", ""); println!("aria2c output: {}", clean); Ok(clean) } else { let err = String::from_utf8_lossy(&output.stderr); println!("aria2c error output: {}", err); Err(format!("aria2c error: {}", err)) } } #[tauri::command] async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result { println!("test_ffmpeg called!"); let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let ffmpeg_path = resource_dir.join("binaries").join("ffmpeg"); println!("Resolved ffmpeg path: {:?}", ffmpeg_path); let output = AsyncCommand::new(&ffmpeg_path) .arg("-version") .output() .await .map_err(|e| { println!("Failed to execute: {}", e); format!("Failed to execute ffmpeg: {}", e) })?; println!("ffmpeg execution finished with status: {}", output.status); if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); let first_line = text.lines().next().unwrap_or("").to_string(); let parts: Vec<&str> = first_line.split_whitespace().collect(); let clean = parts.get(2).unwrap_or(&first_line.as_str()).split('-').next().unwrap_or("").to_string(); println!("ffmpeg output: {}", clean); Ok(clean) } else { let err = String::from_utf8_lossy(&output.stderr); println!("ffmpeg error output: {}", err); Err(format!("ffmpeg error: {}", err)) } } #[tauri::command] async fn test_deno(app_handle: tauri::AppHandle) -> Result { println!("test_deno called!"); let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let deno_path = resource_dir.join("binaries").join("deno"); println!("Resolved deno path: {:?}", deno_path); let output = AsyncCommand::new(&deno_path) .arg("--version") .output() .await .map_err(|e| { println!("Failed to execute: {}", e); format!("Failed to execute deno: {}", e) })?; println!("deno execution finished with status: {}", output.status); if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); let first_line = text.lines().next().unwrap_or("").to_string(); let parts: Vec<&str> = first_line.split_whitespace().collect(); let clean = parts.get(1).unwrap_or(&first_line.as_str()).to_string(); println!("deno output: {}", clean); Ok(clean) } else { let err = String::from_utf8_lossy(&output.stderr); println!("deno error output: {}", err); Err(format!("deno error: {}", err)) } } #[tauri::command] async fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> { println!("open_file called for path: {}", path); use tauri_plugin_opener::OpenerExt; app.opener().open_path(&path, None::).map_err(|e| format!("Failed to open file: {}", e)) } #[tauri::command] async fn show_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> { println!("show_in_folder called for path: {}", path); use tauri_plugin_opener::OpenerExt; app.opener().reveal_item_in_dir(&path).map_err(|e| format!("Failed to reveal in folder: {}", e)) } use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; struct Aria2DaemonGuard(std::sync::Mutex>); impl Drop for Aria2DaemonGuard { fn drop(&mut self) { if let Ok(mut lock) = self.0.lock() { if let Some(mut child) = lock.take() { let _ = child.kill(); } } } } mod parity; pub mod error; pub use error::AppError; pub enum TaskHandle { Aria2(String), Pid(u32), Queued, } pub struct AppState { pub tasks: Arc>>, pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_frontend_ready: extension_server::SharedFrontendReady, pub aria2_port: u16, pub aria2_secret: String, pub media_semaphore: Arc, pub sleep_preventer: Arc>>, } #[derive(Clone, Serialize)] struct DownloadProgressEvent { id: String, fraction: f64, speed: String, eta: String, } fn collect_download_uris(url: &str, mirrors: Option<&str>) -> Vec { let mut uris = Vec::new(); for uri in std::iter::once(url).chain(mirrors.into_iter().flat_map(str::lines)) { let uri = uri.trim(); if !uri.is_empty() && !uris.iter().any(|existing| existing == uri) { uris.push(uri.to_string()); } } uris } async fn rpc_call(port: u16, secret: &str, method: &str, params: serde_json::Value) -> Result { 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::new(); 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())?; Ok(json) } #[tauri::command] async fn start_download( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, url: String, destination: String, filename: String, connections: Option, speed_limit: Option, username: Option, password: Option, headers: Option, checksum: Option, cookies: Option, mirrors: Option, user_agent: Option, max_tries: Option, proxy: Option, ) -> Result<(), AppError> { println!("start_download called for id: {}", id); let state_aria2_port = state.aria2_port; let state_aria2_secret = state.aria2_secret.clone(); let tasks_map = state.tasks.clone(); let mut resolved_dest = std::path::PathBuf::from(&destination); if destination.starts_with("~/") { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home.join(&destination[2..]); } } else if destination == "~" { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home; } } if !resolved_dest.exists() { let _ = tokio::fs::create_dir_all(&resolved_dest).await; } let gid: String = id.replace("-", "").chars().take(16).collect(); // ensure exactly 16 chars let gid = format!("{:0<16}", gid); tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Aria2(gid.clone())); let mut options = serde_json::Map::new(); options.insert("gid".to_string(), serde_json::json!(gid)); options.insert("dir".to_string(), serde_json::json!(resolved_dest.to_string_lossy().to_string())); options.insert("out".to_string(), serde_json::json!(filename)); if let Some(conn) = connections { options.insert("split".to_string(), serde_json::json!(conn.to_string())); options.insert("max-connection-per-server".to_string(), serde_json::json!(conn.to_string())); } if let Some(limit) = speed_limit { if !limit.is_empty() { options.insert("max-download-limit".to_string(), serde_json::json!(limit)); } } if let Some(user) = username { if !user.is_empty() { options.insert("http-user".to_string(), serde_json::json!(user)); options.insert("ftp-user".to_string(), serde_json::json!(user)); if let Some(pass) = password { options.insert("http-passwd".to_string(), serde_json::json!(pass)); options.insert("ftp-passwd".to_string(), serde_json::json!(pass)); } } } let mut hdrs = Vec::new(); if let Some(hdr) = headers { for header in hdr.lines().map(str::trim).filter(|h| !h.is_empty()) { hdrs.push(header.to_string()); } } if let Some(cks) = cookies { if !cks.is_empty() { hdrs.push(format!("Cookie: {}", cks)); } } if !hdrs.is_empty() { options.insert("header".to_string(), serde_json::json!(hdrs)); } if let Some(p) = proxy { if !p.is_empty() { options.insert("all-proxy".to_string(), serde_json::json!(p)); } } if let Some(ua) = user_agent { if !ua.is_empty() { options.insert("user-agent".to_string(), serde_json::json!(ua)); } } if let Some(chk) = checksum { if !chk.is_empty() { options.insert("checksum".to_string(), serde_json::json!(chk)); } } if let Some(tries) = max_tries { options.insert("max-tries".to_string(), serde_json::json!(tries.to_string())); } let uris = collect_download_uris(&url, mirrors.as_deref()); let _ = rpc_call(state_aria2_port, &state_aria2_secret, "aria2.addUri", serde_json::json!([uris, options])).await?; Ok(()) } #[tauri::command] async fn start_media_download( app_handle: tauri::AppHandle, state: tauri::State<'_, AppState>, id: String, url: String, destination: String, filename: String, format_selector: Option, cookie_source: Option, speed_limit: Option, username: Option, password: Option, headers: Option, proxy: Option, user_agent: Option, max_tries: Option, ) -> Result<(), String> { let tasks_map = state.tasks.clone(); let media_semaphore = state.media_semaphore.clone(); // Mark task as queued tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Queued); let id_clone = id.clone(); tauri::async_runtime::spawn(async move { // Wait in queue via semaphore let permit = media_semaphore.acquire().await; // Check if user cancelled the task while it was waiting in queue { let map = tasks_map.lock().unwrap(); if !map.contains_key(&id_clone) { return; } } let _ = start_media_download_internal( app_handle, tasks_map, id_clone, url, destination, filename, format_selector, cookie_source, speed_limit, username, password, headers, proxy, user_agent, max_tries, ).await; drop(permit); // Release semaphore permit }); Ok(()) } pub(crate) async fn start_media_download_internal( app_handle: tauri::AppHandle, tasks_map: Arc>>, id: String, url: String, destination: String, filename: String, format_selector: Option, cookie_source: Option, speed_limit: Option, username: Option, password: Option, headers: Option, proxy: Option, user_agent: Option, max_tries: Option, ) -> Result<(), String> { println!("start_media_download called for id: {}", id); let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let ytdlp_path = resource_dir.join("binaries").join("yt-dlp"); let ffmpeg_path = resource_dir.join("binaries").join("ffmpeg"); let mut resolved_dest = std::path::PathBuf::from(&destination); if destination.starts_with("~/") { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home.join(&destination[2..]); } } else if destination == "~" { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home; } } if !resolved_dest.exists() { let _ = tokio::fs::create_dir_all(&resolved_dest).await; } let out_path = resolved_dest.join(&filename); let total_tracks: f64 = if let Some(ref format) = format_selector { if format.contains('+') { 2.0 } else { 1.0 } } else { 1.0 }; let mut cmd = AsyncCommand::new(&ytdlp_path); cmd.arg("--newline") .arg("--ffmpeg-location") .arg(&ffmpeg_path) .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:-x 16 -s 16 -k 1M") .arg("--concurrent-fragments").arg("4") .arg("-o").arg(out_path.to_string_lossy().to_string()); if let Some(limit) = speed_limit { if !limit.is_empty() { cmd.arg("--limit-rate").arg(limit); } } if let Some(p) = proxy { if !p.is_empty() { cmd.arg("--proxy").arg(p); } } if let Some(mut cs) = cookie_source { if !cs.is_empty() && cs != "none" { if cs == "safari" { cs = "safari:".to_string() } cmd.arg("--cookies-from-browser").arg(cs); } } if let Some(ua) = user_agent { if !ua.is_empty() { cmd.arg("--user-agent").arg(ua); } } if let Some(tries) = max_tries { cmd.arg("--retries").arg(tries.to_string()); } 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(); if !config_content.is_empty() { cmd.arg("--config-locations").arg(&config_path); } if let Some(format) = format_selector { cmd.arg("-f").arg(format); // If the filename implies an audio format, use it as audio output if filename.ends_with(".mp3") { cmd.arg("-x").arg("--audio-format").arg("mp3"); } else if filename.ends_with(".m4a") { cmd.arg("-x").arg("--audio-format").arg("m4a"); } else if filename.ends_with(".opus") { cmd.arg("-x").arg("--audio-format").arg("opus"); } else { // Otherwise attempt to merge into mp4 or mkv based on filename if filename.ends_with(".mp4") { cmd.arg("--merge-output-format").arg("mp4"); } else if filename.ends_with(".webm") { cmd.arg("--merge-output-format").arg("webm"); } else { cmd.arg("--merge-output-format").arg("mkv"); } } } cmd.arg(&url); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn yt-dlp: {}", e))?; let pid = child.id().unwrap_or(0); // Update task handle from Queued to Pid tasks_map.lock().unwrap().insert(id.clone(), TaskHandle::Pid(pid)); let stdout = child.stdout.take().unwrap(); let app_handle_clone = app_handle.clone(); let id_clone = id.clone(); // yt-dlp parsing regex let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap(); let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap(); let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap(); tokio::spawn(async move { let _keep_alive = config_path; // Keep the temp file alive let mut reader = BufReader::new(stdout).lines(); let mut current_track: f64 = 0.0; let mut last_fraction: f64 = 0.0; loop { tokio::select! { line_result = reader.next_line() => { match line_result { Ok(Some(line)) => { if line.contains("[download]") && line.contains("%") { let fraction = pct_re.captures(&line) .and_then(|cap| cap.get(1)) .and_then(|m| m.as_str().parse::().ok()) .unwrap_or(0.0) / 100.0; if fraction < last_fraction && (last_fraction - fraction) > 0.5 { current_track += 1.0; } last_fraction = fraction; let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0); let speed = spd_re.captures(&line) .and_then(|cap| cap.get(1)) .map(|m| m.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); let eta = eta_re.captures(&line) .and_then(|cap| cap.get(1)) .map(|m| m.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { id: id_clone.clone(), fraction: overall_fraction, speed, eta, }); } } _ => break, } } status = child.wait() => { println!("child exit status: {:?}", status); if let Ok(exit_status) = status { if exit_status.success() { let _ = app_handle_clone.emit("download-complete", id_clone.clone()); } else { // If it exited with error, emit failed let _ = app_handle_clone.emit("download-failed", id_clone.clone()); } } break; } } } }); Ok(()) } #[tauri::command] async fn pause_download(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> { println!("pause_download called for id: {}", id); let handle_opt = state.tasks.lock().unwrap().remove(&id); if let Some(handle) = handle_opt { match handle { TaskHandle::Aria2(gid) => { let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.pause", serde_json::json!([gid])).await; } TaskHandle::Queued => { // If it was just queued, it's already removed from tasks map. // The waiting Tokio task will wake up, see it's missing, and abort silently. println!("Queued download {} aborted before starting", id); } TaskHandle::Pid(pid) => { if pid > 0 { use sysinfo::{System, Pid}; let mut sys = System::new_all(); sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true); let parent_pid = Pid::from_u32(pid); let mut to_kill = vec![parent_pid]; let mut idx = 0; while idx < to_kill.len() { let current = to_kill[idx]; for (proc_pid, proc) in sys.processes() { if let Some(p) = proc.parent() { if p == current { to_kill.push(*proc_pid); } } } idx += 1; } for p in to_kill.into_iter().rev() { if let Some(proc) = sys.process(p) { #[cfg(unix)] { if proc.kill_with(sysinfo::Signal::Term).is_none() { proc.kill(); // Fallback to SIGKILL } } #[cfg(windows)] proc.kill(); println!("Sent termination signal to pid: {}", p.as_u32()); } } } } } } Ok(()) } #[tauri::command] async fn remove_download(state: tauri::State<'_, AppState>, id: String, filepath: Option) -> Result<(), String> { println!("remove_download called for id: {}", id); // Check if it's aria2 first, so we can call remove instead of pause let mut is_aria2 = false; let mut gid_to_remove = String::new(); if let Some(TaskHandle::Aria2(gid)) = state.tasks.lock().unwrap().get(&id) { is_aria2 = true; gid_to_remove = gid.clone(); } if is_aria2 { state.tasks.lock().unwrap().remove(&id); let _ = rpc_call(state.aria2_port, &state.aria2_secret, "aria2.remove", serde_json::json!([gid_to_remove])).await; } else { let _ = pause_download(state, id).await; } if let Some(path) = filepath { if !path.is_empty() { let p = std::path::Path::new(&path); 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] 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(); 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; } } #[tauri::command] fn perform_system_action(action: String) -> Result<(), String> { match action.as_str() { "shutdown" => system_shutdown::shutdown().map_err(|e| e.to_string()), "restart" => system_shutdown::reboot().map_err(|e| e.to_string()), "sleep" => system_shutdown::sleep().map_err(|e| e.to_string()), _ => Err("Invalid action".to_string()) } } #[tauri::command] async fn set_concurrent_limit(state: tauri::State<'_, AppState>, limit: usize) -> Result<(), String> { let _ = rpc_call( state.aria2_port, &state.aria2_secret, "aria2.changeGlobalOption", serde_json::json!([{"max-concurrent-downloads": limit.to_string()}]) ).await; Ok(()) } #[tauri::command] async fn set_global_speed_limit(state: tauri::State<'_, AppState>, limit: Option) -> Result<(), String> { let limit_str = limit.unwrap_or_else(|| "0".to_string()); let _ = rpc_call( state.aria2_port, &state.aria2_secret, "aria2.changeGlobalOption", serde_json::json!([{"max-overall-download-limit": limit_str}]) ).await; Ok(()) } #[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 { 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()); } } return 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::) .map_err(|e| format!("Failed to open Automation settings: {}", e))?; return Ok(()); } #[cfg(not(target_os = "macos"))] Err("Automation settings are only available on macOS".to_string()) } #[tauri::command] fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result { use sysinfo::Disks; use tauri::Manager; let disks = Disks::new_with_refreshed_list(); let mut resolved_dest = std::path::PathBuf::from(&path); if path.starts_with("~/") { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home.join(&path[2..]); } } else if path == "~" { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home; } } // 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> { let entry = keyring::Entry::new("com.firelink.app", &id).map_err(|e| e.to_string())?; entry.set_password(&password).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] fn get_keychain_password(id: String) -> Result { let entry = keyring::Entry::new("com.firelink.app", &id).map_err(|e| e.to_string())?; entry.get_password().map_err(|e| e.to_string()) } #[tauri::command] fn delete_keychain_password(id: String) -> Result<(), String> { let entry = keyring::Entry::new("com.firelink.app", &id).map_err(|e| e.to_string())?; let _ = entry.delete_credential(); // Ignore error if it doesn't exist Ok(()) } #[tauri::command] fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool { use tauri::Manager; let mut resolved_dest = std::path::PathBuf::from(&path); if path.starts_with("~/") { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home.join(&path[2..]); } } else if path == "~" { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home; } } resolved_dest.exists() } #[tauri::command] fn delete_file(app_handle: tauri::AppHandle, path: String) -> Result<(), String> { use tauri::Manager; let mut resolved_dest = std::path::PathBuf::from(&path); if path.starts_with("~/") { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home.join(&path[2..]); } } else if path == "~" { if let Ok(home) = app_handle.path().home_dir() { resolved_dest = home; } } if resolved_dest.exists() { std::fs::remove_file(resolved_dest).map_err(|e| e.to_string()) } else { Ok(()) } } #[tauri::command] fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), String> { use tauri::tray::TrayIconBuilder; use tauri::menu::{Menu, MenuItem}; use tauri::Manager; 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")).unwrap(); let _tray = TrayIconBuilder::with_id("main") .icon(tray_icon) .icon_as_template(true) .menu(&menu) .on_menu_event(|app, event| match event.id.as_ref() { "quit" => { std::process::exit(0); } "show" => { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } } _ => {} }) .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); } #[cfg(test)] mod tests { use super::collect_download_uris; #[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", ] ); } } #[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 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 = format!("{:x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()); tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); } })) .plugin(tauri_plugin_deep_link::init()) .manage(AppState { tasks: Arc::new(Mutex::new(HashMap::new())), extension_pairing_token, extension_frontend_ready, aria2_port, aria2_secret: aria2_secret.clone(), media_semaphore: Arc::new(tokio::sync::Semaphore::new(3)), sleep_preventer: Arc::new(Mutex::new(None)), }) .setup(move |app| { let db_conn = crate::db::init_db(app.handle()).expect("Failed to init db"); app.manage(crate::db::DbState { conn: std::sync::Mutex::new(db_conn) }); crate::scheduler::spawn_scheduler(app.handle().clone()); let resource_dir = app.path().resource_dir().unwrap(); let aria2c_path = resource_dir.join("binaries").join("aria2c"); let mut cmd = std::process::Command::new(&aria2c_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("--allow-overwrite=false") .arg("--summary-interval=1") .arg("--console-log-level=warn") .arg("--download-result=hide") .arg("--check-certificate=false"); match cmd.spawn() { Ok(child) => { println!("Spawned global aria2c daemon on port {}", aria2_port); app.manage(Aria2DaemonGuard(std::sync::Mutex::new(Some(child)))); } Err(e) => eprintln!("Failed to spawn aria2c daemon: {}", e), } let app_handle_clone = app.handle().clone(); let aria2_port_clone = aria2_port; let aria2_secret_clone = aria2_secret.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(500)).await; let ws_url = format!("ws://127.0.0.1:{}/jsonrpc", aria2_port_clone); use futures_util::{StreamExt, SinkExt}; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; let (mut ws_stream, _) = match connect_async(&ws_url).await { Ok(stream) => stream, Err(e) => { eprintln!("Failed to connect to aria2 WebSocket: {}", e); return; } }; let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); loop { tokio::select! { _ = interval.tick() => { let req = serde_json::json!({ "jsonrpc": "2.0", "id": "progress", "method": "aria2.tellActive", "params": [ format!("token:{}", aria2_secret_clone), ["gid", "status", "completedLength", "totalLength", "downloadSpeed"] ] }); if let Ok(msg) = serde_json::to_string(&req) { let _ = ws_stream.send(Message::Text(msg.into())).await; } } msg = ws_stream.next() => { match msg { Some(Ok(Message::Text(text))) => { if let Ok(json) = serde_json::from_str::(text.as_str()) { let state = app_handle_clone.state::(); let tasks = state.tasks.clone(); // Process progress if json.get("id").and_then(|i| i.as_str()) == Some("progress") { let mut gid_to_id = HashMap::new(); { let map = tasks.lock().unwrap(); for (id, handle) in map.iter() { if let TaskHandle::Aria2(gid) = handle { gid_to_id.insert(gid.clone(), id.clone()); } } } if let Some(arr) = json.get("result").and_then(|r| r.as_array()) { for item in arr { if let Some(gid) = item.get("gid").and_then(|v| v.as_str()) { if let Some(id) = gid_to_id.get(gid) { let completed = item.get("completedLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0.0); let total = item.get("totalLength").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(1.0); let speed_bytes = item.get("downloadSpeed").and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()).unwrap_or(0.0); let fraction = if total > 0.0 { completed / total } else { 0.0 }; let speed = if speed_bytes > 1024.0 * 1024.0 { format!("{:.1} MB/s", speed_bytes / (1024.0 * 1024.0)) } else if speed_bytes > 1024.0 { format!("{:.1} KB/s", speed_bytes / 1024.0) } else { format!("{:.0} B/s", speed_bytes) }; let eta = if speed_bytes > 0.0 && total > completed { let seconds = (total - completed) / speed_bytes; if seconds > 3600.0 { format!("{:.0}h {:.0}m", seconds / 3600.0, (seconds % 3600.0) / 60.0) } else if seconds > 60.0 { format!("{:.0}m {:.0}s", seconds / 60.0, seconds % 60.0) } else { format!("{:.0}s", seconds) } } else { "-".to_string() }; let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { id: id.clone(), fraction, speed, eta, }); } } } } } // Process Events if let Some(method) = json.get("method").and_then(|m| m.as_str()) { if method == "aria2.onDownloadComplete" || method == "aria2.onDownloadError" { if let Some(params) = json.get("params").and_then(|p| p.as_array()) { if let Some(event_info) = params.get(0) { if let Some(gid) = event_info.get("gid").and_then(|g| g.as_str()) { let mut target_id = None; { let map = tasks.lock().unwrap(); for (id, handle) in map.iter() { if let TaskHandle::Aria2(task_gid) = handle { if task_gid == gid { target_id = Some(id.clone()); break; } } } } if let Some(id) = target_id { if method == "aria2.onDownloadComplete" { let _ = app_handle_clone.emit("download-complete", id.clone()); } else { let _ = app_handle_clone.emit("download-failed", id.clone()); } tasks.lock().unwrap().remove(&id); } } } } } } } } Some(Err(e)) => { eprintln!("WebSocket error: {}", e); break; } None => break, // Stream closed _ => {} // Ignore binary/ping/pong for now } } } } }); let ext_app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { match extension_server::start_server( ext_app_handle, server_pairing_token.clone(), server_frontend_ready.clone(), ).await { Ok(()) => println!( "Browser extension server listening on 127.0.0.1:{}", extension_server::EXTENSION_SERVER_PORT ), Err(error) => eprintln!("Browser extension server unavailable: {error}"), } }); Ok(()) }) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![ greet, test_ytdlp, test_aria2c, test_ffmpeg, test_deno, open_file, show_in_folder, start_download, start_media_download, pause_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, 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, parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, db_save_settings, db_load_settings, db_get_all_downloads, db_save_download, db_delete_download, db_get_all_queues, db_save_queue, db_delete_queue ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } mod extension_server; mod db; mod scheduler; #[tauri::command] fn db_save_settings(state: tauri::State, data: String) -> Result<(), String> { let conn = state.conn.lock().unwrap(); crate::db::save_settings(&conn, &data).map_err(|e| e.to_string()) } #[tauri::command] fn db_load_settings(state: tauri::State) -> Result, String> { let conn = state.conn.lock().unwrap(); crate::db::get_settings(&conn).map_err(|e| e.to_string()) } #[tauri::command] fn db_get_all_downloads(state: tauri::State) -> Result, String> { let conn = state.conn.lock().unwrap(); crate::db::get_all_downloads(&conn).map_err(|e| e.to_string()) } #[tauri::command] fn db_save_download(state: tauri::State, id: String, status: String, queue_id: String, data: String) -> Result<(), String> { let conn = state.conn.lock().unwrap(); crate::db::insert_download(&conn, &id, &status, &queue_id, &data).map_err(|e| e.to_string()) } #[tauri::command] fn db_delete_download(state: tauri::State, id: String) -> Result<(), String> { let conn = state.conn.lock().unwrap(); crate::db::delete_download(&conn, &id).map_err(|e| e.to_string()) } #[tauri::command] fn db_get_all_queues(state: tauri::State) -> Result, String> { let conn = state.conn.lock().unwrap(); crate::db::get_all_queues(&conn).map_err(|e| e.to_string()) } #[tauri::command] fn db_save_queue(state: tauri::State, id: String, data: String) -> Result<(), String> { let conn = state.conn.lock().unwrap(); crate::db::insert_queue(&conn, &id, &data).map_err(|e| e.to_string()) } #[tauri::command] fn db_delete_queue(state: tauri::State, id: String) -> Result<(), String> { let conn = state.conn.lock().unwrap(); crate::db::delete_queue(&conn, &id).map_err(|e| e.to_string()) }