feat(network): auto-detect system proxy and keep user overrides

This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent a372a819f8
commit 050dbddca4
15 files changed
+1109 -43

No files matched your search

+1
View File
@@ -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",
+2
View File
@@ -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)
+1
View File
@@ -147,6 +147,7 @@ fn handle_configuration_file_changes(
let mut config = toml::from_str::<GuiConfigFile>(&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)
})();
+16 -17
View File
@@ -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::<IpAddr>().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<CoreConfigView, String> {
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]
+21 -4
View File
@@ -1230,7 +1230,7 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
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<GuiConfigFile, String> {
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<bool, St
if config.api_keys != original_api_keys {
changed = true;
}
let proxy_url = config.proxy_url.trim().to_string();
let proxy_url = if config.proxy_override {
network_proxy::normalize_optional_proxy_url(&config.proxy_url)?
} else {
let proxy_url = config.proxy_url.trim().to_string();
if proxy_url.is_empty() || network_proxy::normalize_proxy_url(&proxy_url).is_ok() {
proxy_url
} else {
String::new()
}
};
if config.proxy_url != proxy_url {
config.proxy_url = proxy_url;
changed = true;
@@ -1723,6 +1735,7 @@ pub(crate) fn write_gui_config_to_path(
("plugins-enabled", value(config.plugins_enabled)),
("routing-strategy", value(config.routing_strategy.as_str())),
("proxy-url", value(config.proxy_url.as_str())),
("proxy-override", value(config.proxy_override)),
("download-source", value(config.download_source.as_str())),
(
"prefer-gitcode-downloads",
@@ -1754,6 +1767,8 @@ pub(crate) fn write_gui_config_to_path(
set_codex_table_item(root, key, item);
}
for key in [
"proxy-mode",
"proxy-manual-url",
"codex-session-repair-on-launch",
"claude-code-working-directory",
"claude-code-working-directory-prompt-disabled",
@@ -1853,7 +1868,9 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> 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 {
+27 -7
View File
@@ -376,6 +376,7 @@ pub(crate) fn start_core_process_with_state(
process_state: &CoreProcessState,
gui_config_state: &GuiConfigState,
) -> Result<CoreStatus, String> {
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<CoreStatus, String> {
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<reqwest::ClientBuilder, String> {
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> {
+11 -3
View File
@@ -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<String>,
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<bool>,
routing_strategy: Option<String>,
proxy_url: Option<String>,
proxy_override: Option<bool>,
download_source: Option<VersionDownloadSource>,
custom_download_mirrors: Option<Vec<String>>,
active_custom_download_mirror: Option<String>,
@@ -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<String>,
}
#[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::<GuiConfigState>().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();
+1
View File
@@ -179,6 +179,7 @@ pub(crate) async fn start_oauth_login(
provider: String,
browser: Option<String>,
) -> Result<OAuthStartResult, String> {
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()?;
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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(),
+4 -7
View File
@@ -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(),
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -701,7 +701,7 @@ export const en: Record<MessageKey, string> = {
'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',
+1 -1
View File
@@ -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',
+3 -1
View File
@@ -720,7 +720,9 @@ export function ConfigPanelPage() {
setBusyAction('network');
try {
const result = await invoke<CoreConfigSettings>('save_network_endpoint_settings', {
settings: { host, port, proxyUrl },
settings: networkDraftDirtyRef.current.proxyUrl
? { host, port, proxyUrl }
: { host, port },
});
clearDraftDirty('host');
clearDraftDirty('port');