From 050dbddca4b5f429c8c9fb4ca4efaf5b0fa112f5 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 16 Sep 2026 22:15:45 +0800 Subject: [PATCH] feat(network): auto-detect system proxy and keep user overrides --- src-tauri/Cargo.toml | 1 + src-tauri/src/agents/commands.rs | 2 + src-tauri/src/configuration_watcher.rs | 1 + src-tauri/src/core_config/commands.rs | 33 +- src-tauri/src/core_config/settings.rs | 25 +- src-tauri/src/core_runtime.rs | 34 +- src-tauri/src/main.rs | 14 +- src-tauri/src/management_api.rs | 1 + src-tauri/src/network_proxy.rs | 1017 ++++++++++++++++++++++++ src-tauri/src/tests/core_config.rs | 3 +- src-tauri/src/usage.rs | 11 +- src/i18n/ja.ts | 2 +- src/i18n/locales/en.ts | 2 +- src/i18n/locales/zh-CN.ts | 2 +- src/pages/ConfigPanel.tsx | 4 +- 15 files changed, 1109 insertions(+), 43 deletions(-) create mode 100644 src-tauri/src/network_proxy.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d144dd1..a203508 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,7 @@ objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent", "NSRu [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = [ "Win32_Foundation", + "Win32_Networking_WinHttp", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", diff --git a/src-tauri/src/agents/commands.rs b/src-tauri/src/agents/commands.rs index d07b126..3600d15 100644 --- a/src-tauri/src/agents/commands.rs +++ b/src-tauri/src/agents/commands.rs @@ -716,6 +716,7 @@ pub(crate) async fn fetch_agent_models( } let tls_enabled = managed_core_tls_enabled(); let client = reqwest::Client::builder() + .no_proxy() .connect_timeout(Duration::from_secs(3)) .timeout(Duration::from_secs(15)) .danger_accept_invalid_certs(tls_enabled) @@ -769,6 +770,7 @@ pub(crate) async fn fetch_codex_runtime_models( } let tls_enabled = managed_core_tls_enabled(); let client = reqwest::Client::builder() + .no_proxy() .connect_timeout(Duration::from_secs(3)) .timeout(Duration::from_secs(15)) .danger_accept_invalid_certs(tls_enabled) diff --git a/src-tauri/src/configuration_watcher.rs b/src-tauri/src/configuration_watcher.rs index 638b616..70a1d23 100644 --- a/src-tauri/src/configuration_watcher.rs +++ b/src-tauri/src/configuration_watcher.rs @@ -147,6 +147,7 @@ fn handle_configuration_file_changes( let mut config = toml::from_str::(&content) .map_err(|error| format!("解析 GUI 配置失败: {error}"))?; config.allow_lan = !is_loopback_host(&config.host); + config.proxy_url = super::network_proxy::resolve(&config); validate_gui_config(&config)?; Ok(config) })(); diff --git a/src-tauri/src/core_config/commands.rs b/src-tauri/src/core_config/commands.rs index f4613a0..b354c4a 100644 --- a/src-tauri/src/core_config/commands.rs +++ b/src-tauri/src/core_config/commands.rs @@ -98,7 +98,6 @@ pub(crate) fn save_network_routing_settings( if settings.port == 0 { return Err("端口必须在 1 到 65535 之间".to_string()); } - let proxy_url = normalize_optional_config_string(settings.proxy_url, "代理 URL")?; let routing_session_affinity_ttl = normalize_optional_config_string(settings.routing_session_affinity_ttl, "会话粘性 TTL")?; @@ -116,7 +115,6 @@ pub(crate) fn save_network_routing_settings( "127.0.0.1" } .to_string(); - next.proxy_url = proxy_url.clone(); next.routing_session_affinity = settings.routing_session_affinity; next.routing_session_affinity_ttl = routing_session_affinity_ttl.clone(); next.disable_cooling = settings.disable_cooling; @@ -157,13 +155,25 @@ pub(crate) fn save_network_endpoint_settings( if host.parse::().is_err() { return Err("Listen IP must be a valid IPv4 or IPv6 address".to_string()); } - let proxy_url = normalize_optional_config_string(settings.proxy_url, "Proxy URL")?; let previous = gui_config_state.snapshot()?; let mut next = previous.clone(); next.host = host; next.allow_lan = !is_loopback_host(&next.host); next.port = settings.port; - next.proxy_url = proxy_url; + if let Some(proxy_url) = settings.proxy_url { + let proxy_url = network_proxy::normalize_optional_proxy_url(&proxy_url)?; + let detected = if proxy_url.is_empty() { + Some(network_proxy::detect()) + } else { + None + }; + next.proxy_override = !proxy_url.is_empty(); + next.proxy_url = if next.proxy_override { + proxy_url + } else { + detected.unwrap_or_else(network_proxy::detect) + }; + } validate_gui_config(&next)?; patch_core_network_endpoint_settings(&next)?; let config = match gui_config_state.update_network_endpoint(&next) { @@ -461,19 +471,8 @@ pub(crate) fn set_core_proxy_url( gui_config_state: tauri::State<'_, GuiConfigState>, proxy_url: String, ) -> Result { - let proxy_url = normalize_optional_config_string(proxy_url, "代理 URL")?; - let mut settings = current_core_config_settings(gui_config_state.inner())?; - let previous_proxy_url = settings.proxy_url.clone(); - settings.proxy_url = proxy_url; - patch_core_proxy_url(&settings.proxy_url)?; - let config = match gui_config_state.sync_core_settings(&settings) { - Ok(config) => config, - Err(error) => { - let rollback_error = patch_core_proxy_url(&previous_proxy_url).err(); - return Err(config_update_error_with_rollback(error, rollback_error)); - } - }; - Ok(CoreConfigView::from(&config)) + network_proxy::set_manual(gui_config_state.inner(), proxy_url)?; + Ok(CoreConfigView::from(&gui_config_state.snapshot()?)) } #[tauri::command] diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 213e3d2..27868bf 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -1230,7 +1230,7 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.routing_strategy.is_none() { config.routing_strategy = core_settings.routing_strategy; } - if presence.proxy_url.is_none() { + if presence.proxy_url.is_none() && config.proxy_override { config.proxy_url = core_settings.proxy_url; } if presence.routing_session_affinity.is_none() { @@ -1301,6 +1301,10 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.prefer_gitcode_downloads.is_none() { changed = true; } + changed |= network_proxy::initialize_override( + &mut config, + presence.proxy_override.is_some(), + ); config.prefer_gitcode_downloads = config.download_source == VersionDownloadSource::Gitcode; let management_secret_rotated = ensure_strong_management_secret(&mut config)?; changed |= management_secret_rotated; @@ -1350,7 +1354,6 @@ pub(crate) fn apply_core_settings_to_gui_config( config.request_log = core_settings.request_log; config.plugins_enabled = core_settings.plugins_enabled; config.routing_strategy = core_settings.routing_strategy.clone(); - config.proxy_url = core_settings.proxy_url.clone(); config.routing_session_affinity = core_settings.routing_session_affinity; config.routing_session_affinity_ttl = core_settings.routing_session_affinity_ttl.clone(); config.disable_cooling = core_settings.disable_cooling; @@ -1599,7 +1602,16 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result Result<(), String> } validate_strong_management_secret_key(&config.management_secret_key)?; validate_routing_strategy(config.routing_strategy.trim())?; - if config.proxy_url.chars().any(char::is_control) { + if config.proxy_override { + network_proxy::normalize_proxy_url(&config.proxy_url)?; + } else if config.proxy_url.chars().any(char::is_control) { return Err("代理 URL 不能包含控制字符".to_string()); } for url in &config.custom_download_mirrors { diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 8285038..d5f26ce 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -376,6 +376,7 @@ pub(crate) fn start_core_process_with_state( process_state: &CoreProcessState, gui_config_state: &GuiConfigState, ) -> Result { + network_proxy::refresh(gui_config_state)?; let config = gui_config_state.snapshot()?; start_core_process_inner(process_state, &config)?; if let Err(error) = gui_config_state.set_run_on_startup(true) { @@ -408,6 +409,7 @@ pub(crate) fn restart_core_process_with_state( process_state: &CoreProcessState, gui_config_state: &GuiConfigState, ) -> Result { + network_proxy::refresh(gui_config_state)?; let config = gui_config_state.snapshot()?; if current_core_status(Some(process_state), None)?.running { stop_core_process_inner(process_state)?; @@ -971,11 +973,13 @@ pub(crate) fn apply_configured_proxy( proxy_url: &str, ) -> Result { let proxy_url = proxy_url.trim(); + let builder = builder.no_proxy(); if proxy_url.is_empty() { return Ok(builder); } - let proxy = - reqwest::Proxy::all(proxy_url).map_err(|error| format!("代理 URL 无效: {error}"))?; + let proxy = reqwest::Proxy::all(proxy_url) + .map_err(|_| "代理 URL 无效".to_string())? + .no_proxy(reqwest::NoProxy::from_string("localhost,127.0.0.1,::1")); Ok(builder.proxy(proxy)) } @@ -1387,6 +1391,16 @@ fn start_core_process_once( } }; configure_background_command(&mut command); + for variable in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ] { + command.env_remove(variable); + } let mut child = match spawn_core_child(command) { Ok(child) => child, @@ -1413,6 +1427,9 @@ pub(crate) fn start_core_process_inner( gui_config: &GuiConfigFile, ) -> Result<(), String> { process_state.ensure_active()?; + let mut resolved_config = gui_config.clone(); + resolved_config.proxy_url = network_proxy::resolve(gui_config); + let gui_config = &resolved_config; let install_dir = core_install_dir()?; if !gui_config.auth_dir.trim().is_empty() { let auth_dir = auth_dir_path_for_core(&gui_config.auth_dir, &install_dir); @@ -1547,10 +1564,6 @@ pub(crate) fn configure_background_command(command: &mut Command) { pub(crate) fn configure_networked_command(command: &mut Command, proxy_url: &str) { let proxy_url = proxy_url.trim(); - if proxy_url.is_empty() { - return; - } - for variable in [ "HTTP_PROXY", "HTTPS_PROXY", @@ -1559,8 +1572,15 @@ pub(crate) fn configure_networked_command(command: &mut Command, proxy_url: &str "https_proxy", "all_proxy", ] { - command.env(variable, proxy_url); + if proxy_url.is_empty() { + command.env_remove(variable); + } else { + command.env(variable, proxy_url); + } } + command + .env("NO_PROXY", "localhost,127.0.0.1,::1") + .env("no_proxy", "localhost,127.0.0.1,::1"); } pub(crate) fn stop_core_process_inner(process_state: &CoreProcessState) -> Result<(), String> { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 19e835b..c30dace 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -12,6 +12,7 @@ mod core_runtime; mod desktop_theme; mod instance_lock; mod management_api; +mod network_proxy; mod oauth_browser; mod progress; mod provider_health; @@ -638,6 +639,7 @@ struct GuiConfigFile { plugins_enabled: bool, routing_strategy: String, proxy_url: String, + proxy_override: bool, download_source: VersionDownloadSource, custom_download_mirrors: Vec, active_custom_download_mirror: String, @@ -928,6 +930,7 @@ impl Default for GuiConfigFile { plugins_enabled: false, routing_strategy: "round-robin".to_string(), proxy_url: String::new(), + proxy_override: false, download_source: VersionDownloadSource::Github, custom_download_mirrors: Vec::new(), active_custom_download_mirror: String::new(), @@ -969,6 +972,7 @@ struct GuiConfigPresence { plugins_enabled: Option, routing_strategy: Option, proxy_url: Option, + proxy_override: Option, download_source: Option, custom_download_mirrors: Option>, active_custom_download_mirror: Option, @@ -1414,7 +1418,6 @@ struct GuiNetworkSettings { struct GuiNetworkRoutingSettings { port: u16, allow_lan: bool, - proxy_url: String, routing_session_affinity: bool, routing_session_affinity_ttl: String, #[serde(default)] @@ -1430,7 +1433,8 @@ struct GuiNetworkRoutingSettings { struct GuiNetworkEndpointSettings { host: String, port: u16, - proxy_url: String, + #[serde(default)] + proxy_url: Option, } #[derive(Deserialize)] @@ -1954,6 +1958,7 @@ impl GuiConfigState { config.allow_lan = settings.allow_lan; config.port = settings.port; config.proxy_url = settings.proxy_url.clone(); + config.proxy_override = settings.proxy_override; Ok(()) }) } @@ -2121,7 +2126,6 @@ impl GuiConfigState { } config.plugins_enabled = settings.plugins_enabled; config.routing_strategy = settings.routing_strategy.clone(); - config.proxy_url = settings.proxy_url.clone(); config.routing_session_affinity = settings.routing_session_affinity; config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone(); config.disable_cooling = settings.disable_cooling; @@ -2364,6 +2368,9 @@ fn main() { let app = app .setup(move |app| { + if let Err(error) = network_proxy::refresh(app.state::().inner()) { + eprintln!("读取启动代理设置失败: {error}"); + } if let Err(error) = codex_catalog::validate_embedded_catalog() { eprintln!("Codex 内置模型目录无效: {error}"); } @@ -2398,6 +2405,7 @@ fn main() { eprintln!("启动配置文件监控失败: {error}"); } + network_proxy::start_monitor(app.handle().clone()); start_codex_model_catalog_sync(app.handle().clone()); let usage_app = app.handle().clone(); diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 7b24573..6a58d2b 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -179,6 +179,7 @@ pub(crate) async fn start_oauth_login( provider: String, browser: Option, ) -> Result { + super::network_proxy::prepare_oauth(&app).await; let config = gui_config_state.snapshot()?; let provider_key = normalize_management_oauth_provider(&provider)?; let client = management_http_client()?; diff --git a/src-tauri/src/network_proxy.rs b/src-tauri/src/network_proxy.rs new file mode 100644 index 0000000..cebffa9 --- /dev/null +++ b/src-tauri/src/network_proxy.rs @@ -0,0 +1,1017 @@ +use super::*; + +pub(crate) fn normalize_proxy_url(value: &str) -> Result { + let value = value.trim(); + let invalid = || { + "代理地址无效,请使用 http://host:port、https://host:port 或 socks5://host:port(不是 PAC 地址)".to_string() + }; + if value.is_empty() || value.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err(invalid()); + } + let value = if let Some(rest) = value.strip_prefix("socks://") { + format!("socks5://{rest}") + } else { + value.to_string() + }; + let url = reqwest::Url::parse(&value).map_err(|_| invalid())?; + if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h") + || url.host_str().is_none() + || url.port() == Some(0) + || (url.port_or_known_default().is_none() && url.port().is_none()) + || !matches!(url.path(), "" | "/") + || url.query().is_some() + || url.fragment().is_some() + { + return Err(invalid()); + } + Ok(value.trim_end_matches('/').to_string()) +} + +fn fixed_proxy(value: &str, scheme: &str) -> Result { + parse_detected_proxy(&if value.contains("://") { + value.to_string() + } else { + format!("{scheme}://{value}") + }) +} + +fn parse_detected_proxy(value: &str) -> Result { + let value = value.trim().trim_matches('\'').trim_matches('"'); + if value.is_empty() { + return Err("系统代理地址为空".to_string()); + } + if value.contains("://") { + normalize_proxy_url(value) + } else { + normalize_proxy_url(&format!("http://{value}")) + } +} + +fn parse_env_proxy(name: &str, value: &str) -> Result { + let value = value.trim().trim_matches('\'').trim_matches('"'); + if value.is_empty() { + return Err("系统代理地址为空".to_string()); + } + if !value.contains("://") && name.eq_ignore_ascii_case("socks_proxy") { + return normalize_proxy_url(&format!("socks5://{value}")); + } + parse_detected_proxy(value) +} + +#[cfg(any(target_os = "linux", test))] +fn desktop_scalar(value: &str) -> String { + let value = value.trim().trim_matches('\'').trim_matches('"'); + value + .strip_prefix("uint32 ") + .unwrap_or(value) + .trim() + .to_string() +} + +#[cfg(any(target_os = "windows", test))] +fn parse_windows_proxy(value: &str) -> Result { + if !value.contains('=') { + return fixed_proxy(value.trim(), "http"); + } + for protocol in ["https", "http", "socks"] { + if let Some((_, address)) = value + .split(';') + .filter_map(|part| part.trim().split_once('=')) + .find(|(key, _)| key.trim().eq_ignore_ascii_case(protocol)) + { + return fixed_proxy( + address.trim(), + if protocol == "socks" { + "socks5" + } else { + "http" + }, + ); + } + } + Err("系统代理没有可用的 HTTP / HTTPS / SOCKS 地址".to_string()) +} + +#[cfg(any(target_os = "windows", test))] +fn pac_error() -> String { + "检测到 PAC / 自动发现代理,暂不支持转换为内核固定代理".to_string() +} + +#[cfg(target_os = "windows")] +fn detect_system_proxy() -> Result { + use windows_sys::Win32::{ + Foundation::GlobalFree, + Networking::WinHttp::{ + WinHttpGetIEProxyConfigForCurrentUser, WINHTTP_CURRENT_USER_IE_PROXY_CONFIG, + }, + }; + unsafe fn read_wide(ptr: *mut u16) -> String { + if ptr.is_null() { + return String::new(); + } + let mut len = 0; + while *ptr.add(len) != 0 { + len += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(ptr, len)) + } + unsafe { + let mut config: WINHTTP_CURRENT_USER_IE_PROXY_CONFIG = std::mem::zeroed(); + if WinHttpGetIEProxyConfigForCurrentUser(&mut config) == 0 { + return Err("无法读取当前用户的 Windows 系统代理".to_string()); + } + let proxy = read_wide(config.lpszProxy); + let pac = read_wide(config.lpszAutoConfigUrl); + let auto_detect = config.fAutoDetect != 0; + for ptr in [ + config.lpszProxy, + config.lpszProxyBypass, + config.lpszAutoConfigUrl, + ] { + if !ptr.is_null() { + GlobalFree(ptr.cast()); + } + } + select_windows_proxy(&proxy, &pac, auto_detect) + } +} + +#[cfg(any(target_os = "windows", test))] +fn select_windows_proxy(proxy: &str, pac: &str, auto_detect: bool) -> Result { + if !proxy.trim().is_empty() { + return parse_windows_proxy(proxy); + } + if !pac.trim().is_empty() || auto_detect { + return Err(pac_error()); + } + Ok(String::new()) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn env_proxy() -> Option { + for name in [ + "https_proxy", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", + "http_proxy", + "HTTP_PROXY", + "socks_proxy", + "SOCKS_PROXY", + ] { + if let Ok(value) = std::env::var(name) { + if !value.trim().is_empty() { + if let Ok(url) = parse_env_proxy(name, &value) { + return Some(url); + } + } + } + } + None +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn desktop_setting(program: &str, args: &[&str]) -> Result { + let mut command = Command::new(program); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command + .spawn() + .map_err(|_| format!("无法运行 {program} 读取系统代理"))?; + let deadline = std::time::Instant::now() + Duration::from_millis(400); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return Err(format!("{program} 读取系统代理失败")); + } + let output = child + .wait_with_output() + .map_err(|_| "读取系统代理失败".to_string())?; + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)) + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Err("读取系统代理超时".to_string()); + } + } + } +} + +#[cfg(target_os = "macos")] +fn detect_system_proxy() -> Result { + if let Ok(output) = desktop_setting("/usr/sbin/scutil", &["--proxy"]) { + if let Ok(url) = parse_macos_proxy(&output) { + if !url.is_empty() { + return Ok(url); + } + } + } + if let Ok(url) = macos_networksetup_proxy() { + if !url.is_empty() { + return Ok(url); + } + } + Ok(env_proxy().unwrap_or_default()) +} + +#[cfg(target_os = "macos")] +fn macos_networksetup_proxy() -> Result { + let list = desktop_setting("/usr/sbin/networksetup", &["-listallnetworkservices"])?; + for service in list.lines().skip(1).filter_map(macos_network_service_name) { + for (flag, scheme) in [ + ("-getsecurewebproxy", "http"), + ("-getwebproxy", "http"), + ("-getsocksfirewallproxy", "socks5"), + ] { + if let Ok(output) = desktop_setting("/usr/sbin/networksetup", &[flag, service]) { + if let Ok(url) = parse_networksetup_proxy(&output, scheme) { + if !url.is_empty() { + return Ok(url); + } + } + } + } + } + Ok(String::new()) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_macos_proxy(output: &str) -> Result { + let fields: std::collections::HashMap<_, _> = output + .lines() + .filter_map(|line| { + line.split_once(':') + .map(|(key, value)| (key.trim(), value.trim())) + }) + .collect(); + for (prefix, scheme) in [("HTTPS", "http"), ("HTTP", "http"), ("SOCKS", "socks5")] { + if fields.get(format!("{prefix}Enable").as_str()) != Some(&"1") { + continue; + } + let Some(host) = fields.get(format!("{prefix}Proxy").as_str()).copied() else { + continue; + }; + let Some(port) = fields.get(format!("{prefix}Port").as_str()).copied() else { + continue; + }; + if host.is_empty() || port.is_empty() || port == "0" { + continue; + } + if let Ok(url) = fixed_proxy(&format!("{}:{port}", bracket_host(host)), scheme) { + return Ok(url); + } + } + if fields.get("ProxyAutoConfigEnable") == Some(&"1") + || fields.get("ProxyAutoDiscoveryEnable") == Some(&"1") + { + return Ok(String::new()); + } + Ok(String::new()) +} + +#[cfg(any(target_os = "macos", test))] +fn macos_network_service_name(line: &str) -> Option<&str> { + let line = line.trim(); + if line.is_empty() || line.starts_with('*') { + None + } else { + Some(line) + } +} + +#[cfg(any(target_os = "macos", test))] +fn parse_networksetup_proxy(output: &str, scheme: &str) -> Result { + let mut enabled = false; + let mut host = String::new(); + let mut port = String::new(); + for line in output.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let key = key.trim(); + let value = value.trim(); + if key.eq_ignore_ascii_case("Enabled") { + enabled = value.eq_ignore_ascii_case("Yes") || value == "是"; + } else if key.eq_ignore_ascii_case("Server") || key == "服务器" { + host = value.to_string(); + } else if key.eq_ignore_ascii_case("Port") || key == "端口" { + port = value.to_string(); + } + } + if !enabled || host.is_empty() || port.is_empty() || port == "0" { + return Ok(String::new()); + } + fixed_proxy(&format!("{}:{port}", bracket_host(&host)), scheme) +} + +#[cfg(any(target_os = "macos", target_os = "linux", test))] +fn bracket_host(host: &str) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + } +} + +#[cfg(any(target_os = "linux", test))] +fn gnome_manual_proxy( + mode: &str, + https_host: &str, + https_port: &str, + http_host: &str, + http_port: &str, + socks_host: &str, + socks_port: &str, + http_auth: &str, +) -> Result { + match desktop_scalar(mode).as_str() { + "none" | "auto" => Ok(String::new()), + "manual" => { + let http_auth = desktop_scalar(http_auth).eq_ignore_ascii_case("true"); + for (host, port, scheme, auth) in [ + (https_host, https_port, "http", false), + (http_host, http_port, "http", http_auth), + (socks_host, socks_port, "socks5", false), + ] { + let host = desktop_scalar(host); + let port = desktop_scalar(port); + if host.is_empty() || port.is_empty() || port == "0" || auth { + continue; + } + if let Ok(url) = fixed_proxy(&format!("{}:{port}", bracket_host(&host)), scheme) { + return Ok(url); + } + } + Ok(String::new()) + } + _ => Ok(String::new()), + } +} + +#[cfg(any(target_os = "linux", test))] +fn parse_kde_proxy(proxy_type: &str, https: &str, http: &str, socks: &str) -> Result, String> { + match desktop_scalar(proxy_type).as_str() { + "0" | "" | "2" | "3" => Ok(Some(String::new())), + "4" => Ok(None), + _ => { + for value in [https, http, socks] { + let value = desktop_scalar(value); + if value.is_empty() { + continue; + } + if let Ok(url) = parse_detected_proxy(&value) { + return Ok(Some(url)); + } + } + Ok(Some(String::new())) + } + } +} + +#[cfg(target_os = "linux")] +fn gnome_system_proxy() -> Result, String> { + let get = |schema: &str, key: &str| desktop_setting("gsettings", &["get", schema, key]); + let Ok(mode) = get("org.gnome.system.proxy", "mode") else { + return Ok(None); + }; + let https_host = get("org.gnome.system.proxy.https", "host").unwrap_or_default(); + let https_port = get("org.gnome.system.proxy.https", "port").unwrap_or_default(); + let http_host = get("org.gnome.system.proxy.http", "host").unwrap_or_default(); + let http_port = get("org.gnome.system.proxy.http", "port").unwrap_or_default(); + let socks_host = get("org.gnome.system.proxy.socks", "host").unwrap_or_default(); + let socks_port = get("org.gnome.system.proxy.socks", "port").unwrap_or_default(); + let http_auth = get("org.gnome.system.proxy.http", "use-authentication").unwrap_or_default(); + gnome_manual_proxy( + &mode, + &https_host, + &https_port, + &http_host, + &http_port, + &socks_host, + &socks_port, + &http_auth, + ) + .map(Some) +} + +#[cfg(target_os = "linux")] +fn kde_config(key: &str) -> Result { + for program in ["kreadconfig6", "kreadconfig5"] { + if let Ok(value) = desktop_setting( + program, + &[ + "--file", + "kioslaverc", + "--group", + "Proxy Settings", + "--key", + key, + ], + ) { + return Ok(value); + } + } + Err("无法读取 KDE 系统代理".to_string()) +} + +#[cfg(target_os = "linux")] +fn kde_system_proxy() -> Result, String> { + parse_kde_proxy( + &kde_config("ProxyType")?, + &kde_config("httpsProxy").unwrap_or_default(), + &kde_config("httpProxy").unwrap_or_default(), + &kde_config("socksProxy").unwrap_or_default(), + ) +} + +#[cfg(target_os = "linux")] +fn linux_desktop() -> String { + std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_ascii_lowercase() +} + +#[cfg(target_os = "linux")] +fn detect_system_proxy() -> Result { + if let Some(url) = env_proxy() { + return Ok(url); + } + let desktop = linux_desktop(); + let gnome_desktop = ["gnome", "unity", "cinnamon", "budgie", "pantheon"] + .iter() + .any(|name| desktop.contains(name)); + let kde_desktop = desktop.contains("kde") || desktop.contains("plasma"); + if gnome_desktop { + match gnome_system_proxy() { + Ok(Some(url)) => return Ok(url), + Ok(None) => {} + Err(error) => return Err(error), + } + } + if kde_desktop || !gnome_desktop { + match kde_system_proxy() { + Ok(Some(url)) => return Ok(url), + Ok(None) | Err(_) => {} + } + } + if !gnome_desktop { + match gnome_system_proxy() { + Ok(Some(url)) => return Ok(url), + Ok(None) | Err(_) => {} + } + } + Ok(String::new()) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +fn detect_system_proxy() -> Result { + Ok(String::new()) +} + +pub(crate) fn detect() -> String { + detect_system_proxy().unwrap_or_default() +} + +pub(crate) fn resolve(config: &GuiConfigFile) -> String { + if config.proxy_override { + config.proxy_url.clone() + } else { + detect() + } +} + +pub(crate) fn initialize_override(config: &mut GuiConfigFile, had_override: bool) -> bool { + if had_override { + return false; + } + if config.proxy_override { + config.proxy_override = false; + return true; + } + false +} + +pub(crate) fn normalize_optional_proxy_url(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(String::new()); + } + normalize_proxy_url(value) +} + +fn apply(state: &GuiConfigState) -> Result { + let snapshot = state + .inner + .lock() + .map_err(|_| "代理配置锁已损坏".to_string())? + .clone(); + let detected = if snapshot.proxy_override { + None + } else { + Some(detect()) + }; + let mut current = state + .inner + .lock() + .map_err(|_| "代理配置锁已损坏".to_string())?; + let mut next = current.clone(); + next.proxy_url = if next.proxy_override { + next.proxy_url.clone() + } else { + detected.unwrap_or_else(detect) + }; + commit(&mut current, next, patch_core_proxy_url, write_gui_config)?; + Ok(current.clone()) +} + +fn commit( + current: &mut GuiConfigFile, + next: GuiConfigFile, + mut patch: impl FnMut(&str) -> Result<(), String>, + persist: impl FnOnce(&GuiConfigFile) -> Result<(), String>, +) -> Result<(), String> { + let changed = + next.proxy_url != current.proxy_url || next.proxy_override != current.proxy_override; + patch(&next.proxy_url)?; + if changed { + if let Err(error) = persist(&next) { + return Err(config_update_error_with_rollback( + error, + patch(¤t.proxy_url).err(), + )); + } + *current = next; + } + Ok(()) +} + +pub(crate) fn refresh(state: &GuiConfigState) -> Result<(), String> { + apply(state).map(|_| ()) +} + +pub(crate) fn set_manual(state: &GuiConfigState, proxy_url: String) -> Result<(), String> { + let proxy_url = normalize_optional_proxy_url(&proxy_url)?; + let detected = if proxy_url.is_empty() { + Some(detect()) + } else { + None + }; + let mut current = state + .inner + .lock() + .map_err(|_| "代理配置锁已损坏".to_string())?; + let mut next = current.clone(); + next.proxy_override = !proxy_url.is_empty(); + next.proxy_url = if next.proxy_override { + proxy_url + } else { + detected.unwrap_or_else(detect) + }; + commit(&mut current, next, patch_core_proxy_url, write_gui_config) +} + +pub(crate) async fn verify_core_proxy(config: &GuiConfigFile) -> Result<(), String> { + let client = management_api::management_http_client()?; + let mut updated = false; + for attempt in 0..10 { + let response = client + .get(management_api::management_endpoint(config, "proxy-url")?) + .header( + "Authorization", + management_api::management_authorization(config)?, + ) + .timeout(Duration::from_secs(2)) + .send() + .await; + if let Ok(response) = response { + if response.status().is_success() { + if let Ok(value) = response.json::().await { + if let Some(actual) = value.get("proxy-url").and_then(|v| v.as_str()) { + if actual == config.proxy_url { + return Ok(()); + } + if attempt >= 2 && !updated { + let response = client + .put(management_api::management_endpoint(config, "proxy-url")?) + .header( + "Authorization", + management_api::management_authorization(config)?, + ) + .json(&serde_json::json!({"value": config.proxy_url})) + .timeout(Duration::from_secs(3)) + .send() + .await + .map_err(|_| "无法更新内核代理,请重启内核后重试".to_string())?; + if !response.status().is_success() { + return Err( + "内核拒绝代理更新,请重启内核或升级内核后重试".to_string() + ); + } + updated = true; + } + } + } + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + Err( + "代理配置已保存,但尚未确认内核已应用。请确认内核正在运行,必要时重启内核后重试。" + .to_string(), + ) +} + +static SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +async fn synchronize(app: &tauri::AppHandle, require_core: bool) -> Result<(), String> { + let _guard = SYNC_LOCK.lock().await; + let work_app = app.clone(); + let config = tauri::async_runtime::spawn_blocking(move || { + apply(work_app.state::().inner()) + }) + .await + .map_err(|e| e.to_string())??; + if require_core || app.state::().managed_pid().is_some() { + verify_core_proxy(&config).await?; + } + Ok(()) +} + +pub(crate) async fn prepare_oauth(app: &tauri::AppHandle) { + let _ = synchronize(app, true).await; +} + +pub(crate) fn start_monitor(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(15)).await; + if app + .state::() + .shutting_down + .load(Ordering::Acquire) + { + return; + } + let _ = synchronize(&app, false).await; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_gui_write_rolls_back_core() { + let mut current = GuiConfigFile { + proxy_url: "http://localhost:1000".into(), + ..GuiConfigFile::default() + }; + let mut next = current.clone(); + next.proxy_url = "http://localhost:2000".into(); + let mut writes = Vec::new(); + let result = commit( + &mut current, + next, + |url| { + writes.push(url.to_string()); + Ok(()) + }, + |_| Err("disk full".into()), + ); + assert!(result.is_err()); + assert_eq!(writes, ["http://localhost:2000", "http://localhost:1000"]); + assert_eq!(current.proxy_url, "http://localhost:1000"); + } + + #[test] + fn failed_core_write_never_persists() { + let mut current = GuiConfigFile::default(); + let mut next = current.clone(); + next.proxy_url = "http://localhost:7890".into(); + assert!(commit( + &mut current, + next, + |_| Err("read only".into()), + |_| panic!("must not persist") + ) + .is_err()); + assert!(current.proxy_url.is_empty()); + } + + #[test] + fn unchanged_refresh_does_not_rewrite_gui_config() { + let mut current = GuiConfigFile::default(); + let next = current.clone(); + let mut reconciled = false; + commit( + &mut current, + next, + |_| { + reconciled = true; + Ok(()) + }, + |_| panic!("no changes"), + ) + .unwrap(); + assert!(reconciled); + } + + #[tokio::test] + async fn configured_proxy_routes_upstream_but_bypasses_local_management() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let proxy = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_url = format!("http://{}", proxy.local_addr().unwrap()); + let proxy_request = tokio::spawn(async move { + let (mut socket, _) = proxy.accept().await.unwrap(); + let mut bytes = [0; 4096]; + let count = socket.read(&mut bytes).await.unwrap(); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nvia") + .await + .unwrap(); + String::from_utf8_lossy(&bytes[..count]).to_string() + }); + let client = build_http_client_with_proxy( + reqwest::Client::builder().timeout(Duration::from_secs(2)), + &proxy_url, + "test", + ) + .unwrap(); + assert_eq!( + client + .get("http://proxy-target.invalid/test") + .send() + .await + .unwrap() + .text() + .await + .unwrap(), + "via" + ); + assert!(proxy_request + .await + .unwrap() + .starts_with("GET http://proxy-target.invalid/test HTTP/1.1")); + let local = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let local_url = format!("http://{}", local.local_addr().unwrap()); + let response = tokio::spawn(async move { + let (mut socket, _) = local.accept().await.unwrap(); + let mut bytes = [0; 4096]; + let count = socket.read(&mut bytes).await.unwrap(); + assert!(count > 0); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nlocal", + ) + .await + .unwrap(); + }); + assert_eq!( + client + .get(local_url) + .send() + .await + .unwrap() + .text() + .await + .unwrap(), + "local" + ); + response.await.unwrap(); + assert!(client + .get("http://proxy-target.invalid/test") + .send() + .await + .is_err()); + } + + #[test] + fn windows_fixed_and_protocol_specific_proxies() { + assert_eq!( + parse_windows_proxy("127.0.0.1:7890").unwrap(), + "http://127.0.0.1:7890" + ); + assert_eq!( + parse_windows_proxy("http=localhost:1;https=localhost:2;socks=localhost:3").unwrap(), + "http://localhost:2" + ); + assert_eq!( + parse_windows_proxy("socks=[::1]:1080").unwrap(), + "socks5://[::1]:1080" + ); + assert!(parse_windows_proxy("ftp=localhost:21").is_err()); + assert_eq!( + select_windows_proxy("127.0.0.1:7890", "http://wpad/proxy.pac", true).unwrap(), + "http://127.0.0.1:7890" + ); + assert!(select_windows_proxy("", "http://wpad/proxy.pac", false).is_err()); + assert_eq!(select_windows_proxy("", "", false).unwrap(), ""); + } + + #[test] + fn rejects_pac_paths_invalid_ports_and_schemes() { + for url in [ + "", + "localhost:7890", + "file:///proxy.pac", + "http://localhost/proxy.pac", + "http://localhost:0", + "http://localhost:99999", + "http://localhost:8?x=1", + "socks5://localhost", + "http://local host:8", + ] { + assert!(normalize_proxy_url(url).is_err(), "{url}"); + } + assert_eq!( + normalize_proxy_url(" http://localhost:7890/ ").unwrap(), + "http://localhost:7890" + ); + for url in [ + "http://localhost:7890", + "https://proxy.example", + "socks5://[::1]:1080", + "socks5h://user:pass@localhost:1080", + "socks://127.0.0.1:1080", + ] { + assert!(normalize_proxy_url(url).is_ok(), "{url}"); + } + assert_eq!( + normalize_proxy_url("socks://127.0.0.1:1080").unwrap(), + "socks5://127.0.0.1:1080" + ); + assert!(normalize_optional_proxy_url("not a proxy").is_err()); + assert!(normalize_optional_proxy_url("http://127.0.0.1:7890/path").is_err()); + assert_eq!( + parse_detected_proxy(" '127.0.0.1:7890' ").unwrap(), + "http://127.0.0.1:7890" + ); + assert!(parse_detected_proxy("").is_err()); + } + + #[test] + fn user_override_keeps_manual_proxy() { + let mut config = GuiConfigFile { + proxy_url: "socks5://127.0.0.1:1080".into(), + proxy_override: true, + ..GuiConfigFile::default() + }; + assert_eq!(resolve(&config), "socks5://127.0.0.1:1080"); + config.proxy_override = false; + config.proxy_url = "http://localhost:1".into(); + assert_eq!(resolve(&config), detect()); + assert!(!initialize_override(&mut config, true)); + let mut leftover = GuiConfigFile { + proxy_url: "http://127.0.0.1:7890".into(), + proxy_override: true, + ..GuiConfigFile::default() + }; + assert!(!initialize_override(&mut leftover, true)); + assert!(leftover.proxy_override); + let mut auto = GuiConfigFile { + proxy_url: "http://127.0.0.1:7890".into(), + ..GuiConfigFile::default() + }; + assert!(!initialize_override(&mut auto, false)); + assert!(!auto.proxy_override); + assert_eq!(resolve(&auto), detect()); + assert_eq!(normalize_optional_proxy_url("").unwrap(), ""); + assert_eq!( + normalize_optional_proxy_url(" socks5://127.0.0.1:7890/ ").unwrap(), + "socks5://127.0.0.1:7890" + ); + } + + #[test] + fn macos_fixed_and_pac() { + assert_eq!( + parse_macos_proxy(" HTTPEnable : 1\n HTTPProxy : 127.0.0.1\n HTTPPort : 7890") + .unwrap(), + "http://127.0.0.1:7890" + ); + assert_eq!(parse_macos_proxy("HTTPEnable : 0").unwrap(), ""); + assert_eq!( + parse_macos_proxy("ProxyAutoConfigEnable : 1\nHTTPEnable : 1\nHTTPProxy : 127.0.0.1\nHTTPPort : 7890") + .unwrap(), + "http://127.0.0.1:7890" + ); + assert_eq!(parse_macos_proxy("ProxyAutoConfigEnable : 1").unwrap(), ""); + assert_eq!( + parse_macos_proxy("HTTPSEnable : 1\nHTTPSProxy : ::1\nHTTPSPort : 7890").unwrap(), + "http://[::1]:7890" + ); + assert_eq!(macos_network_service_name("*Wi-Fi"), None); + assert_eq!(macos_network_service_name("Wi-Fi"), Some("Wi-Fi")); + } + + #[test] + fn macos_networksetup_and_unix_env_style_proxies() { + assert_eq!( + parse_networksetup_proxy( + "Enabled: Yes\nServer: 127.0.0.1\nPort: 7890", + "http", + ) + .unwrap(), + "http://127.0.0.1:7890" + ); + assert_eq!( + parse_networksetup_proxy("Enabled: No\nServer: 127.0.0.1\nPort: 7890", "http") + .unwrap(), + "" + ); + assert_eq!( + parse_detected_proxy("127.0.0.1:7890").unwrap(), + "http://127.0.0.1:7890" + ); + assert_eq!( + parse_detected_proxy("socks://127.0.0.1:1080").unwrap(), + "socks5://127.0.0.1:1080" + ); + assert_eq!( + parse_env_proxy("socks_proxy", "127.0.0.1:1080").unwrap(), + "socks5://127.0.0.1:1080" + ); + assert_eq!( + parse_env_proxy("https_proxy", "127.0.0.1:7890").unwrap(), + "http://127.0.0.1:7890" + ); + assert_eq!( + gnome_manual_proxy( + "'manual'", + "'127.0.0.1'", + "uint32 7890", + "", + "uint32 0", + "", + "uint32 0", + "false", + ) + .unwrap(), + "http://127.0.0.1:7890" + ); + assert_eq!( + gnome_manual_proxy("none", "", "", "", "", "", "", "false").unwrap(), + "" + ); + assert_eq!( + gnome_manual_proxy( + "manual", + "", + "0", + "127.0.0.1", + "7890", + "127.0.0.1", + "1080", + "true", + ) + .unwrap(), + "socks5://127.0.0.1:1080" + ); + assert_eq!( + gnome_manual_proxy("auto", "127.0.0.1", "7890", "", "", "", "", "false").unwrap(), + "" + ); + assert_eq!( + parse_kde_proxy("1", "http://127.0.0.1:7890", "", "").unwrap(), + Some("http://127.0.0.1:7890".to_string()) + ); + assert_eq!( + parse_kde_proxy("0", "http://127.0.0.1:7890", "", "").unwrap(), + Some(String::new()) + ); + assert_eq!(parse_kde_proxy("2", "", "", "").unwrap(), Some(String::new())); + assert_eq!(parse_kde_proxy("4", "http://127.0.0.1:7890", "", "").unwrap(), None); + assert_eq!( + parse_kde_proxy("1", "http://localhost:0", "socks://127.0.0.1:1080", "").unwrap(), + Some("socks5://127.0.0.1:1080".to_string()) + ); + let mut current = GuiConfigFile { + proxy_url: "http://localhost:1000".into(), + ..GuiConfigFile::default() + }; + let mut next = current.clone(); + next.proxy_override = true; + let mut persisted = false; + commit( + &mut current, + next, + |_| Ok(()), + |_| { + persisted = true; + Ok(()) + }, + ) + .unwrap(); + assert!(persisted); + assert!(current.proxy_override); + } +} diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 48ab2b1..d331c96 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -393,7 +393,7 @@ fn legacy_gui_config_can_seed_managed_core_settings() { assert!(config.request_log); assert!(config.plugins_enabled); assert_eq!(config.routing_strategy, "fill-first"); - assert_eq!(config.proxy_url, "http://127.0.0.1:8080"); + assert_eq!(config.proxy_url, ""); assert!(config.routing_session_affinity); assert_eq!(config.routing_session_affinity_ttl, "45m"); assert_eq!(config.request_retry, 1); @@ -1130,6 +1130,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { plugins_enabled: true, routing_strategy: "fill-first".to_string(), proxy_url: "socks5://127.0.0.1:7890".to_string(), + proxy_override: true, download_source: VersionDownloadSource::Github, custom_download_mirrors: Vec::new(), active_custom_download_mirror: String::new(), diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 263a039..6c00335 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -3296,13 +3296,9 @@ pub(crate) async fn sync_usage_model_prices( .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)); let proxy_url = config.proxy_url.trim(); - let client = if proxy_url.is_empty() { - client_builder.build().ok() - } else { - apply_configured_proxy(client_builder, proxy_url) - .ok() - .and_then(|builder| builder.build().ok()) - }; + let client = apply_configured_proxy(client_builder, proxy_url) + .ok() + .and_then(|builder| builder.build().ok()); let remote_content = match client { Some(client) => match client.get(MODEL_PRICE_SYNC_URL).send().await { Ok(response) if response.status().is_success() => response.text().await.ok(), @@ -4377,6 +4373,7 @@ mod tests { plugins_enabled: false, routing_strategy: "round-robin".to_string(), proxy_url: String::new(), + proxy_override: false, download_source: VersionDownloadSource::Github, custom_download_mirrors: Vec::new(), active_custom_download_mirror: String::new(), diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 7ebff0a..68473c4 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -670,7 +670,7 @@ export const jaOverrides = { 'config.network.listenHostHint': '例: 127.0.0.1、0.0.0.0、または LAN IP。変更すると実行中のコアが再起動します', 'config.network.proxyUrl': 'プロキシ URL', 'config.network.proxyPlaceholder': '例:socks5://127.0.0.1:7890', - 'config.network.proxyHint': 'SOCKS プロトコルと HTTP プロトコルに対応しています', + 'config.network.proxyHint': '手動入力できます。空欄の場合はシステムプロキシを使います。未検出なら空欄のままです。HTTP と SOCKS に対応しています。', 'config.network.sessionAffinity': 'セッションアフィニティ', 'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します', 'config.network.sessionTtl': 'セッションアフィニティ TTL', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index dd084ca..3a9126c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -701,7 +701,7 @@ export const en: Record = { 'config.network.listenHostHint': 'For example, 127.0.0.1, 0.0.0.0, or a LAN IP; changing it restarts a running core', 'config.network.proxyUrl': 'Proxy URL', 'config.network.proxyPlaceholder': 'For example, socks5://127.0.0.1:7890', - 'config.network.proxyHint': 'Supports SOCKS and HTTP protocols', + 'config.network.proxyHint': 'You can enter a proxy URL. Leave it empty to use the system proxy, or none if none is detected. Supports HTTP and SOCKS.', 'config.network.sessionAffinity': 'Session Affinity', 'config.network.sessionAffinityHint': 'Prefer the previously selected credential for the same session', 'config.network.sessionTtl': 'Session Affinity TTL', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 6df3ec3..dc33f0a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -700,7 +700,7 @@ export const zhCN = { 'config.network.listenHostHint': '例如 127.0.0.1、0.0.0.0 或局域网 IP;修改后会重启运行中的内核', 'config.network.proxyUrl': '代理 URL', 'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890', - 'config.network.proxyHint': '支持socks协议与http协议', + 'config.network.proxyHint': '可手动填写;留空则自动读取系统代理,没有就不填。支持 HTTP 与 SOCKS。', 'config.network.sessionAffinity': '会话粘性路由', 'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据', 'config.network.sessionTtl': '会话粘性 TTL', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index c1a1085..6e5f938 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -720,7 +720,9 @@ export function ConfigPanelPage() { setBusyAction('network'); try { const result = await invoke('save_network_endpoint_settings', { - settings: { host, port, proxyUrl }, + settings: networkDraftDirtyRef.current.proxyUrl + ? { host, port, proxyUrl } + : { host, port }, }); clearDraftDirty('host'); clearDraftDirty('port');