diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 50cf2c1..ffced53 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,7 +31,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] } regex = "1.10" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] } uuid = { version = "1", features = ["v4"] } ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] } tauri-plugin-notification = "2.3.3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 45845cc..16431e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -17,6 +17,10 @@ fn get_metadata_cache() -> &'static std::sync::Mutex> { CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) } +fn metadata_info_cache_key(url: &str, cookie_source: Option<&str>, proxy: Option<&str>) -> String { + serde_json::json!([url, cookie_source.unwrap_or(""), proxy.unwrap_or("")]).to_string() +} + fn sanitize_metadata_filename(filename: &str) -> Option { let normalized = filename.trim().replace('\\', "/"); let basename = std::path::Path::new(&normalized) @@ -1057,6 +1061,7 @@ async fn fetch_metadata( password: Option, headers: Option, cookies: Option, + proxy: Option, ) -> Result { let mut current_url = url.clone(); let original_host = reqwest::Url::parse(&url).ok().and_then(|u| u.host_str().map(|s| s.to_string())); @@ -1069,6 +1074,13 @@ async fn fetch_metadata( } let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()); + if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if proxy.eq_ignore_ascii_case("none") { + builder = builder.no_proxy(); + } else { + builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(|e| e.to_string())?); + } + } if let Some(ref ua) = user_agent { let ua = ua.trim(); @@ -1244,12 +1256,14 @@ fn media_metadata_cache_key( cookie_browser: &Option, username: &Option, password: &Option, + proxy: &Option, ) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); url.hash(&mut hasher); cookie_browser.hash(&mut hasher); username.hash(&mut hasher); password.hash(&mut hasher); + proxy.hash(&mut hasher); hasher.finish() } @@ -1283,9 +1297,10 @@ async fn fetch_media_metadata( cookie_browser: Option, username: Option, password: Option, + proxy: Option, ) -> Result { validate_url_ssrf(&url).await?; - let cache_key = media_metadata_cache_key(&url, &cookie_browser, &username, &password); + let cache_key = media_metadata_cache_key(&url, &cookie_browser, &username, &password, &proxy); let cache = MEDIA_METADATA_CACHE.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); let mut cache_guard = cache.lock().await; @@ -1316,8 +1331,15 @@ async fn fetch_media_metadata( } drop(cache_guard); - let result = - fetch_media_metadata_uncached(app_handle, url, cookie_browser, username, password).await; + let result = fetch_media_metadata_uncached( + app_handle, + url, + cookie_browser, + username, + password, + proxy, + ) + .await; let result = match result { Ok(metadata) if metadata.formats.is_empty() => { @@ -1354,7 +1376,10 @@ async fn fetch_media_metadata_uncached( cookie_browser: Option, username: Option, password: Option, + proxy: Option, ) -> Result { + let info_cache_key = metadata_info_cache_key(&url, cookie_browser.as_deref(), proxy.as_deref()); + // Pass bundled tools by absolute path so extraction never depends on // system Python, a user-managed PATH, or auto-detection heuristics. let deno_path = resolve_bundled_binary_path(&app_handle, "deno") @@ -1389,9 +1414,17 @@ async fn fetch_media_metadata_uncached( .arg("--print") .arg("%(.{title,duration,thumbnail,formats})j"); - if let Some(browser) = cookie_browser { + if let Some(browser) = cookie_browser.as_deref() { if !browser.is_empty() { - cmd = cmd.arg("--cookies-from-browser").arg(&browser); + cmd = cmd.arg("--cookies-from-browser").arg(browser); + } + } + + if let Some(proxy) = proxy.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + if proxy.eq_ignore_ascii_case("none") { + cmd = cmd.arg("--proxy").arg(""); + } else { + cmd = cmd.arg("--proxy").arg(proxy); } } @@ -1437,7 +1470,7 @@ async fn fetch_media_metadata_uncached( if let Ok(mut cache) = get_metadata_cache().lock() { if let Ok(json_str) = String::from_utf8(output.stdout.clone()) { - cache.insert(url.to_string(), json_str); + cache.insert(info_cache_key, json_str); } } @@ -2720,7 +2753,9 @@ pub(crate) async fn start_media_download_internal( let mut temp_info_path = None; if let Ok(mut cache) = get_metadata_cache().lock() { - if let Some(json_str) = cache.remove(&url) { + let info_cache_key = + metadata_info_cache_key(&url, cookie_source.as_deref(), proxy.as_deref()); + if let Some(json_str) = cache.remove(&info_cache_key) { let temp_dir = std::env::temp_dir(); let path = temp_dir.join(format!("firelink_ytdlp_{}.info.json", id)); if std::fs::write(&path, json_str).is_ok() { diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index 89f737c..436da69 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -8,12 +8,11 @@ pub async fn get_system_proxy() -> Result, String> { match sysproxy::Sysproxy::get_system_proxy() { Ok(proxy) => { if proxy.enable { - let protocol = if proxy.host.contains("://") { - "" + if proxy.host.contains('=') { + Ok(parse_windows_proxy_server(&proxy.host)) } else { - "http://" - }; - Ok(Some(format!("{}{}:{}", protocol, proxy.host, proxy.port))) + Ok(normalize_sysproxy_address(&proxy.host, proxy.port)) + } } else { Ok(None) } @@ -24,21 +23,8 @@ pub async fn get_system_proxy() -> Result, String> { return Ok(Some(proxy)); } - if let Ok(proxy) = std::env::var("HTTP_PROXY") - .or_else(|_| std::env::var("http_proxy")) - .or_else(|_| std::env::var("HTTPS_PROXY")) - .or_else(|_| std::env::var("https_proxy")) - .or_else(|_| std::env::var("ALL_PROXY")) - .or_else(|_| std::env::var("all_proxy")) - { - if !proxy.is_empty() { - let protocol = if proxy.contains("://") { - "" - } else { - "http://" - }; - return Ok(Some(format!("{}{}", protocol, proxy))); - } + if let Some(proxy) = proxy_from_environment() { + return Ok(Some(proxy)); } Err(format!("failed to read system proxy settings: {error}")) @@ -46,12 +32,100 @@ pub async fn get_system_proxy() -> Result, String> { } } +fn proxy_from_environment() -> Option { + [ + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", + ] + .into_iter() + .find_map(|name| { + std::env::var(name) + .ok() + .and_then(|value| normalize_proxy_address(&value, "http")) + }) +} + +fn normalize_proxy_address(raw: &str, default_scheme: &str) -> Option { + let trimmed = raw.trim().trim_matches('"').trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("{default_scheme}://{trimmed}") + }; + let parsed = url::Url::parse(&candidate).ok()?; + match parsed.scheme() { + "http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h" => {} + _ => return None, + } + parsed.host_str()?; + Some(candidate) +} + +fn normalize_sysproxy_address(host: &str, port: u16) -> Option { + let host = host.trim(); + if host.is_empty() { + return None; + } + + if host.contains("://") { + let mut parsed = url::Url::parse(host).ok()?; + if parsed.port().is_none() && port != 0 { + parsed.set_port(Some(port)).ok()?; + } + return normalize_proxy_address(parsed.as_str(), "http"); + } + + if port == 0 { + normalize_proxy_address(host, "http") + } else { + normalize_proxy_address(&format!("{host}:{port}"), "http") + } +} + +fn parse_windows_proxy_server(value: &str) -> Option { + let value = value.trim().trim_matches('"'); + if value.is_empty() { + return None; + } + + if !value.contains('=') { + return normalize_proxy_address(value, "http"); + } + + let mut http = None; + let mut https = None; + let mut socks = None; + for entry in value.split(';') { + let Some((kind, address)) = entry.split_once('=') else { + continue; + }; + let kind = kind.trim().to_ascii_lowercase(); + let address = address.trim(); + match kind.as_str() { + "http" => http = normalize_proxy_address(address, "http"), + "https" => https = normalize_proxy_address(address, "http"), + "socks" => socks = normalize_proxy_address(address, "socks5"), + _ => {} + } + } + + https.or(http).or(socks) +} + #[cfg(target_os = "windows")] fn fallback_windows_proxy() -> Result, ()> { use std::os::windows::process::CommandExt; use std::process::Command; const CREATE_NO_WINDOW: u32 = 0x08000000; - + let output = Command::new("reg") .args(&[ "query", @@ -62,9 +136,16 @@ fn fallback_windows_proxy() -> Result, ()> { .creation_flags(CREATE_NO_WINDOW) .output() .map_err(|_| ())?; - + + if !output.status.success() { + return Err(()); + } + let stdout = String::from_utf8_lossy(&output.stdout); - if !stdout.contains("0x1") { + let enabled = registry_value(&stdout, "ProxyEnable") + .as_deref() + .is_some_and(windows_proxy_enabled); + if !enabled { return Ok(None); } @@ -78,30 +159,120 @@ fn fallback_windows_proxy() -> Result, ()> { .creation_flags(CREATE_NO_WINDOW) .output() .map_err(|_| ())?; - + + if !output.status.success() { + return Err(()); + } + let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if line.contains("ProxyServer") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 3 { - let proxy_val = parts[2]; - if proxy_val.contains('=') { - for part in proxy_val.split(';') { - if part.starts_with("http=") || part.starts_with("https=") || part.starts_with("socks=") { - let addr = part.split('=').nth(1).unwrap_or(""); - if !addr.is_empty() { - let protocol = if part.starts_with("socks") { "socks5://" } else { "http://" }; - return Ok(Some(format!("{}{}", protocol, addr))); - } - } - } - } else { - return Ok(Some(format!("http://{}", proxy_val))); - } - } + Ok(registry_value(&stdout, "ProxyServer").and_then(|value| parse_windows_proxy_server(&value))) +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn windows_proxy_enabled(value: &str) -> bool { + let value = value.trim(); + if value == "1" { + return true; + } + value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .is_some_and(|enabled| enabled == 1) +} + +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn registry_value(output: &str, name: &str) -> Option { + for line in output.lines() { + let trimmed = line.trim(); + let mut parts = trimmed.split_whitespace(); + let Some(key) = parts.next() else { + continue; + }; + if !key.eq_ignore_ascii_case(name) { + continue; + } + if parts.next().is_none() { + continue; + } + let data = parts.collect::>().join(" "); + if !data.is_empty() { + return Some(data); } } - Ok(None) + None +} + +#[cfg(test)] +mod proxy_tests { + use super::{ + normalize_proxy_address, normalize_sysproxy_address, parse_windows_proxy_server, + registry_value, windows_proxy_enabled, + }; + + #[test] + fn normalizes_bare_proxy_addresses() { + assert_eq!( + normalize_proxy_address("127.0.0.1:8080", "http").as_deref(), + Some("http://127.0.0.1:8080") + ); + assert_eq!( + normalize_proxy_address(" socks5://127.0.0.1:1080/ ", "http").as_deref(), + Some("socks5://127.0.0.1:1080") + ); + assert_eq!(normalize_proxy_address("file:///tmp/proxy", "http"), None); + } + + #[test] + fn parses_windows_protocol_proxy_server_values() { + assert_eq!( + parse_windows_proxy_server("http=127.0.0.1:8080;https=127.0.0.1:8081").as_deref(), + Some("http://127.0.0.1:8081") + ); + assert_eq!( + parse_windows_proxy_server("socks=127.0.0.1:1080").as_deref(), + Some("socks5://127.0.0.1:1080") + ); + assert_eq!( + parse_windows_proxy_server("proxy.local:9000").as_deref(), + Some("http://proxy.local:9000") + ); + } + + #[test] + fn normalizes_sysproxy_host_without_duplicating_ports() { + assert_eq!( + normalize_sysproxy_address("http://proxy.local", 8080).as_deref(), + Some("http://proxy.local:8080") + ); + assert_eq!( + normalize_sysproxy_address("http://proxy.local:9000", 8080).as_deref(), + Some("http://proxy.local:9000") + ); + assert_eq!( + normalize_sysproxy_address("proxy.local", 8080).as_deref(), + Some("http://proxy.local:8080") + ); + } + + #[test] + fn parses_reg_query_output_values() { + let output = r#" +HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings + ProxyEnable REG_DWORD 0x1 + ProxyServer REG_SZ http=127.0.0.1:8080;https=127.0.0.1:8081 +"#; + + assert!(registry_value(output, "ProxyEnable") + .as_deref() + .is_some_and(windows_proxy_enabled)); + assert_eq!( + registry_value(output, "ProxyServer").as_deref(), + Some("http=127.0.0.1:8080;https=127.0.0.1:8081") + ); + assert!(!windows_proxy_enabled("0X0")); + assert!(windows_proxy_enabled("0X1")); + } } #[tauri::command] diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 544a648..0704882 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useDownloadStore, getSiteLogin, + getProxyArgs, type AddDownloadAction } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; @@ -186,8 +187,9 @@ export const AddDownloadsModal = () => { void (async () => { try { + const settingsStore = useSettingsStore.getState(); + const proxy = await getProxyArgs(settingsStore); if (row.isMedia) { - const settingsStore = useSettingsStore.getState(); const { mediaCookieSource } = settingsStore; const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; const login = getSiteLogin(row.sourceUrl, settingsStore); @@ -204,7 +206,8 @@ export const AddDownloadsModal = () => { url: row.sourceUrl, cookieBrowser: browserArg, username: useAuth ? username.trim() || null : login?.username || null, - password: useAuth ? password || null : keychainPassword + password: useAuth ? password || null : keychainPassword, + proxy }); if (mediaData && mediaData.formats.length > 0) { const mappedFormats = mediaData.formats.map(f => { @@ -245,7 +248,6 @@ export const AddDownloadsModal = () => { throw new Error("Invalid media metadata or no formats found"); } } else { - const settingsStore = useSettingsStore.getState(); const login = getSiteLogin(row.sourceUrl, settingsStore); let keychainPassword = null; if (login) { @@ -261,7 +263,8 @@ export const AddDownloadsModal = () => { username: useAuth ? username.trim() || null : login?.username || null, password: useAuth ? password || null : keychainPassword, headers: headers?.trim() || null, - cookies: cookies?.trim() || null + cookies: cookies?.trim() || null, + proxy }); const nextDownloadUrl = meta.url || row.sourceUrl; if (cookiesFromExtensionRef.current && isCrossHostRedirect(row.sourceUrl, nextDownloadUrl)) { diff --git a/src/ipc.ts b/src/ipc.ts index b8f8d56..e1ec018 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -17,11 +17,11 @@ import type { PlatformInfo } from './bindings/PlatformInfo'; type CommandMap = { fetch_metadata: { - args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null }; + args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null }; result: MetadataResponse; }; fetch_media_metadata: { - args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null }; + args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null; proxy: string | null }; result: MediaMetadata; }; get_aria2_engine_status: { args: undefined; result: EngineStatusItem }; diff --git a/src/utils/mediaMetadata.ts b/src/utils/mediaMetadata.ts index 7b357c0..5cd3cd5 100644 --- a/src/utils/mediaMetadata.ts +++ b/src/utils/mediaMetadata.ts @@ -6,12 +6,13 @@ type FetchMediaMetadataArgs = { cookieBrowser: string | null; username: string | null; password: string | null; + proxy: string | null; }; const inFlightMediaMetadata = new Map>(); const metadataKey = (args: FetchMediaMetadataArgs) => - JSON.stringify([args.url, args.cookieBrowser, args.username, args.password]); + JSON.stringify([args.url, args.cookieBrowser, args.username, args.password, args.proxy]); export const fetchMediaMetadataDeduped = (args: FetchMediaMetadataArgs): Promise => { const key = metadataKey(args);