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.
This commit is contained in:
NimBold
2026-07-07 22:25:58 +03:30
parent f711c6b7a4
commit 607d193f8e
3 changed files with 244 additions and 34 deletions
+199 -22
View File
@@ -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<Option<String>, 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<Option<String>, String> {
fallback_windows_proxy().map_err(|_| "failed to read Windows proxy registry".to_string())
}
#[cfg(target_os = "macos")]
fn native_system_proxy() -> Result<Option<String>, 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<Option<String>, 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<Option<String>, String> {
Ok(None)
}
fn proxy_from_environment() -> Option<String> {
[
"HTTPS_PROXY",
@@ -49,6 +83,17 @@ fn proxy_from_environment() -> Option<String> {
})
}
fn command_stdout(command: &mut Command) -> std::io::Result<String> {
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<String> {
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<String> {
https.or(http).or(socks)
}
#[cfg(target_os = "macos")]
fn macos_proxy_for_host_port(host: &str, port: u16) -> Option<String> {
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<String> {
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<String> {
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<String> {
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<Option<String>, ()> {
use std::os::windows::process::CommandExt;
@@ -206,8 +338,9 @@ fn registry_value(output: &str, name: &str) -> Option<String> {
#[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#"
+44 -11
View File
@@ -998,6 +998,28 @@ fn uri_host_for_log(uri: &str) -> String {
.unwrap_or_else(|| "<unknown host>".to_string())
}
fn proxy_scheme(proxy: &str) -> Option<String> {
proxy
.split_once("://")
.map(|(scheme, _)| scheme.trim().to_ascii_lowercase())
}
fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, 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!(
+1 -1
View File
@@ -838,7 +838,7 @@ runEngineChecks(false);
)}
<p className="settings-group-footer">
{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.')}