diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index b81bc97..2d2783a 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", @@ -1655,6 +1656,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz", + "integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1ec6650..92e3536 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,6 +12,7 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-deep-link": "^2.4.9", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 32eb6e1..a080d94 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -484,6 +484,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "cookie" version = "0.18.1" @@ -577,6 +597,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -710,6 +736,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -779,6 +806,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1461,6 +1497,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1500,6 +1542,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1623,7 +1674,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -2527,6 +2578,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3089,6 +3150,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3823,17 +3894,21 @@ dependencies = [ name = "tauri-app" version = "0.1.0" dependencies = [ + "hmac", "keyring", "regex", "reqwest 0.12.28", "serde", "serde_json", + "sha2", "sysinfo", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tokio", ] @@ -3915,6 +3990,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -3998,6 +4094,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -4204,6 +4316,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5126,6 +5247,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-registry" version = "0.6.1" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 374ecf5..d232b48 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -29,4 +29,7 @@ reqwest = "0.12" tauri-plugin-notification = "2.3.3" sysinfo = "0.39.3" keyring = "3" - +hmac = "0.12" +sha2 = "0.10" +tauri-plugin-deep-link = "2" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 1c33bc2..abc3b22 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:window:allow-start-dragging", "opener:default", - "dialog:default" + "dialog:default", + "deep-link:default" ] } diff --git a/apps/desktop/src-tauri/src/extension_server.rs b/apps/desktop/src-tauri/src/extension_server.rs index 73be8b1..40aec60 100644 --- a/apps/desktop/src-tauri/src/extension_server.rs +++ b/apps/desktop/src-tauri/src/extension_server.rs @@ -1,57 +1,644 @@ -use std::net::TcpListener; -use std::io::{Read, Write}; -use std::thread; -use tauri::{AppHandle, Emitter}; +use hmac::{Hmac, Mac}; +use reqwest::Url; use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Emitter, Manager}; -#[derive(Deserialize, Serialize, Clone)] -struct ExtensionPayload { - url: String, - token: String, +pub const EXTENSION_SERVER_PORT: u16 = 23522; +const MAX_HEADER_BYTES: usize = 16 * 1024; +const MAX_REQUEST_BYTES: usize = 128 * 1024; +const MAX_URL_COUNT: usize = 200; +const SIGNATURE_MAX_AGE_MS: u64 = 60_000; + +type HmacSha256 = Hmac; +pub type SharedExtensionToken = Arc>; +pub type SharedFrontendReady = Arc; +type ReplayCache = Arc>>; + +#[derive(Deserialize)] +struct ExtensionRequest { + urls: Vec, + #[serde(default)] + referer: Option, + #[serde(default)] + silent: bool, + #[serde(default)] + filename: Option, } -pub fn start_server(app_handle: AppHandle) { - thread::spawn(move || { - let listener = match TcpListener::bind("127.0.0.1:23522") { - Ok(l) => l, - Err(_) => return, // Port might be in use, ignore for now - }; +#[derive(Clone, Serialize)] +struct ExtensionDownload { + urls: Vec, + referer: Option, + silent: bool, + filename: Option, +} +struct HttpRequest { + method: String, + path: String, + headers: HashMap, + body: Vec, +} + +impl HttpRequest { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .get(&name.to_ascii_lowercase()) + .map(String::as_str) + } +} + +#[derive(Debug)] +enum RequestError { + BadRequest, + LengthRequired, + PayloadTooLarge, +} + +#[derive(Clone, Copy)] +enum HttpStatus { + Ok, + NoContent, + BadRequest, + Forbidden, + NotFound, + MethodNotAllowed, + LengthRequired, + PayloadTooLarge, + UnsupportedMediaType, + ServiceUnavailable, + InternalServerError, +} + +impl HttpStatus { + fn code(self) -> u16 { + match self { + Self::Ok => 200, + Self::NoContent => 204, + Self::BadRequest => 400, + Self::Forbidden => 403, + Self::NotFound => 404, + Self::MethodNotAllowed => 405, + Self::LengthRequired => 411, + Self::PayloadTooLarge => 413, + Self::UnsupportedMediaType => 415, + Self::ServiceUnavailable => 503, + Self::InternalServerError => 500, + } + } + + fn reason(self) -> &'static str { + match self { + Self::Ok => "OK", + Self::NoContent => "No Content", + Self::BadRequest => "Bad Request", + Self::Forbidden => "Forbidden", + Self::NotFound => "Not Found", + Self::MethodNotAllowed => "Method Not Allowed", + Self::LengthRequired => "Length Required", + Self::PayloadTooLarge => "Payload Too Large", + Self::UnsupportedMediaType => "Unsupported Media Type", + Self::ServiceUnavailable => "Service Unavailable", + Self::InternalServerError => "Internal Server Error", + } + } +} + +pub fn start_server( + app_handle: AppHandle, + pairing_token: SharedExtensionToken, + frontend_ready: SharedFrontendReady, +) -> Result<(), String> { + let listener = TcpListener::bind(("127.0.0.1", EXTENSION_SERVER_PORT)) + .map_err(|error| format!("Failed to bind 127.0.0.1:{EXTENSION_SERVER_PORT}: {error}"))?; + let replay_cache = Arc::new(Mutex::new(HashMap::new())); + + thread::spawn(move || { for stream in listener.incoming() { match stream { - Ok(mut stream) => { - let mut buffer = [0; 1024]; - if let Ok(size) = stream.read(&mut buffer) { - let request = String::from_utf8_lossy(&buffer[..size]); - - // Parse simple HTTP POST - if request.starts_with("POST") { - if let Some(body_start) = request.find("\r\n\r\n") { - let body = &request[body_start + 4..]; - if let Ok(payload) = serde_json::from_str::(body) { - // Emit event to frontend - let _ = app_handle.emit("extension-add-download", payload); - - // Send success response - let response = "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\nContent-Type: application/json\r\n\r\n{\"success\":true}"; - let _ = stream.write_all(response.as_bytes()); - continue; - } - } - } - - // Handle OPTIONS for CORS - if request.starts_with("OPTIONS") { - let response = "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n\r\n"; - let _ = stream.write_all(response.as_bytes()); - } else { - let response = "HTTP/1.1 400 Bad Request\r\nAccess-Control-Allow-Origin: *\r\n\r\n"; - let _ = stream.write_all(response.as_bytes()); - } - } - } - Err(_) => {} + Ok(stream) => handle_connection( + stream, + &app_handle, + &pairing_token, + &frontend_ready, + &replay_cache, + ), + Err(error) => eprintln!("Browser extension connection failed: {error}"), } } }); + + Ok(()) +} + +fn handle_connection( + mut stream: TcpStream, + app_handle: &AppHandle, + pairing_token: &SharedExtensionToken, + frontend_ready: &SharedFrontendReady, + replay_cache: &ReplayCache, +) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); + + let request = match read_http_request(&mut stream) { + Ok(request) => request, + Err(RequestError::BadRequest) => { + write_response(&mut stream, HttpStatus::BadRequest, None); + return; + } + Err(RequestError::LengthRequired) => { + write_response(&mut stream, HttpStatus::LengthRequired, None); + return; + } + Err(RequestError::PayloadTooLarge) => { + write_response(&mut stream, HttpStatus::PayloadTooLarge, None); + return; + } + }; + + let origin = request.header("origin").map(str::to_string); + let status = process_request( + request, + app_handle, + pairing_token, + frontend_ready, + replay_cache, + ); + write_response( + &mut stream, + status, + origin.as_deref().filter(|value| is_allowed_origin(value)), + ); +} + +fn process_request( + request: HttpRequest, + app_handle: &AppHandle, + pairing_token: &SharedExtensionToken, + frontend_ready: &SharedFrontendReady, + replay_cache: &ReplayCache, +) -> HttpStatus { + if !is_local_host(request.header("host")) { + return HttpStatus::Forbidden; + } + + let origin = request.header("origin"); + if request.method == "OPTIONS" { + return if origin.is_some_and(is_allowed_origin) { + HttpStatus::NoContent + } else { + HttpStatus::Forbidden + }; + } + if origin.is_some_and(|value| !is_allowed_origin(value)) { + return HttpStatus::Forbidden; + } + + let timestamp = match verify_signature(&request, pairing_token) { + Ok(timestamp) => timestamp, + Err(()) => return HttpStatus::Forbidden, + }; + + if request.path == "/ping" { + return if request.method == "GET" { + if frontend_ready.load(Ordering::Acquire) { + HttpStatus::Ok + } else { + HttpStatus::ServiceUnavailable + } + } else { + HttpStatus::MethodNotAllowed + }; + } + + if request.path != "/download" { + return HttpStatus::NotFound; + } + if request.method != "POST" { + return HttpStatus::MethodNotAllowed; + } + if !request + .header("content-type") + .is_some_and(|value| value.to_ascii_lowercase().contains("application/json")) + { + return HttpStatus::UnsupportedMediaType; + } + + let payload = match serde_json::from_slice::(&request.body) { + Ok(payload) => payload, + Err(_) => return HttpStatus::BadRequest, + }; + let download = match normalize_download(payload) { + Some(download) => download, + None => return HttpStatus::BadRequest, + }; + if !frontend_ready.load(Ordering::Acquire) { + return HttpStatus::ServiceUnavailable; + } + + let signature = match request.header("x-firelink-signature") { + Some(signature) => signature, + None => return HttpStatus::Forbidden, + }; + if !claim_request(signature, timestamp, replay_cache) { + return HttpStatus::Forbidden; + } + + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + if app_handle.emit("extension-add-download", download).is_err() { + return HttpStatus::InternalServerError; + } + + HttpStatus::Ok +} + +fn normalize_download(payload: ExtensionRequest) -> Option { + let mut seen = HashSet::new(); + let urls = payload + .urls + .into_iter() + .take(MAX_URL_COUNT) + .filter_map(|raw_url| normalize_url(&raw_url)) + .filter(|url| seen.insert(url.clone())) + .collect::>(); + if urls.is_empty() { + return None; + } + + let referer = payload.referer.and_then(|value| { + let url = Url::parse(value.trim()).ok()?; + matches!(url.scheme(), "http" | "https").then(|| url.to_string()) + }); + let filename = payload.filename.and_then(|value| sanitize_filename(&value)); + + Some(ExtensionDownload { + urls, + referer, + silent: payload.silent, + filename, + }) +} + +fn normalize_url(raw_url: &str) -> Option { + let url = Url::parse(raw_url.trim()).ok()?; + matches!(url.scheme(), "http" | "https" | "ftp" | "sftp").then(|| url.to_string()) +} + +fn sanitize_filename(filename: &str) -> Option { + let normalized = filename.trim().replace('\\', "/"); + let basename = Path::new(&normalized).file_name()?.to_str()?.trim(); + if basename.is_empty() || basename == "." || basename == ".." || basename.len() > 255 { + return None; + } + Some(basename.to_string()) +} + +fn verify_signature( + request: &HttpRequest, + pairing_token: &SharedExtensionToken, +) -> Result { + let signature = decode_hex(request.header("x-firelink-signature").ok_or(())?)?; + let timestamp_text = request.header("x-firelink-timestamp").ok_or(())?; + let timestamp = timestamp_text.parse::().map_err(|_| ())?; + let now = current_time_millis().ok_or(())?; + if now.abs_diff(timestamp) >= SIGNATURE_MAX_AGE_MS { + return Err(()); + } + + let token = pairing_token.read().map_err(|_| ())?; + if token.is_empty() { + return Err(()); + } + + let mut mac = HmacSha256::new_from_slice(token.as_bytes()).map_err(|_| ())?; + mac.update(timestamp_text.as_bytes()); + mac.update(&request.body); + mac.verify_slice(&signature).map_err(|_| ())?; + Ok(timestamp) +} + +fn claim_request(signature: &str, timestamp: u64, replay_cache: &ReplayCache) -> bool { + let now = match current_time_millis() { + Some(now) => now, + None => return false, + }; + let mut cache = match replay_cache.lock() { + Ok(cache) => cache, + Err(_) => return false, + }; + cache.retain(|_, seen_at| now.saturating_sub(*seen_at) < SIGNATURE_MAX_AGE_MS); + let key = format!("{timestamp}:{}", signature.to_ascii_lowercase()); + cache.insert(key, now).is_none() +} + +fn current_time_millis() -> Option { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) +} + +fn decode_hex(value: &str) -> Result, ()> { + if value.len() != 64 || !value.is_ascii() { + return Err(()); + } + value + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let high = hex_digit(pair[0]).ok_or(())?; + let low = hex_digit(pair[1]).ok_or(())?; + Ok((high << 4) | low) + }) + .collect() +} + +fn hex_digit(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn is_local_host(host: Option<&str>) -> bool { + matches!( + host, + Some(value) + if value == format!("127.0.0.1:{EXTENSION_SERVER_PORT}") + || value == format!("localhost:{EXTENSION_SERVER_PORT}") + || value == "127.0.0.1" + || value == "localhost" + ) +} + +fn is_allowed_origin(origin: &str) -> bool { + Url::parse(origin) + .ok() + .is_some_and(|url| matches!(url.scheme(), "moz-extension" | "chrome-extension")) +} + +fn read_http_request(reader: &mut impl Read) -> Result { + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 4096]; + let mut expected_length = None; + + loop { + let read = reader + .read(&mut chunk) + .map_err(|_| RequestError::BadRequest)?; + if read == 0 { + break; + } + bytes.extend_from_slice(&chunk[..read]); + if bytes.len() > MAX_REQUEST_BYTES { + return Err(RequestError::PayloadTooLarge); + } + + if expected_length.is_none() { + if let Some(header_end) = find_bytes(&bytes, b"\r\n\r\n") { + if header_end > MAX_HEADER_BYTES { + return Err(RequestError::PayloadTooLarge); + } + let headers = parse_headers(&bytes[..header_end])?; + if headers.contains_key("transfer-encoding") { + return Err(RequestError::BadRequest); + } + let method = parse_request_line(&bytes[..header_end])?.0; + let content_length = parse_content_length(&headers)?; + if method == "POST" && content_length.is_none() { + return Err(RequestError::LengthRequired); + } + let body_length = content_length.unwrap_or(0); + let total_length = header_end + 4 + body_length; + if total_length > MAX_REQUEST_BYTES { + return Err(RequestError::PayloadTooLarge); + } + expected_length = Some(total_length); + } else if bytes.len() > MAX_HEADER_BYTES { + return Err(RequestError::PayloadTooLarge); + } + } + + if expected_length.is_some_and(|length| bytes.len() >= length) { + break; + } + } + + let header_end = find_bytes(&bytes, b"\r\n\r\n").ok_or(RequestError::BadRequest)?; + let (method, path) = parse_request_line(&bytes[..header_end])?; + let headers = parse_headers(&bytes[..header_end])?; + let body_length = parse_content_length(&headers)?.unwrap_or(0); + let body_start = header_end + 4; + let body_end = body_start + body_length; + if bytes.len() < body_end { + return Err(RequestError::BadRequest); + } + + Ok(HttpRequest { + method, + path, + headers, + body: bytes[body_start..body_end].to_vec(), + }) +} + +fn parse_request_line(header_bytes: &[u8]) -> Result<(String, String), RequestError> { + let headers = std::str::from_utf8(header_bytes).map_err(|_| RequestError::BadRequest)?; + let mut parts = headers + .lines() + .next() + .ok_or(RequestError::BadRequest)? + .split_whitespace(); + let method = parts.next().ok_or(RequestError::BadRequest)?; + let raw_path = parts.next().ok_or(RequestError::BadRequest)?; + let version = parts.next().ok_or(RequestError::BadRequest)?; + if parts.next().is_some() || !version.starts_with("HTTP/1.") { + return Err(RequestError::BadRequest); + } + let path = raw_path.split('?').next().unwrap_or_default(); + Ok((method.to_ascii_uppercase(), path.to_string())) +} + +fn parse_headers(header_bytes: &[u8]) -> Result, RequestError> { + let headers = std::str::from_utf8(header_bytes).map_err(|_| RequestError::BadRequest)?; + let mut parsed = HashMap::new(); + for line in headers.lines().skip(1) { + let (name, value) = line.split_once(':').ok_or(RequestError::BadRequest)?; + let name = name.trim().to_ascii_lowercase(); + if name.is_empty() || parsed.contains_key(&name) { + return Err(RequestError::BadRequest); + } + parsed.insert(name, value.trim().to_string()); + } + Ok(parsed) +} + +fn parse_content_length( + headers: &HashMap, +) -> Result, RequestError> { + headers + .get("content-length") + .map(|value| { + value + .parse::() + .map_err(|_| RequestError::BadRequest) + }) + .transpose() +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn write_response(stream: &mut impl Write, status: HttpStatus, origin: Option<&str>) { + let mut headers = vec![ + format!("HTTP/1.1 {} {}", status.code(), status.reason()), + "Content-Length: 0".to_string(), + "Connection: close".to_string(), + "X-Firelink-Server: 1".to_string(), + ]; + if let Some(origin) = origin { + headers.push(format!("Access-Control-Allow-Origin: {origin}")); + headers.push("Vary: Origin".to_string()); + headers.push("Access-Control-Allow-Methods: GET, POST, OPTIONS".to_string()); + headers.push( + "Access-Control-Allow-Headers: Content-Type, X-Firelink-Signature, X-Firelink-Timestamp" + .to_string(), + ); + headers.push("Access-Control-Allow-Private-Network: true".to_string()); + headers.push("Access-Control-Expose-Headers: X-Firelink-Server".to_string()); + } + let response = headers.join("\r\n") + "\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + struct ChunkedReader { + bytes: Cursor>, + chunk_size: usize, + } + + impl Read for ChunkedReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let limit = buffer.len().min(self.chunk_size); + self.bytes.read(&mut buffer[..limit]) + } + } + + fn token(value: &str) -> SharedExtensionToken { + Arc::new(RwLock::new(value.to_string())) + } + + fn sign(value: &str, timestamp: &str, body: &[u8]) -> String { + let mut mac = HmacSha256::new_from_slice(value.as_bytes()).unwrap(); + mac.update(timestamp.as_bytes()); + mac.update(body); + mac.finalize() + .into_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + #[test] + fn reads_fragmented_signed_request() { + let body = br#"{"urls":["https://example.com/file.zip"],"silent":true}"#; + let request = format!( + "POST /download HTTP/1.1\r\nHost: 127.0.0.1:6412\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + std::str::from_utf8(body).unwrap() + ); + let mut reader = ChunkedReader { + bytes: Cursor::new(request.into_bytes()), + chunk_size: 7, + }; + + let parsed = read_http_request(&mut reader).expect("request should parse"); + + assert_eq!(parsed.method, "POST"); + assert_eq!(parsed.path, "/download"); + assert_eq!(parsed.body, body); + } + + #[test] + fn verifies_hmac_over_timestamp_and_exact_body() { + let body = br#"{"urls":["https://example.com/file.zip"]}"#.to_vec(); + let timestamp = current_time_millis().unwrap().to_string(); + let signature = sign("secret", ×tamp, &body); + let request = HttpRequest { + method: "POST".to_string(), + path: "/download".to_string(), + headers: HashMap::from([ + ("x-firelink-signature".to_string(), signature), + ("x-firelink-timestamp".to_string(), timestamp), + ]), + body, + }; + + assert!(verify_signature(&request, &token("secret")).is_ok()); + assert!(verify_signature(&request, &token("wrong")).is_err()); + } + + #[test] + fn rejects_replayed_download_signature() { + let cache = Arc::new(Mutex::new(HashMap::new())); + let timestamp = current_time_millis().unwrap(); + + assert!(claim_request("signature", timestamp, &cache)); + assert!(!claim_request("signature", timestamp, &cache)); + } + + #[test] + fn normalizes_payload_and_sanitizes_filename() { + let download = normalize_download(ExtensionRequest { + urls: vec![ + "https://example.com/file.zip".to_string(), + "https://example.com/file.zip".to_string(), + "javascript:alert(1)".to_string(), + ], + referer: Some("https://example.com/page".to_string()), + silent: true, + filename: Some("../archive.zip".to_string()), + }) + .unwrap(); + + assert_eq!(download.urls, vec!["https://example.com/file.zip"]); + assert_eq!( + download.referer.as_deref(), + Some("https://example.com/page") + ); + assert_eq!(download.filename.as_deref(), Some("archive.zip")); + } + + #[test] + fn requires_content_length_for_post() { + let mut reader = + Cursor::new(b"POST /download HTTP/1.1\r\nHost: localhost\r\n\r\n{}".to_vec()); + + assert!(matches!( + read_http_request(&mut reader), + Err(RequestError::LengthRequired) + )); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 377b68a..76ff44d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -344,10 +344,13 @@ async fn show_in_folder(path: String) -> Result<(), String> { } use std::collections::HashMap; -use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; struct AppState { tasks: Mutex>, + extension_pairing_token: extension_server::SharedExtensionToken, + extension_frontend_ready: extension_server::SharedFrontendReady, } #[derive(Clone, Serialize)] @@ -358,6 +361,17 @@ struct DownloadProgressEvent { 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 +} + #[tauri::command] async fn start_download( app_handle: tauri::AppHandle, @@ -459,17 +473,8 @@ async fn start_download( } } - cmd.arg(&url); - - if let Some(m) = mirrors { - if !m.is_empty() { - for mirror_url in m.lines() { - let trimmed = mirror_url.trim(); - if !trimmed.is_empty() { - cmd.arg(trimmed); - } - } - } + for uri in collect_download_uris(&url, mirrors.as_deref()) { + cmd.arg(uri); } cmd.stdout(Stdio::piped()); @@ -556,6 +561,7 @@ async fn start_media_download( speed_limit: Option, username: Option, password: Option, + headers: 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())?; @@ -638,6 +644,11 @@ async fn start_media_download( cmd.arg("--password").arg(&pass); } } + if let Some(headers) = headers { + for header in headers.lines().map(str::trim).filter(|header| !header.is_empty()) { + cmd.arg("--add-headers").arg(header); + } + } cmd.arg(&url); cmd.stdout(Stdio::piped()); @@ -1028,15 +1039,92 @@ fn toggle_tray_icon(app_handle: tauri::AppHandle, show: bool) -> Result<(), Stri 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(); + tauri::Builder::default() - .setup(|app| { - extension_server::start_server(app.handle().clone()); - Ok(()) - }) + .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: Mutex::new(HashMap::new()), + extension_pairing_token, + extension_frontend_ready, + }) + .setup(move |app| { + match extension_server::start_server( + app.handle().clone(), + server_pairing_token.clone(), + server_frontend_ready.clone(), + ) { + 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()) @@ -1047,7 +1135,8 @@ pub fn run() { 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 + check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token, + set_extension_frontend_ready ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 9f70f86..92bc5d5 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "tauri-app", + "productName": "Firelink", "version": "0.1.0", - "identifier": "com.nima.tauri-app", + "identifier": "com.nimbold.firelink", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "tauri-app", + "title": "Firelink", "width": 1024, "height": 768, "minWidth": 800, @@ -39,5 +39,12 @@ "binaries/*", "binaries/**/*" ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["firelink"] + } + } } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7bc0592..29d9a54 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -9,6 +9,7 @@ import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore'; import { useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; import { invoke } from "@tauri-apps/api/core"; +import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; @@ -19,6 +20,22 @@ const localDateKey = (date: Date) => { return `${year}-${month}-${day}`; }; +const handleDeepLinks = (deepLinks: string[]) => { + for (const rawDeepLink of deepLinks) { + try { + const deepLink = new URL(rawDeepLink); + if (deepLink.protocol !== 'firelink:' || deepLink.hostname !== 'add') continue; + const urls = deepLink.searchParams.get('url') || ''; + if (urls.length > 0 && urls.length < 65_536) { + useDownloadStore.getState().openAddModalWithUrls(urls); + return; + } + } catch (error) { + console.warn('Ignored invalid Firelink deep link:', error); + } + } +}; + function App() { const [filter, setFilter] = useState('all'); const updateDownload = useDownloadStore(state => state.updateDownload); @@ -28,6 +45,7 @@ function App() { const appFontSize = useSettingsStore(state => state.appFontSize); const showDockBadge = useSettingsStore(state => state.showDockBadge); const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon); + const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken); const downloads = useDownloadStore(state => state.downloads); const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length; const queuedCount = downloads.filter(download => download.status === 'queued').length; @@ -48,6 +66,25 @@ function App() { invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error); }, [showMenuBarIcon]); + useEffect(() => { + invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => { + console.error('Failed to configure browser extension pairing token:', error); + }); + }, [extensionPairingToken]); + + useEffect(() => { + const unlisten = onOpenUrl(handleDeepLinks); + getCurrent() + .then(urls => { + if (urls) handleDeepLinks(urls); + }) + .catch(error => console.error('Failed to read startup deep link:', error)); + + return () => { + unlisten.then(dispose => dispose()); + }; + }, []); + useEffect(() => { if (previousSpeedLimit.current === globalSpeedLimit) return; previousSpeedLimit.current = globalSpeedLimit; @@ -178,16 +215,14 @@ function App() { }); const unlistenExtension = listen('extension-add-download', (event: any) => { - const { url, token } = event.payload; - const settings = useSettingsStore.getState(); - if (settings.extensionPairingToken && token === settings.extensionPairingToken) { - useDownloadStore.getState().openAddModalWithUrls(url); - } else { - console.warn('Extension add download rejected: invalid token'); - } + useDownloadStore.getState().handleExtensionDownload(event.payload); }); + unlistenExtension + .then(() => invoke('set_extension_frontend_ready', { ready: true })) + .catch(error => console.error('Failed to activate browser extension integration:', error)); return () => { + invoke('set_extension_frontend_ready', { ready: false }).catch(() => {}); unlistenProgress.then(f => f()); unlistenComplete.then(f => f()); unlistenFailed.then(f => f()); diff --git a/apps/desktop/src/components/AddDownloadsModal.tsx b/apps/desktop/src/components/AddDownloadsModal.tsx index 84faf1e..76fa112 100644 --- a/apps/desktop/src/components/AddDownloadsModal.tsx +++ b/apps/desktop/src/components/AddDownloadsModal.tsx @@ -1,22 +1,11 @@ import { useState, useEffect } from 'react'; import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; -import { DownloadCategory } from '../store/useDownloadStore'; import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; - -function determineCategory(fileName: string): DownloadCategory { - const ext = fileName.split('.').pop()?.toLowerCase() || ''; - if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'].includes(ext)) return 'Movies'; - if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].includes(ext)) return 'Musics'; - if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents'; - if (['exe', 'dmg', 'apk', 'app', 'pkg', 'deb', 'rpm', 'msi', 'iso', 'bin', 'run'].includes(ext)) return 'Applications'; - if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'].includes(ext)) return 'Pictures'; - if (['zip', 'rar', '7z', 'tar', 'gz', 'xz', 'bz2'].includes(ext)) return 'Compressed'; - return 'Other'; -} +import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads'; interface RawMediaFormat { format_id?: string; @@ -238,19 +227,16 @@ const parseMediaFormats = (jsonStr: string) => { } }; -const MEDIA_DOMAINS = ['youtube.com', 'youtu.be', 'twitter.com', 'x.com', 'twitch.tv', 'vimeo.com', 'instagram.com', 'tiktok.com', 'reddit.com', 'soundcloud.com', 'facebook.com']; -const isMediaUrl = (url: string) => { - try { - const u = new URL(url); - return MEDIA_DOMAINS.some(d => u.hostname.includes(d)); - } catch { - return false; - } -}; - - export const AddDownloadsModal = () => { - const { isAddModalOpen, pendingAddUrls, toggleAddModal, addDownload, queues } = useDownloadStore(); + const { + isAddModalOpen, + pendingAddUrls, + pendingAddReferer, + pendingAddFilename, + toggleAddModal, + addDownload, + queues + } = useDownloadStore(); const { defaultDownloadPath } = useSettingsStore(); const [selectedQueueId, setSelectedQueueId] = useState(MAIN_QUEUE_ID); @@ -290,8 +276,24 @@ export const AddDownloadsModal = () => { setParsedItems([]); setSelectedItemIndex(null); setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID); + setUseAuth(false); + setUsername(''); + setPassword(''); + setAdvancedExpanded(false); + setChecksumEnabled(false); + setChecksumAlgo('SHA-256'); + setChecksumValue(''); + setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : ''); + setCookies(''); + setMirrors(''); } - }, [isAddModalOpen, pendingAddUrls, defaultDownloadPath]); + }, [ + isAddModalOpen, + pendingAddUrls, + pendingAddReferer, + defaultDownloadPath, + queues + ]); useEffect(() => { if (!saveLocation) return; @@ -306,8 +308,9 @@ export const AddDownloadsModal = () => { // Immediately display items in loading state const initialItems: ParsedDownloadItem[] = lines.map(url => { - let fallbackFile = 'URL'; - try { fallbackFile = new URL(url).pathname.split('/').pop() || 'download'; } catch {} + const fallbackFile = lines.length === 1 && pendingAddFilename + ? pendingAddFilename + : fileNameFromUrl(url); return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) }; }); setParsedItems(initialItems); @@ -378,7 +381,13 @@ export const AddDownloadsModal = () => { username: login?.username || null, password: keychainPassword }); - updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' }; + updatedItems[i] = { + url, + file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename, + size: meta.size, + sizeBytes: meta.size_bytes, + status: 'Ready' + }; } if (firstReadyIndex === null) firstReadyIndex = i; } catch (e) { @@ -394,7 +403,7 @@ export const AddDownloadsModal = () => { }, 400); return () => clearTimeout(timer); - }, [urls]); // Re-fetch only on urls change + }, [urls, pendingAddFilename]); if (!isAddModalOpen) return null; @@ -563,13 +572,18 @@ export const AddDownloadsModal = () => { url: item.url, fileName: finalFile, status: startImmediately ? 'queued' : 'paused', - category: determineCategory(finalFile), + category: categoryForFileName(finalFile), dateAdded: new Date().toISOString(), connections: Number(connections), speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, username: useAuth ? username.trim() : undefined, password: useAuth ? password.trim() : undefined, headers: headers.trim() || undefined, + checksum: checksumEnabled && checksumValue.trim() + ? `${checksumAlgo}=${checksumValue.trim()}` + : undefined, + cookies: cookies.trim() || undefined, + mirrors: mirrors.trim() || undefined, destination: finalLocation, isMedia: item.isMedia, mediaFormatSelector: formatSelector, diff --git a/apps/desktop/src/components/SettingsView.tsx b/apps/desktop/src/components/SettingsView.tsx index f7b7026..5cc2b1c 100644 --- a/apps/desktop/src/components/SettingsView.tsx +++ b/apps/desktop/src/components/SettingsView.tsx @@ -777,7 +777,7 @@ export default function SettingsView() {

