fix(network): allow manual proxy overrides when system proxy is detected

This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent 7cde8942d3
commit de01219cc8
10 files changed
+145 -31

No files matched your search

+1 -1
View File
@@ -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(())
+11 -9
View File
@@ -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)?;
+18 -2
View File
@@ -1152,6 +1152,7 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
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<GuiConfigFile, String> {
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());
}
+25 -1
View File
@@ -1489,6 +1489,8 @@ struct GuiNetworkEndpointSettings {
port: u16,
#[serde(default)]
proxy_url: Option<String>,
#[serde(default)]
proxy_override: Option<bool>,
}
#[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<GuiConfigFile, String> {
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<GuiConfigFile, String> {
self.sync_core_settings_internal(settings, None, true)
}
fn sync_core_settings_with_api_key(
&self,
settings: &CoreConfigSettings,
added_api_key: Option<GuiApiKeyEntry>,
) -> Result<GuiConfigFile, String> {
self.sync_core_settings_internal(settings, added_api_key, false)
}
fn sync_core_settings_internal(
&self,
settings: &CoreConfigSettings,
added_api_key: Option<GuiApiKeyEntry>,
apply_external_proxy: bool,
) -> Result<GuiConfigFile, String> {
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,
+12 -6
View File
@@ -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<String, String> {
@@ -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(), "");
+34
View File
@@ -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::<serde_norway::Value>(&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";
+3 -1
View File
@@ -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',
+3 -1
View File
@@ -719,7 +719,9 @@ 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': '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',
+3 -1
View File
@@ -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',
+35 -9
View File
@@ -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<CoreConfigSettings>('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() {
<small>{t('config.network.listenHostHint')}</small>
</label>
<label className="config-network-field">
<span className="config-network-label">
<Link2 size={15} aria-hidden="true" />
{t('config.network.proxyUrl')}
</span>
<div className="config-network-field config-proxy-field">
<div className="config-proxy-heading">
<label className="config-network-label" htmlFor="config-network-proxy-url">
<Link2 size={15} aria-hidden="true" />
{t('config.network.proxyUrl')}
</label>
<label className="config-proxy-system-toggle" title={t('config.network.systemProxyHint')}>
<span>{t('config.network.systemProxy')}</span>
<span className="switch-control">
<input
type="checkbox"
checked={!proxyOverrideDraft}
disabled={controlsDisabled}
onChange={(event) => {
markDraftDirty('proxyOverride');
setProxyOverrideDraft(!event.currentTarget.checked);
}}
/>
<span className="switch-track" />
</span>
</label>
</div>
<input
id="config-network-proxy-url"
className="config-network-input"
type="text"
value={proxyUrlDraft}
disabled={controlsDisabled}
disabled={controlsDisabled || !proxyOverrideDraft}
placeholder={t('config.network.proxyPlaceholder')}
onChange={(event) => {
markDraftDirty('proxyUrl');
@@ -1526,7 +1552,7 @@ export function ConfigPanelPage() {
}}
/>
<small>{t('config.network.proxyHint')}</small>
</label>
</div>
</div>
</section>