diff --git a/src-tauri/src/configuration_watcher.rs b/src-tauri/src/configuration_watcher.rs index 70a1d23..2190df8 100644 --- a/src-tauri/src/configuration_watcher.rs +++ b/src-tauri/src/configuration_watcher.rs @@ -194,7 +194,7 @@ fn handle_configuration_file_changes( if preserve_invalid_gui_file { gui_state.replace_core_settings_external(&settings)?; } else { - gui_state.sync_core_settings(&settings)?; + gui_state.sync_core_settings_external(&settings)?; } cache.clear()?; Ok(()) diff --git a/src-tauri/src/core_config/commands.rs b/src-tauri/src/core_config/commands.rs index b354c4a..cb595d4 100644 --- a/src-tauri/src/core_config/commands.rs +++ b/src-tauri/src/core_config/commands.rs @@ -160,18 +160,20 @@ pub(crate) fn save_network_endpoint_settings( next.host = host; next.allow_lan = !is_loopback_host(&next.host); next.port = settings.port; - 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(); + if settings.proxy_url.is_some() || settings.proxy_override.is_some() { + let proxy_url = network_proxy::normalize_optional_proxy_url( + settings.proxy_url.as_deref().unwrap_or(&next.proxy_url), + )?; + // Explicit mode selection lets an empty manual URL mean direct access. + // Calls from older frontends keep the legacy behavior where an empty URL + // means following the system proxy. + next.proxy_override = settings + .proxy_override + .unwrap_or_else(|| !proxy_url.is_empty()); next.proxy_url = if next.proxy_override { proxy_url } else { - detected.unwrap_or_else(network_proxy::detect) + network_proxy::detect() }; } validate_gui_config(&next)?; diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index ae4a388..5d5808b 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -1152,6 +1152,7 @@ pub(crate) fn load_or_create_gui_config() -> Result { if core_is_newer && !gui_parse_failed { if let Ok(core_settings) = read_installed_core_config_settings() { apply_core_settings_to_gui_config(&mut config, &core_settings); + apply_external_core_proxy_override(&mut config, &core_settings)?; changed = true; } } @@ -1239,8 +1240,9 @@ 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() && config.proxy_override { + if presence.proxy_url.is_none() { config.proxy_url = core_settings.proxy_url; + config.proxy_override = !config.proxy_url.trim().is_empty(); } if presence.routing_session_affinity.is_none() { config.routing_session_affinity = core_settings.routing_session_affinity; @@ -1372,6 +1374,20 @@ pub(crate) fn apply_core_settings_to_gui_config( config.streaming_bootstrap_retries = core_settings.streaming_bootstrap_retries; } +pub(crate) fn apply_external_core_proxy_override( + config: &mut GuiConfigFile, + core_settings: &CoreConfigSettings, +) -> Result<(), String> { + if core_settings.proxy_url == config.proxy_url { + return Ok(()); + } + config.proxy_url = network_proxy::normalize_optional_proxy_url(&core_settings.proxy_url)?; + // Changes made directly in the core YAML are user choices. Persist them as + // manual overrides so the system-proxy monitor does not revert them. + config.proxy_override = true; + Ok(()) +} + pub(crate) fn default_api_key_entry() -> GuiApiKeyEntry { GuiApiKeyEntry { key: DEFAULT_API_KEY.to_string(), @@ -1890,7 +1906,7 @@ 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_override { - network_proxy::normalize_proxy_url(&config.proxy_url)?; + network_proxy::normalize_optional_proxy_url(&config.proxy_url)?; } else if config.proxy_url.chars().any(char::is_control) { return Err("代理 URL 不能包含控制字符".to_string()); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 53a45c5..5de041a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1489,6 +1489,8 @@ struct GuiNetworkEndpointSettings { port: u16, #[serde(default)] proxy_url: Option, + #[serde(default)] + proxy_override: Option, } #[derive(Deserialize)] @@ -1589,6 +1591,7 @@ struct CoreConfigView { plugins_enabled: bool, routing_strategy: String, proxy_url: String, + proxy_override: bool, routing_session_affinity: bool, routing_session_affinity_ttl: String, disable_cooling: bool, @@ -1974,6 +1977,7 @@ impl GuiConfigState { .map_err(|_| "GUI 配置状态锁已损坏".to_string())?; let mut config = current.clone(); apply_core_settings_to_gui_config(&mut config, settings); + apply_external_core_proxy_override(&mut config, settings)?; sanitize_gui_config(&mut config)?; validate_gui_config(&config)?; *current = config.clone(); @@ -2143,13 +2147,29 @@ impl GuiConfigState { } fn sync_core_settings(&self, settings: &CoreConfigSettings) -> Result { - self.sync_core_settings_with_api_key(settings, None) + self.sync_core_settings_internal(settings, None, false) + } + + fn sync_core_settings_external( + &self, + settings: &CoreConfigSettings, + ) -> Result { + self.sync_core_settings_internal(settings, None, true) } fn sync_core_settings_with_api_key( &self, settings: &CoreConfigSettings, added_api_key: Option, + ) -> Result { + self.sync_core_settings_internal(settings, added_api_key, false) + } + + fn sync_core_settings_internal( + &self, + settings: &CoreConfigSettings, + added_api_key: Option, + apply_external_proxy: bool, ) -> Result { self.update(|config| { config.api_keys = merge_core_api_keys_with_gui_metadata( @@ -2187,6 +2207,9 @@ impl GuiConfigState { config.max_retry_credentials = settings.max_retry_credentials; config.max_retry_interval = settings.max_retry_interval; config.streaming_bootstrap_retries = settings.streaming_bootstrap_retries; + if apply_external_proxy { + apply_external_core_proxy_override(config, settings)?; + } Ok(()) }) } @@ -2277,6 +2300,7 @@ impl From<&GuiConfigFile> for CoreConfigView { plugins_enabled: config.plugins_enabled, routing_strategy: config.routing_strategy.clone(), proxy_url: config.proxy_url.clone(), + proxy_override: config.proxy_override, routing_session_affinity: config.routing_session_affinity, routing_session_affinity_ttl: config.routing_session_affinity_ttl.clone(), disable_cooling: config.disable_cooling, diff --git a/src-tauri/src/network_proxy.rs b/src-tauri/src/network_proxy.rs index cebffa9..bf01335 100644 --- a/src-tauri/src/network_proxy.rs +++ b/src-tauri/src/network_proxy.rs @@ -492,11 +492,10 @@ pub(crate) fn initialize_override(config: &mut GuiConfigFile, had_override: bool if had_override { return false; } - if config.proxy_override { - config.proxy_override = false; - return true; - } - false + // Before proxy-override existed, every non-empty proxy-url was explicitly + // entered by the user. Preserve that choice when migrating the config. + config.proxy_override = !config.proxy_url.trim().is_empty(); + true } pub(crate) fn normalize_optional_proxy_url(value: &str) -> Result { @@ -877,11 +876,18 @@ mod tests { }; assert!(!initialize_override(&mut leftover, true)); assert!(leftover.proxy_override); + let mut legacy = GuiConfigFile { + proxy_url: "http://127.0.0.1:7890".into(), + ..GuiConfigFile::default() + }; + assert!(initialize_override(&mut legacy, false)); + assert!(legacy.proxy_override); + assert_eq!(resolve(&legacy), "http://127.0.0.1:7890"); let mut auto = GuiConfigFile { proxy_url: "http://127.0.0.1:7890".into(), ..GuiConfigFile::default() }; - assert!(!initialize_override(&mut auto, false)); + assert!(!initialize_override(&mut auto, true)); assert!(!auto.proxy_override); assert_eq!(resolve(&auto), detect()); assert_eq!(normalize_optional_proxy_url("").unwrap(), ""); diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index d331c96..d5d9a12 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -403,6 +403,40 @@ fn legacy_gui_config_can_seed_managed_core_settings() { assert!(config.run_on_startup); } +#[test] +fn external_core_proxy_changes_become_manual_overrides() { + let settings_with_proxy = |proxy_url: &str| { + let input = format!("proxy-url: \"{proxy_url}\"\n"); + let document = serde_norway::from_str::(&input).unwrap(); + core_config_settings_from_value(&document).unwrap() + }; + + let detected_url = "http://127.0.0.1:7890"; + let mut unchanged = GuiConfigFile { + proxy_url: detected_url.to_string(), + proxy_override: false, + ..GuiConfigFile::default() + }; + apply_external_core_proxy_override(&mut unchanged, &settings_with_proxy(detected_url)).unwrap(); + assert!(!unchanged.proxy_override); + + let mut custom = unchanged.clone(); + apply_external_core_proxy_override( + &mut custom, + &settings_with_proxy("socks5://127.0.0.1:1080"), + ) + .unwrap(); + assert!(custom.proxy_override); + assert_eq!(custom.proxy_url, "socks5://127.0.0.1:1080"); + + let mut direct = unchanged; + apply_external_core_proxy_override(&mut direct, &settings_with_proxy("")).unwrap(); + assert!(direct.proxy_override); + assert!(direct.proxy_url.is_empty()); + ensure_strong_management_secret(&mut direct).unwrap(); + assert!(validate_gui_config(&direct).is_ok()); +} + #[test] fn example_api_keys_are_not_persisted_as_gui_settings() { let input = "api-keys:\n - your-api-key-1\n - real-key\nremote-management:\n secret-key: plain-management-secret\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\n"; diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 1c85a21..61b0f9d 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -688,7 +688,9 @@ 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': '手動入力できます。空欄の場合はシステムプロキシを使います。未検出なら空欄のままです。HTTP と SOCKS に対応しています。', + 'config.network.proxyHint': '「システムに従う」をオフにすると編集できます。手動設定が空欄の場合は直接接続します。HTTP と SOCKS に対応しています。', + 'config.network.systemProxy': 'システムに従う', + 'config.network.systemProxyHint': 'オンの場合は検出したシステムプロキシを、オフの場合は下の手動設定を使用します', '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 9d26806..5558a89 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -719,7 +719,9 @@ 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': '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.proxyHint': 'Turn off “Follow system” to customize it. An empty manual value means a direct connection. Supports HTTP and SOCKS.', + 'config.network.systemProxy': 'Follow system', + 'config.network.systemProxyHint': 'Use the detected system proxy when enabled; use the manual value below when disabled', '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 40ccdf8..7ea72e8 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -718,7 +718,9 @@ 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': '可手动填写;留空则自动读取系统代理,没有就不填。支持 HTTP 与 SOCKS。', + 'config.network.proxyHint': '关闭“跟随系统”后可自定义;手动留空表示直连。支持 HTTP 与 SOCKS。', + 'config.network.systemProxy': '跟随系统', + 'config.network.systemProxyHint': '开启时自动使用系统代理;关闭后使用下方的手动配置', 'config.network.sessionAffinity': '会话粘性路由', 'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据', 'config.network.sessionTtl': '会话粘性 TTL', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index 6e5f938..e72f70f 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -50,6 +50,7 @@ type CoreConfigSettings = { allowLan: boolean; routingStrategy: string; proxyUrl: string; + proxyOverride: boolean; routingSessionAffinity: boolean; routingSessionAffinityTtl: string; disableCooling: boolean; @@ -83,6 +84,7 @@ type NetworkDraftField = | 'port' | 'host' | 'proxyUrl' + | 'proxyOverride' | 'sessionAffinity' | 'sessionTtl' | 'disableCooling' @@ -118,6 +120,7 @@ const cleanNetworkDraft = (): NetworkDraftDirty => ({ port: false, host: false, proxyUrl: false, + proxyOverride: false, sessionAffinity: false, sessionTtl: false, disableCooling: false, @@ -190,6 +193,7 @@ export function ConfigPanelPage() { const [portDraft, setPortDraft] = useState('8317'); const [hostDraft, setHostDraft] = useState('127.0.0.1'); const [proxyUrlDraft, setProxyUrlDraft] = useState(''); + const [proxyOverrideDraft, setProxyOverrideDraft] = useState(false); const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false); const [sessionTtlDraft, setSessionTtlDraft] = useState(''); const [disableCoolingDraft, setDisableCoolingDraft] = useState(false); @@ -236,6 +240,7 @@ export function ConfigPanelPage() { if (!dirty.port) setPortDraft(String(result.port)); if (!dirty.host) setHostDraft(result.host); if (!dirty.proxyUrl) setProxyUrlDraft(result.proxyUrl); + if (!dirty.proxyOverride) setProxyOverrideDraft(result.proxyOverride); if (!dirty.sessionAffinity) setSessionAffinityDraft(result.routingSessionAffinity); if (!dirty.sessionTtl) setSessionTtlDraft(result.routingSessionAffinityTtl); if (!dirty.disableCooling) setDisableCoolingDraft(result.disableCooling); @@ -261,6 +266,7 @@ export function ConfigPanelPage() { setPortDraft(String(result.port)); setHostDraft(result.host); setProxyUrlDraft(result.proxyUrl); + setProxyOverrideDraft(result.proxyOverride); setSessionAffinityDraft(result.routingSessionAffinity); setSessionTtlDraft(result.routingSessionAffinityTtl); setDisableCoolingDraft(result.disableCooling); @@ -720,13 +726,14 @@ export function ConfigPanelPage() { setBusyAction('network'); try { const result = await invoke('save_network_endpoint_settings', { - settings: networkDraftDirtyRef.current.proxyUrl - ? { host, port, proxyUrl } + settings: networkDraftDirtyRef.current.proxyUrl || networkDraftDirtyRef.current.proxyOverride + ? { host, port, proxyUrl, proxyOverride: proxyOverrideDraft } : { host, port }, }); clearDraftDirty('host'); clearDraftDirty('port'); clearDraftDirty('proxyUrl'); + clearDraftDirty('proxyOverride'); applySettings(result, 'preserve'); setLoadError(''); @@ -872,6 +879,7 @@ export function ConfigPanelPage() { hostDraft.trim() !== settings?.host || portDraft !== String(settings?.port) || proxyUrlDraft.trim() !== settings?.proxyUrl + || proxyOverrideDraft !== settings?.proxyOverride ); const sessionRoutingDirty = Boolean(settings) && ( sessionAffinityDraft !== settings?.routingSessionAffinity @@ -1502,16 +1510,34 @@ export function ConfigPanelPage() { {t('config.network.listenHostHint')} -