Paste & Connect

-

Click the Firelink icon in your browser's toolbar and paste thecopied token.

+

Click the Firelink icon in your browser's toolbar and paste the copied token.

diff --git a/apps/desktop/src/store/useDownloadStore.ts b/apps/desktop/src/store/useDownloadStore.ts index caadb77..44bc332 100644 --- a/apps/desktop/src/store/useDownloadStore.ts +++ b/apps/desktop/src/store/useDownloadStore.ts @@ -1,6 +1,14 @@ import { create } from 'zustand'; import { invoke } from '@tauri-apps/api/core'; import { useSettingsStore } from './useSettingsStore'; +import { + categoryForFileName, + fileNameFromUrl, + isMediaUrl, + type DownloadCategory +} from '../utils/downloads'; + +export type { DownloadCategory } from '../utils/downloads'; const getProxyArgs = (settings: ReturnType) => { if (settings.proxyMode === 'custom' && settings.proxyHost) { @@ -77,8 +85,6 @@ const effectiveSpeedLimit = ( }; export type DownloadStatus = 'downloading' | 'paused' | 'completed' | 'failed' | 'queued'; -export type DownloadCategory = 'Musics' | 'Movies' | 'Compressed' | 'Documents' | 'Pictures' | 'Applications' | 'Other'; - export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export interface Queue { @@ -113,14 +119,24 @@ export interface DownloadItem { queueId: string; } +export interface ExtensionDownloadRequest { + urls: string[]; + referer?: string | null; + silent?: boolean; + filename?: string | null; +} + interface DownloadState { downloads: DownloadItem[]; queues: Queue[]; isAddModalOpen: boolean; pendingAddUrls: string; + pendingAddReferer: string; + pendingAddFilename: string; selectedPropertiesDownloadId: string | null; toggleAddModal: (isOpen: boolean) => void; - openAddModalWithUrls: (urls: string) => void; + openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void; + handleExtensionDownload: (request: ExtensionDownloadRequest) => void; setSelectedPropertiesDownloadId: (id: string | null) => void; addDownload: (item: DownloadItem) => void; updateDownload: (id: string, updates: Partial) => void; @@ -141,9 +157,59 @@ export const useDownloadStore = create((set, get) => ({ queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }], isAddModalOpen: false, pendingAddUrls: '', + pendingAddReferer: '', + pendingAddFilename: '', selectedPropertiesDownloadId: null, - toggleAddModal: (isOpen) => set({ isAddModalOpen: isOpen }), - openAddModalWithUrls: (urls) => set({ isAddModalOpen: true, pendingAddUrls: urls }), + toggleAddModal: (isOpen) => set({ + isAddModalOpen: isOpen, + pendingAddUrls: '', + pendingAddReferer: '', + pendingAddFilename: '' + }), + openAddModalWithUrls: (urls, referer, filename) => set({ + isAddModalOpen: true, + pendingAddUrls: urls, + pendingAddReferer: referer?.trim() || '', + pendingAddFilename: filename?.trim() || '' + }), + handleExtensionDownload: (request) => { + const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))]; + if (urls.length === 0) return; + + const settings = useSettingsStore.getState(); + if (!request.silent || settings.askWhereToSaveEachFile) { + get().openAddModalWithUrls( + urls.join('\n'), + request.referer, + urls.length === 1 ? request.filename : null + ); + return; + } + + const referer = request.referer?.trim(); + const headers = referer ? `Referer: ${referer}` : undefined; + const dateAdded = new Date().toISOString(); + const downloads = urls.map((url, index): DownloadItem => { + const fileName = index === 0 && urls.length === 1 && request.filename?.trim() + ? request.filename.trim() + : fileNameFromUrl(url); + return { + id: crypto.randomUUID(), + url, + fileName, + status: 'queued', + category: categoryForFileName(fileName), + dateAdded, + connections: settings.perServerConnections, + headers, + isMedia: isMediaUrl(url), + queueId: MAIN_QUEUE_ID + }; + }); + + set(state => ({ downloads: [...state.downloads, ...downloads] })); + void get().processQueue(); + }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), addDownload: (item) => { set((state) => ({ downloads: [...state.downloads, item] })); @@ -327,7 +393,8 @@ export const useDownloadStore = create((set, get) => ({ cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null, speedLimit, username: item.username || (login ? login.username : null), - password: item.password || keychainPassword + password: item.password || keychainPassword, + headers: item.headers || null }); } else { const speedLimit = effectiveSpeedLimit( diff --git a/apps/desktop/src/store/useSettingsStore.ts b/apps/desktop/src/store/useSettingsStore.ts index 0cae6dd..a4885a8 100644 --- a/apps/desktop/src/store/useSettingsStore.ts +++ b/apps/desktop/src/store/useSettingsStore.ts @@ -102,6 +102,30 @@ const defaultDirectories = { Other: '~/Downloads/Other' }; +const normalizeDownloadDirectories = (directories: unknown): Record => { + if (!directories || typeof directories !== 'object') { + return { ...defaultDirectories }; + } + + const values = directories as Record; + const directory = (current: string, legacy?: string) => { + const value = values[current] ?? (legacy ? values[legacy] : undefined); + return typeof value === 'string' && value.length > 0 + ? value + : defaultDirectories[current as keyof typeof defaultDirectories]; + }; + + return { + Musics: directory('Musics', 'Audio'), + Movies: directory('Movies', 'Video'), + Compressed: directory('Compressed', 'Archives'), + Documents: directory('Documents'), + Pictures: directory('Pictures', 'Images'), + Applications: directory('Applications', 'Apps'), + Other: directory('Other') + }; +}; + const generateSecureToken = () => { try { const cryptoObj = typeof window !== 'undefined' ? (window.crypto || (window as any).msCrypto) : null; @@ -165,15 +189,7 @@ export const useSettingsStore = create()( askWhereToSaveEachFile: false, preventsSleepWhileDownloading: true, mediaCookieSource: 'none', - downloadDirectories: { - 'Video': '~/Downloads/Video', - 'Audio': '~/Downloads/Audio', - 'Documents': '~/Downloads/Documents', - 'Apps': '~/Downloads/Apps', - 'Images': '~/Downloads/Images', - 'Archives': '~/Downloads/Compressed', - 'Other': '~/Downloads/Other' - }, + downloadDirectories: { ...defaultDirectories }, siteLogins: [], extensionPairingToken: generateSecureToken(), @@ -255,9 +271,7 @@ export const useSettingsStore = create()( ...persistedState, appFontSize: persistedState?.appFontSize === 'extra-large' ? 'large' : (persistedState?.appFontSize || currentState.appFontSize), listRowDensity: persistedState?.listRowDensity === 'spacious' ? 'relaxed' : (persistedState?.listRowDensity || currentState.listRowDensity), - downloadDirectories: (persistedState && typeof persistedState === 'object' && persistedState.downloadDirectories) - ? persistedState.downloadDirectories - : currentState.downloadDirectories, + downloadDirectories: normalizeDownloadDirectories(persistedState?.downloadDirectories), siteLogins: (persistedState && typeof persistedState === 'object' && Array.isArray(persistedState.siteLogins)) ? persistedState.siteLogins : currentState.siteLogins diff --git a/apps/desktop/src/utils/downloads.ts b/apps/desktop/src/utils/downloads.ts new file mode 100644 index 0000000..c28b77b --- /dev/null +++ b/apps/desktop/src/utils/downloads.ts @@ -0,0 +1,60 @@ +export type DownloadCategory = + | 'Musics' + | 'Movies' + | 'Compressed' + | 'Documents' + | 'Pictures' + | 'Applications' + | 'Other'; + +const MEDIA_DOMAINS = [ + 'youtube.com', + 'youtu.be', + 'twitter.com', + 'x.com', + 'twitch.tv', + 'vimeo.com', + 'instagram.com', + 'tiktok.com', + 'reddit.com', + 'soundcloud.com', + 'facebook.com' +]; + +export const categoryForFileName = (fileName: string): DownloadCategory => { + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'].includes(ext)) return 'Movies'; + if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].includes(ext)) return 'Musics'; + if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents'; + if (['exe', 'dmg', 'apk', 'app', 'pkg', 'deb', 'rpm', 'msi', 'iso', 'bin', 'run'].includes(ext)) return 'Applications'; + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'].includes(ext)) return 'Pictures'; + if (['zip', 'rar', '7z', 'tar', 'gz', 'xz', 'bz2'].includes(ext)) return 'Compressed'; + return 'Other'; +}; + +export const fileNameFromUrl = (rawUrl: string): string => { + try { + const url = new URL(rawUrl); + const pathName = url.pathname.split('/').filter(Boolean).pop(); + if (pathName) { + const decoded = decodeURIComponent(pathName).trim(); + if (decoded && decoded !== '.' && decoded !== '..') { + return decoded.replace(/[\/\\?%*:|"<>]/g, '-'); + } + } + } catch { + // Fall through to the stable generic name. + } + return 'download'; +}; + +export const isMediaUrl = (rawUrl: string): boolean => { + try { + const url = new URL(rawUrl); + return MEDIA_DOMAINS.some(domain => + url.hostname === domain || url.hostname.endsWith(`.${domain}`) + ); + } catch { + return false; + } +};