From 607d193f8e838f5f70dc80bbca41561dad7cbedc Mon Sep 17 00:00:00 2001 From: NimBold Date: Tue, 7 Jul 2026 22:25:58 +0330 Subject: [PATCH] fix(proxy): preserve system proxy schemes Detect platform system proxy schemes before normalizing proxy values. Fail normal aria2 downloads clearly when the selected proxy is SOCKS, since aria2 only supports HTTP-style all-proxy URLs. --- src-tauri/src/parity.rs | 221 ++++++++++++++++++++++++++++---- src-tauri/src/queue.rs | 55 ++++++-- src/components/SettingsView.tsx | 2 +- 3 files changed, 244 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/parity.rs b/src-tauri/src/parity.rs index 3445310..3fc5f8f 100644 --- a/src-tauri/src/parity.rs +++ b/src-tauri/src/parity.rs @@ -1,37 +1,71 @@ use serde::{Deserialize, Serialize}; +use std::process::Command; use ts_rs::TS; use crate::ipc::DownloadCategory; #[tauri::command] pub async fn get_system_proxy() -> Result, String> { - match sysproxy::Sysproxy::get_system_proxy() { - Ok(proxy) => { - if proxy.enable { + match native_system_proxy() { + Ok(Some(proxy)) => Ok(Some(proxy)), + Ok(None) => Ok(proxy_from_environment()), + Err(native_error) => match sysproxy::Sysproxy::get_system_proxy() { + Ok(proxy) if proxy.enable => { if proxy.host.contains('=') { - Ok(parse_windows_proxy_server(&proxy.host)) + Ok(parse_windows_proxy_server(&proxy.host).or_else(proxy_from_environment)) } else { - Ok(normalize_sysproxy_address(&proxy.host, proxy.port)) + Ok(normalize_sysproxy_address(&proxy.host, proxy.port) + .or_else(proxy_from_environment)) } - } else { - Ok(None) } - } - Err(error) => { - #[cfg(target_os = "windows")] - if let Ok(Some(proxy)) = fallback_windows_proxy() { - return Ok(Some(proxy)); - } - - if let Some(proxy) = proxy_from_environment() { - return Ok(Some(proxy)); - } - - Err(format!("failed to read system proxy settings: {error}")) - } + Ok(_) => Ok(proxy_from_environment()), + Err(error) => proxy_from_environment().map(Some).ok_or_else(|| { + format!( + "failed to read system proxy settings: {native_error}; sysproxy fallback: {error}" + ) + }), + }, } } +#[cfg(target_os = "windows")] +fn native_system_proxy() -> Result, String> { + fallback_windows_proxy().map_err(|_| "failed to read Windows proxy registry".to_string()) +} + +#[cfg(target_os = "macos")] +fn native_system_proxy() -> Result, String> { + let proxy = sysproxy::Sysproxy::get_system_proxy().map_err(|error| error.to_string())?; + if !proxy.enable { + return Ok(None); + } + Ok(macos_proxy_for_host_port(&proxy.host, proxy.port) + .unwrap_or_else(|| { + normalize_sysproxy_address(&proxy.host, proxy.port) + .unwrap_or_else(|| format!("http://{}:{}", proxy.host, proxy.port)) + }) + .into()) +} + +#[cfg(target_os = "linux")] +fn native_system_proxy() -> Result, String> { + let mode = + command_stdout(Command::new("gsettings").args(["get", "org.gnome.system.proxy", "mode"])) + .map_err(|error| error.to_string())?; + if strip_gsettings_string(&mode) != "manual" { + return Ok(None); + } + + Ok(linux_gsettings_proxy("https", "http") + .or_else(|| linux_gsettings_proxy("http", "http")) + .or_else(|| linux_gsettings_proxy("socks", "socks5"))) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +fn native_system_proxy() -> Result, String> { + Ok(None) +} + fn proxy_from_environment() -> Option { [ "HTTPS_PROXY", @@ -49,6 +83,17 @@ fn proxy_from_environment() -> Option { }) } +fn command_stdout(command: &mut Command) -> std::io::Result { + let output = command.output()?; + if !output.status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("command exited with {}", output.status), + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + fn normalize_proxy_address(raw: &str, default_scheme: &str) -> Option { let trimmed = raw.trim().trim_matches('"').trim_end_matches('/'); if trimmed.is_empty() { @@ -120,6 +165,93 @@ fn parse_windows_proxy_server(value: &str) -> Option { https.or(http).or(socks) } +#[cfg(target_os = "macos")] +fn macos_proxy_for_host_port(host: &str, port: u16) -> Option { + let services_output = + command_stdout(Command::new("networksetup").arg("-listallnetworkservices")).ok()?; + for service in parse_macos_network_services(&services_output) { + for (target, scheme) in [ + ("securewebproxy", "http"), + ("webproxy", "http"), + ("socksfirewallproxy", "socks5"), + ] { + let output = command_stdout( + Command::new("networksetup").args([format!("-get{target}"), service.clone()]), + ) + .ok()?; + if let Some(proxy) = parse_macos_networksetup_proxy(&output, scheme) + .filter(|proxy| proxy_matches_host_port(proxy, host, port)) + { + return Some(proxy); + } + } + } + None +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn parse_macos_network_services(output: &str) -> Vec { + output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .filter(|line| !line.starts_with("An asterisk")) + .filter(|line| !line.starts_with('*')) + .map(str::to_string) + .collect() +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn parse_macos_networksetup_proxy(output: &str, scheme: &str) -> Option { + let enabled = macos_networksetup_value(output, "Enabled:") + .is_some_and(|value| value.eq_ignore_ascii_case("yes")); + if !enabled { + return None; + } + let server = macos_networksetup_value(output, "Server:")?; + let port = macos_networksetup_value(output, "Port:")?; + normalize_proxy_address(&format!("{scheme}://{server}:{port}"), scheme) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn proxy_matches_host_port(proxy: &str, host: &str, port: u16) -> bool { + let Ok(parsed) = url::Url::parse(proxy) else { + return false; + }; + parsed.host_str() == Some(host) && parsed.port() == Some(port) +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn macos_networksetup_value<'a>(output: &'a str, key: &str) -> Option<&'a str> { + output + .lines() + .map(str::trim) + .find_map(|line| line.strip_prefix(key).map(str::trim)) + .filter(|value| !value.is_empty()) +} + +#[cfg(target_os = "linux")] +fn linux_gsettings_proxy(service: &str, scheme: &str) -> Option { + let schema = format!("org.gnome.system.proxy.{service}"); + let host = command_stdout(Command::new("gsettings").args(["get", &schema, "host"])).ok()?; + let host = strip_gsettings_string(&host); + if host.is_empty() { + return None; + } + let port = command_stdout(Command::new("gsettings").args(["get", &schema, "port"])).ok()?; + let port = port.trim(); + normalize_proxy_address(&format!("{scheme}://{host}:{port}"), scheme) +} + +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn strip_gsettings_string(value: &str) -> String { + value + .trim() + .trim_matches('\'') + .trim_matches('"') + .to_string() +} + #[cfg(target_os = "windows")] fn fallback_windows_proxy() -> Result, ()> { use std::os::windows::process::CommandExt; @@ -206,8 +338,9 @@ fn registry_value(output: &str, name: &str) -> Option { #[cfg(test)] mod proxy_tests { use super::{ - normalize_proxy_address, normalize_sysproxy_address, parse_windows_proxy_server, - registry_value, windows_proxy_enabled, + normalize_proxy_address, normalize_sysproxy_address, parse_macos_network_services, + parse_macos_networksetup_proxy, parse_windows_proxy_server, proxy_matches_host_port, + registry_value, strip_gsettings_string, windows_proxy_enabled, }; #[test] @@ -255,6 +388,50 @@ mod proxy_tests { ); } + #[test] + fn parses_macos_proxy_outputs_with_scheme() { + let services = r#" +An asterisk (*) denotes that a network service is disabled. +Wi-Fi +*USB 10/100/1000 LAN +Thunderbolt Bridge +"#; + assert_eq!( + parse_macos_network_services(services), + vec!["Wi-Fi".to_string(), "Thunderbolt Bridge".to_string()] + ); + + let proxy = r#" +Enabled: Yes +Server: 127.0.0.1 +Port: 1080 +Authenticated Proxy Enabled: 0 +"#; + assert_eq!( + parse_macos_networksetup_proxy(proxy, "socks5").as_deref(), + Some("socks5://127.0.0.1:1080") + ); + assert!(proxy_matches_host_port( + "socks5://127.0.0.1:1080", + "127.0.0.1", + 1080 + )); + assert!(!proxy_matches_host_port( + "socks5://127.0.0.1:1080", + "127.0.0.1", + 1081 + )); + + let disabled = proxy.replace("Enabled: Yes", "Enabled: No"); + assert_eq!(parse_macos_networksetup_proxy(&disabled, "socks5"), None); + } + + #[test] + fn strips_gsettings_string_quotes() { + assert_eq!(strip_gsettings_string("'manual'\n"), "manual"); + assert_eq!(strip_gsettings_string("\"127.0.0.1\"\n"), "127.0.0.1"); + } + #[test] fn parses_reg_query_output_values() { let output = r#" diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 116fdc6..2d88688 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -998,6 +998,28 @@ fn uri_host_for_log(uri: &str) -> String { .unwrap_or_else(|| "".to_string()) } +fn proxy_scheme(proxy: &str) -> Option { + proxy + .split_once("://") + .map(|(scheme, _)| scheme.trim().to_ascii_lowercase()) +} + +fn aria2_all_proxy_value(proxy: &str) -> Result, String> { + let proxy = proxy.trim(); + if proxy.is_empty() { + return Ok(None); + } + if proxy.eq_ignore_ascii_case("none") { + return Ok(Some(String::new())); + } + if proxy_scheme(proxy).is_some_and(|scheme| scheme.starts_with("socks")) { + return Err( + "SOCKS system proxies are not supported for normal file downloads because aria2 only accepts HTTP/HTTPS/FTP proxy URLs. Use an HTTP proxy endpoint for normal downloads, or use media downloads where yt-dlp supports SOCKS.".to_string(), + ); + } + Ok(Some(proxy.to_string())) +} + async fn probe_bounded_range_support( uri: &str, payload: &SpawnPayload, @@ -1159,6 +1181,12 @@ impl SidecarSpawner for ProductionSpawner { if !crate::is_safe_path(&resolved_dest, &self.app_handle) { return Err("Path traversal blocked".to_string()); } + let proxy_value = payload + .proxy + .as_deref() + .map(aria2_all_proxy_value) + .transpose()? + .flatten(); options.insert( "dir".to_string(), serde_json::json!(resolved_dest.to_string_lossy().to_string()), @@ -1210,17 +1238,8 @@ impl SidecarSpawner for ProductionSpawner { if !header_list.is_empty() { options.insert("header".to_string(), serde_json::json!(header_list)); } - if let Some(prox) = payload - .proxy - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - { - if prox.eq_ignore_ascii_case("none") { - options.insert("all-proxy".to_string(), serde_json::json!("")); - } else { - options.insert("all-proxy".to_string(), serde_json::json!(prox)); - } + if let Some(prox) = proxy_value { + options.insert("all-proxy".to_string(), serde_json::json!(prox)); } let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()); let params = serde_json::json!([uris, options]); @@ -1389,6 +1408,20 @@ mod tests { ); } + #[test] + fn aria2_proxy_value_rejects_socks_proxies() { + assert_eq!(aria2_all_proxy_value("none").unwrap().as_deref(), Some("")); + assert_eq!( + aria2_all_proxy_value("http://127.0.0.1:8080") + .unwrap() + .as_deref(), + Some("http://127.0.0.1:8080") + ); + assert!(aria2_all_proxy_value("socks5://127.0.0.1:1080") + .unwrap_err() + .contains("SOCKS system proxies are not supported")); + } + #[test] fn bounded_range_probe_rejects_server_that_expands_to_end() { assert_eq!( diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index c34fa4d..4a89e00 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -838,7 +838,7 @@ runEngineChecks(false); )}

{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'} - {settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy when available, otherwise no proxy.`} + {settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.`} {settings.proxyMode === 'custom' && (settings.proxyHost ? 'Downloads use the configured proxy URL for HTTP, HTTPS, and media engines.' : 'Enter a proxy host and port to enable the custom proxy.')}