From 747a68ef2f7167af9e65d6061200176eb2b1cd0d Mon Sep 17 00:00:00 2001 From: wonfen Date: Mon, 17 Aug 2026 23:28:32 +0800 Subject: [PATCH] fix(port): stop the mixed port climbing on every restart The startup fallback persisted its replacement, so a port blocked once was never asked for again. It now overrides the Runtime Config for the session only. Most of those conflicts were not real: SO_REUSEADDR cannot reuse a TIME_WAIT socket owned by another user, and a service-managed core runs as root while the app does not. A refused bind is now confirmed by asking whether anything answers. Also restores the borrowed port when saving proxy ports fails. --- Changelog.md | 2 +- src-tauri/src/config/config.rs | 8 +- src-tauri/src/config/mixed_port.rs | 81 +++++++++------- src-tauri/src/config/port.rs | 142 ++++++++++++++++------------- src-tauri/src/core/listener.rs | 142 +++++++++++++++++++++++++++-- src-tauri/src/feat/listener.rs | 23 ++++- src/locales/ar/settings.json | 2 +- src/locales/de/settings.json | 2 +- src/locales/en/settings.json | 2 +- src/locales/es/settings.json | 2 +- src/locales/fa/settings.json | 2 +- src/locales/id/settings.json | 2 +- src/locales/jp/settings.json | 2 +- src/locales/ko/settings.json | 2 +- src/locales/ru/settings.json | 2 +- src/locales/tr/settings.json | 2 +- src/locales/tt/settings.json | 2 +- src/locales/zh/settings.json | 2 +- src/locales/zhtw/settings.json | 2 +- 19 files changed, 304 insertions(+), 120 deletions(-) diff --git a/Changelog.md b/Changelog.md index fbefc0d83..7b3630b5f 100644 --- a/Changelog.md +++ b/Changelog.md @@ -19,7 +19,7 @@
✨ 新增功能 -- 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示 +- 新增混合代理端口冲突自动回退:启动时检测端口占用并临时改用可用端口,配置中的端口保持不变、下次启动重新尝试,同时统一首页、设置与系统代理中的生效端口显示 - 系统代理或 TUN 因权限被拒时弹出引导窗口,一步安装系统服务并自动恢复原本的设置 - 引导窗口显示当前阶段:安装服务 / 重启内核 / 应用代理 - 系统代理失败会保留到窗口可见时再呈现,不会因窗口隐藏或最小化而丢失 diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index 33269a393..07ec717e3 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -1,10 +1,11 @@ -use super::{IClashTemp, IProfiles, IVerge}; +use super::{IClashTemp, IProfiles, IVerge, MixedPort}; use crate::{ config::{PrfItem, profiles_append_item_to_safe, runtime::IRuntime}, constants::{files, timing}, core::{ CoreManager, handle::{self, Handle}, + listener::MIXED_PORT_KEY, tray, validate::CoreConfigValidator, }, @@ -258,6 +259,11 @@ impl Config { let (mut config, exists_keys, logs) = enhance::enhance(profiles).await?; sanitize_tunnels_proxy(&mut config); + // Applied here rather than in the Merge Config: this is the only place the Core's config + // is built, and `config.yaml` must stay unaware so the next launch asks for the original. + if let Some(port) = MixedPort::session_fallback() { + config.insert(MIXED_PORT_KEY.into(), port.into()); + } Self::runtime().await.edit_draft(|d| { *d = IRuntime { diff --git a/src-tauri/src/config/mixed_port.rs b/src-tauri/src/config/mixed_port.rs index a8da3da35..4659c8c7b 100644 --- a/src-tauri/src/config/mixed_port.rs +++ b/src-tauri/src/config/mixed_port.rs @@ -1,16 +1,10 @@ //! Resolving the Mixed Port. //! -//! Two different questions were being asked with one answer: -//! -//! - *What have we configured?* — the user's selection, else the Merge Config. Everything that -//! writes configuration or decides what to start needs this, and it has to be answerable -//! before the Core exists. -//! - *What is the Core actually serving on?* — what the Core reports, else what we configured. -//! Anything that points a client at the proxy wants this, because the two can differ: a -//! startup fallback moves the port, and a hand-edited Core config can move it too. -//! -//! Before, six call sites hand-copied the first chain (one of them skipping the Merge Config -//! entirely) and the frontend hand-copied a version of the second. +//! Two questions, deliberately kept apart because the answers can differ: +//! [`MixedPort::desired`] — what we configured, answerable before the Core exists; +//! [`MixedPort::effective`] — what the Core reports it is serving on. + +use std::sync::atomic::{AtomicU16, Ordering}; use anyhow::Result; @@ -20,33 +14,49 @@ use crate::core::handle::Handle; /// The port mihomo listens on when nothing else is configured. pub const DEFAULT_MIXED_PORT: u16 = 7897; +/// The port a startup fallback moved this session to, or 0 for none. +/// +/// Never persisted: the user's port stays in `verge.yaml` and `config.yaml` so the next launch +/// asks for it again. Persisting it instead made a once-blocked port climb on every launch. +static SESSION_FALLBACK: AtomicU16 = AtomicU16::new(0); + /// Resolving the Mixed Port. pub struct MixedPort; impl MixedPort { - /// The Mixed Port the app has configured. + /// Read by [`Self::desired`] and by runtime config generation — the two places that decide + /// what the Core is asked to listen on. + pub(crate) fn session_fallback() -> Option { + match SESSION_FALLBACK.load(Ordering::Acquire) { + 0 => None, + port => Some(port), + } + } + + pub(crate) fn set_session_fallback(port: u16) { + SESSION_FALLBACK.store(port, Ordering::Release); + } + + pub(crate) fn clear_session_fallback() { + SESSION_FALLBACK.store(0, Ordering::Release); + } + + /// The Mixed Port the app has configured. Safe before the Core exists. /// - /// Reads the draft layer, so an edit in progress is visible to the code applying it. Safe - /// to call before the Core is running, unlike [`Self::effective`]. - /// - /// Reading the draft means a caller that hands this to something *outside* the app — the - /// PAC endpoint is the one that does — can name a port the Core has not moved to yet. - /// What makes that safe is that the only path staging a listener port, - /// `feat::listener::save_proxy_ports`, closes PAC across the staging with `core_starting` - /// and reopens it with `core_start_settled`. A second path that stages a listener port - /// while the Core is serving would have to do the same. + /// Reads the draft layer, so this can name a port the Core has not moved to yet — which + /// matters because the PAC endpoint hands it outside the app. Safe only because + /// `feat::listener::save_proxy_ports` closes PAC across the staging (`core_starting` / + /// `core_start_settled`); any other path staging a listener port must do the same. pub async fn desired() -> u16 { let selected = Config::verge().await.latest_arc().verge_mixed_port; // `get_mixed_port` already falls back to the default when the Merge Config is silent. let merged = Config::clash().await.latest_arc().get_mixed_port(); - resolve_desired(selected, merged) + resolve_desired(Self::session_fallback(), selected, merged) } /// The Mixed Port the Core is actually serving on. /// - /// Costs one round-trip to the Core, so it belongs on paths a user triggers rather than on - /// paths served per request. Falls back to [`Self::desired`] when the Core cannot be asked, - /// which is the right answer whenever the Core is not running anyway. + /// Costs a round-trip, so use it on user-triggered paths, not per-request ones. pub async fn effective() -> u16 { let desired = Self::desired().await; resolve_effective( @@ -57,11 +67,12 @@ impl MixedPort { } } -/// Prefer what the user selected, else what the Merge Config resolved to. -const fn resolve_desired(selected: Option, merged: u16) -> u16 { - match selected { - Some(port) => port, - None => merged, +/// Fallback first — it is the only value not written down, so the files still name the port the +/// user asked for while this session serves another. +const fn resolve_desired(session_fallback: Option, selected: Option, merged: u16) -> u16 { + match (session_fallback, selected) { + (Some(port), _) | (None, Some(port)) => port, + (None, None) => merged, } } @@ -85,17 +96,21 @@ mod tests { #[test] fn a_selected_port_wins_over_the_merge_config() { - assert_eq!(resolve_desired(Some(9000), 7897), 9000); + assert_eq!(resolve_desired(None, Some(9000), 7897), 9000); } #[test] fn the_merge_config_answers_when_nothing_is_selected() { - assert_eq!(resolve_desired(None, 8080), 8080); + assert_eq!(resolve_desired(None, None, 8080), 8080); + } + + #[test] + fn a_session_fallback_outranks_the_port_still_written_down() { + assert_eq!(resolve_desired(Some(7900), Some(7897), 7897), 7900); } #[tokio::test] async fn a_reporting_core_overrides_what_we_configured() { - // This is the startup-fallback case: we asked for 7897 and the Core landed on 7898. assert_eq!(resolve_effective(|| async { Ok(7898) }, 7897).await, 7898); } diff --git a/src-tauri/src/config/port.rs b/src-tauri/src/config/port.rs index a40464b69..85aa55fbc 100644 --- a/src-tauri/src/config/port.rs +++ b/src-tauri/src/config/port.rs @@ -1,12 +1,9 @@ -use super::{ - Config, ConfigType, IClashTemp, IVerge, - snapshot::{capture_config_files, restore_files}, -}; +use super::{Config, ConfigType, IClashTemp, IVerge, MixedPort}; use crate::{ constants::timing, core::{ handle::Handle, - listener::{ListenerBindScope, MIXED_PORT_KEY, proxy_listener_keys}, + listener::{ListenerBindScope, ListenerProbeOutcome, MIXED_PORT_KEY, proxy_listener_keys}, owner_identity::current_owner_credentials, service::{SERVICE_MANAGER, ServiceStatus}, validate::CoreConfigValidator, @@ -50,7 +47,9 @@ impl Config { let _config_write = Self::lock_config_write().await; let clash = Self::clash().await.latest_arc(); let verge = Self::verge().await.latest_arc(); - let selected_port = clash.get_mixed_port(); + // A retry must re-examine the port this session actually tried, not the one still named + // on disk, or it keeps picking the same replacement instead of walking past it. + let selected_port = MixedPort::session_fallback().unwrap_or_else(|| clash.get_mixed_port()); let bind_scope = ListenerBindScope::from_mapping(&clash.0).context("failed to derive mixed proxy listener scope")?; @@ -65,11 +64,24 @@ impl Config { } let selected_scope = bind_scope.clone(); - let port_in_use = AsyncHandler::spawn_blocking(move || !selected_scope.mixed_port_is_available(selected_port)) + let outcome = AsyncHandler::spawn_blocking(move || selected_scope.probe_mixed_port(selected_port)) .await .context("mixed proxy port probe task failed")?; - if !port_in_use { - return Ok(false); + // Only a proven conflict may move the port; an answer the probe could not reach is not + // evidence. A `Conflict` here also means the probe confirmed something is serving. + match outcome { + ListenerProbeOutcome::Conflict { .. } => {} + ListenerProbeOutcome::Available => return Ok(false), + ListenerProbeOutcome::Invalid { message } | ListenerProbeOutcome::Indeterminate { message } => { + logging!( + warn, + Type::Setup, + "Could not establish whether mixed proxy port {} is free ({}); keeping it", + selected_port, + message + ); + return Ok(false); + } } let reserved = configured_listener_ports(&clash, &verge); @@ -86,20 +98,41 @@ impl Config { Ok(true) } + /// Move this session onto `new_port` without touching what is on disk. + /// + /// Only the Runtime Configuration changes — the Core is its only reader and it is rebuilt + /// every launch. `config.yaml` and `verge.yaml` keep naming `old_port` so the next launch + /// asks for it again. async fn apply_startup_mixed_port_fallback(old_port: u16, new_port: u16) -> Result<()> { - let clash = Self::clash().await; - let verge = Self::verge().await; let runtime = Self::runtime().await; - // Roll back all three drafts if fallback fails. - let transaction = DraftTransaction::begin(vec![&clash, &verge, &runtime])?; + let transaction = DraftTransaction::begin(vec![&runtime])?; - clash.edit_draft(|draft| { - draft.0.insert(MIXED_PORT_KEY.into(), new_port.into()); - }); - verge.edit_draft(|draft| { - draft.verge_mixed_port = Some(new_port); - }); + // On a later hop the uncommitted transaction restores the Runtime Config to the previous + // hop, so restore the override to match rather than dropping to what is on disk. + let previous_port = MixedPort::session_fallback(); + MixedPort::set_session_fallback(new_port); + if let Err(error) = Self::stage_fallback_runtime().await { + match previous_port { + Some(port) => MixedPort::set_session_fallback(port), + None => MixedPort::clear_session_fallback(), + } + return Err(error); + } + transaction.commit(); + record_fallback(old_port, new_port); + Handle::refresh_clash(); + logging!( + warn, + Type::Config, + "Mixed proxy port {} is externally occupied; serving on {} for this session only", + old_port, + new_port + ); + Ok(()) + } + + async fn stage_fallback_runtime() -> Result<()> { Self::generate() .await .context("failed to materialize runtime configuration with fallback port")?; @@ -112,46 +145,9 @@ impl Config { bail!("runtime configuration with fallback port is invalid: {validation}"); } - let snapshots = capture_config_files().await?; - let candidate_clash = clash.latest_arc(); - let candidate_verge = verge.latest_arc(); - - let persist_result = async { - candidate_clash - .save_config() - .await - .context("failed to persist Application Merge Configuration")?; - candidate_verge - .save_file() - .await - .context("failed to persist selected mixed proxy port")?; - Self::generate_file(ConfigType::Run) - .await - .context("failed to persist Runtime Configuration")?; - Ok::<(), anyhow::Error>(()) - } - .await; - - // Restore files after rollback so regeneration sees committed values. - if let Err(error) = persist_result { - transaction.rollback(); - return match restore_files(&snapshots).await { - Ok(()) => Err(error), - Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")), - }; - } - - transaction.commit(); - record_fallback(old_port, new_port); - Handle::refresh_clash(); - Handle::refresh_verge(); - logging!( - warn, - Type::Config, - "Mixed proxy port {} is externally occupied; switched to {} and persisted all configuration layers", - old_port, - new_port - ); + Self::generate_file(ConfigType::Run) + .await + .context("failed to write Runtime Configuration")?; Ok(()) } @@ -209,9 +205,16 @@ fn report_fallback_error(message: String) { }); } -// Only a service-managed core can still be running during startup. +// Only a service-managed core can still be running during startup. Every exit here decides +// whether the app may renumber a port the user chose, so each one logs why. async fn owned_service_core_uses_port(port: u16) -> bool { - if !matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) { + let service_status = SERVICE_MANAGER.current().await; + if !matches!(service_status, ServiceStatus::Ready) { + logging!( + info, + Type::Service, + "Service is {service_status:?}, so no managed core can hold mixed proxy port {port}" + ); return false; } @@ -238,9 +241,26 @@ async fn owned_service_core_uses_port(port: u16) -> bool { } }; let Some(status) = response.data else { + logging!( + warn, + Type::Service, + "Service returned no status while checking mixed proxy port {port}" + ); return false; }; if response.code > 0 || !status.is_active || status.core_pid.is_none() { + // Normal after a clean quit: the service drops the owner session and forgets the core, so + // it can no longer vouch for whatever holds the port. This guard only covers dirty exits. + logging!( + info, + Type::Service, + "Service no longer tracks a core for this owner (code {}, active {}, pid {:?}); \ + mixed proxy port {} is unattributed", + response.code, + status.is_active, + status.core_pid, + port + ); return false; } diff --git a/src-tauri/src/core/listener.rs b/src-tauri/src/core/listener.rs index 33f7e51a1..e6d84df20 100644 --- a/src-tauri/src/core/listener.rs +++ b/src-tauri/src/core/listener.rs @@ -1,5 +1,4 @@ use anyhow::{Context as _, Result, anyhow, bail}; -#[cfg(target_os = "macos")] use clash_verge_logging::{Type as LogType, logging}; #[cfg(target_os = "macos")] use network_interface::{NetworkInterface, NetworkInterfaceConfig as _}; @@ -97,7 +96,7 @@ impl ListenerBindScope { }) } - pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool { + pub(crate) fn probe_mixed_port(&self, port: u16) -> ListenerProbeOutcome { let claims = self .addresses .iter() @@ -108,7 +107,13 @@ impl ListenerBindScope { .map(move |transport| BindClaim::new("mixed", address, port, transport)) }) .collect::>(); - matches!(probe_claims(&claims), ListenerProbeOutcome::Available) + probe_claims(&claims) + } + + /// Whether `port` is provably free — for picking a replacement, where anything short of + /// proof should skip the candidate. + pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool { + matches!(self.probe_mixed_port(port), ListenerProbeOutcome::Available) } } @@ -335,12 +340,58 @@ fn claims_overlap(left: &BindClaim, right: &BindClaim) -> bool { && (left.address == right.address || left.address.is_unspecified() || right.address.is_unspecified()) } +/// Whether a refused bind can be dismissed because nothing is actually serving the claim. +/// +/// A refused bind is not proof a port is in use: `SO_REUSEADDR` cannot reuse a `TIME_WAIT` socket +/// owned by a *different* user, and a service-managed Core runs as root while this process does +/// not. So every port the Core used is briefly unbindable here even though the Core itself could +/// rebind it at once (measured on macOS 26). "Is anything answering?" survives that asymmetry; +/// "can I bind?" does not. +/// +/// Restrictions, both deliberate: a wildcard claim can be blocked by a listener on any address, so +/// one refused connection cannot clear it — leaving `allow-lan` setups exposed to the same false +/// positive. UDP has no lingering state, so a refused UDP bind is always a live socket. +fn nothing_is_serving(claim: &BindClaim) -> bool { + const ANSWER_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(200); + + if claim.transport != ListenerTransport::Tcp || claim.address.is_unspecified() { + return false; + } + // Only an explicit refusal proves absence; a timeout leaves the question open. + matches!( + std::net::TcpStream::connect_timeout(&claim.socket_addr(), ANSWER_TIMEOUT), + Err(error) if error.kind() == io::ErrorKind::ConnectionRefused + ) +} + fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome { let mut sockets = Vec::with_capacity(claims.len()); for claim in claims { match bind_claim(claim) { Ok(mut bound) => sockets.append(&mut bound), - Err(error) if is_bind_conflict(&error) => return conflict_outcome(claim), + Err(error) if is_bind_conflict(&error) => { + if nothing_is_serving(claim) { + logging!( + info, + LogType::Network, + "{} could not bind {} ({}), but nothing answers there; treating it as free", + claim.name, + claim.socket_addr(), + error + ); + continue; + } + logging!( + warn, + LogType::Network, + "{} cannot claim {} over {}: {}", + claim.name, + claim.socket_addr(), + transport_name(claim.transport), + error + ); + return conflict_outcome(claim); + } Err(error) if matches!( error.kind(), @@ -370,7 +421,8 @@ fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome { } } } - debug_assert!(sockets.len() >= claims.len()); + // No one-socket-per-claim assertion: a dismissed claim is one this process never bound. + drop(sockets); ListenerProbeOutcome::Available } @@ -425,7 +477,19 @@ fn bind_claim(claim: &BindClaim) -> io::Result> { match bind_socket(&BindClaim::new(claim.name, address, claim.port, claim.transport)) { Ok(socket) => sockets.push(socket), // Only a conflict proves the port is taken; a vanished guard address proves nothing. - Err(error) if is_bind_conflict(&error) => return Err(error), + Err(error) if is_bind_conflict(&error) => { + // The caller only sees the claim, so name the address that actually lost. + logging!( + warn, + LogType::Network, + "{} claiming {} could not hold the overlapping address {}: {}", + claim.name, + claim.socket_addr(), + SocketAddr::new(address, claim.port), + error + ); + return Err(error); + } Err(_) => {} } } @@ -629,14 +693,14 @@ const fn transport_name(transport: ListenerTransport) -> &'static str { #[cfg(test)] mod tests { use super::{ - ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, probe_listener, - probe_proxy_port_change, + BindClaim, ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, nothing_is_serving, + probe_listener, probe_proxy_port_change, }; use serde_json::json; use serde_yaml_ng::Mapping; #[cfg(unix)] use socket2::{Domain, Protocol, SockAddr, Socket, Type}; - use std::net::{Ipv4Addr, TcpListener, UdpSocket}; + use std::net::{IpAddr, Ipv4Addr, TcpListener, UdpSocket}; #[cfg(unix)] use std::{ io::Read as _, @@ -718,6 +782,53 @@ mod tests { Ok(()) } + /// Guards the startup false positive: another user's `TIME_WAIT` makes the kernel refuse the + /// bind while nothing is serving. That refusal needs a second uid to stage, so this asserts + /// on the classifier directly. + #[test] + fn a_refused_bind_with_nothing_answering_is_not_a_conflict() -> anyhow::Result<()> { + let vacant = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = vacant.local_addr()?.port(); + drop(vacant); + + assert!( + nothing_is_serving(&BindClaim::new( + "mixed", + IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + ListenerTransport::Tcp + )), + "a port nobody listens on must be dismissable" + ); + + let serving = TcpListener::bind((Ipv4Addr::LOCALHOST, port))?; + assert!( + !nothing_is_serving(&BindClaim::new( + "mixed", + IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + ListenerTransport::Tcp + )), + "a live listener must never be dismissed" + ); + drop(serving); + Ok(()) + } + + /// UDP has no lingering state, so a refused UDP bind is always a live socket. + #[test] + fn a_refused_udp_bind_is_never_dismissed() -> anyhow::Result<()> { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = socket.local_addr()?.port(); + assert!(!nothing_is_serving(&BindClaim::new( + "mixed", + IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + ListenerTransport::Udp + ))); + Ok(()) + } + #[cfg(unix)] #[test] fn probe_reuses_port_held_only_by_a_stale_tcp_connection() -> anyhow::Result<()> { @@ -848,6 +959,19 @@ mod tests { Ok(()) } + /// The startup fallback only moves on a proven `Conflict`, so a taken port must report one. + #[test] + fn startup_scope_reports_a_conflict_rather_than_an_unreachable_answer() -> anyhow::Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let scope = ListenerBindScope::from_mapping(&mapping("ipv6: false\n")?)?; + assert!(matches!( + scope.probe_mixed_port(port), + ListenerProbeOutcome::Conflict { .. } + )); + Ok(()) + } + #[test] fn startup_scope_rejects_udp_only_conflicts() -> anyhow::Result<()> { let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?; diff --git a/src-tauri/src/feat/listener.rs b/src-tauri/src/feat/listener.rs index 65876485a..cfb905501 100644 --- a/src-tauri/src/feat/listener.rs +++ b/src-tauri/src/feat/listener.rs @@ -1,6 +1,6 @@ use crate::{ config::{ - Config, ConfigType, + Config, ConfigType, MixedPort, snapshot::{FileSnapshot, capture_config_files, restore_files}, }, core::{ @@ -19,7 +19,7 @@ use crate::{ use anyhow::{Context as _, Result, anyhow, bail}; use clash_verge_draft::DraftTransaction; use clash_verge_logging::{Type, logging}; -use scopeguard::defer; +use scopeguard::{ScopeGuard, defer, guard}; pub async fn probe_listener(request: ListenerProbe) -> Result { AsyncHandler::spawn_blocking(move || probe_listener_sync(&request)) @@ -55,6 +55,11 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result Result Result Err(persist_error), Err(rollback_error) => Err(rollback_failure(persist_error, rollback_error)), @@ -112,12 +121,22 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result) { + match previous { + Some(port) => MixedPort::set_session_fallback(port), + None => MixedPort::clear_session_fallback(), + } +} + async fn current_runtime_mapping() -> Result { Config::runtime() .await diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 4b23056fd..d37a9c5e2 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "المنفذ {{oldPort}} مستخدم. تم تبديل منفذ الوكيل المختلط إلى {{newPort}} وحفظه.", + "automaticFallback": "المنفذ {{oldPort}} مستخدم، لذا تعمل هذه الجلسة على {{newPort}}. لم يتغيّر إعدادك وسيُعاد تجربة {{oldPort}} عند التشغيل التالي.", "automaticFallbackFailed": "فشل التبديل التلقائي لمنفذ الوكيل المختلط: {{error}}", "portTooLow": "يجب ألا يقل المنفذ عن 1000", "portTooHigh": "يجب ألا يزيد المنفذ عن 65535" diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 4c78bd4de..c55b5af80 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "Port {{oldPort}} ist belegt. Der gemischte Proxy-Port wurde auf {{newPort}} geändert und gespeichert.", + "automaticFallback": "Port {{oldPort}} ist belegt, daher läuft diese Sitzung auf {{newPort}}. Die Einstellung bleibt unverändert und {{oldPort}} wird beim nächsten Start erneut versucht.", "automaticFallbackFailed": "Der automatische Wechsel des gemischten Proxy-Ports ist fehlgeschlagen: {{error}}", "portTooLow": "Der Port muss mindestens 1000 sein", "portTooHigh": "Der Port darf höchstens 65535 sein" diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 5e16b4fc7..b26f1161e 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "Port {{oldPort}} was in use. The mixed proxy port was switched to {{newPort}} and saved.", + "automaticFallback": "Port {{oldPort}} was in use, so this session runs on {{newPort}}. Your setting is unchanged and {{oldPort}} is retried on the next start.", "automaticFallbackFailed": "Automatic mixed proxy port fallback failed: {{error}}", "portTooLow": "The port must be 1000 or higher", "portTooHigh": "The port must be 65535 or lower" diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 2c34af229..2c4682bc9 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "El puerto {{oldPort}} está en uso. El puerto proxy mixto se cambió a {{newPort}} y se guardó.", + "automaticFallback": "El puerto {{oldPort}} está en uso, por lo que esta sesión usa {{newPort}}. Tu configuración no ha cambiado y se volverá a intentar {{oldPort}} en el próximo inicio.", "automaticFallbackFailed": "Error al cambiar automáticamente el puerto proxy mixto: {{error}}", "portTooLow": "El puerto debe ser 1000 o superior", "portTooHigh": "El puerto debe ser 65535 o inferior" diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 0882892ab..66dd02515 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "پورت {{oldPort}} در حال استفاده بود. پورت پراکسی ترکیبی به {{newPort}} تغییر کرد و ذخیره شد.", + "automaticFallback": "پورت {{oldPort}} در حال استفاده بود، بنابراین این اجرا از {{newPort}} استفاده می‌کند. تنظیم شما تغییر نکرده و در اجرای بعدی {{oldPort}} دوباره امتحان می‌شود.", "automaticFallbackFailed": "تغییر خودکار پورت پراکسی ترکیبی ناموفق بود: {{error}}", "portTooLow": "پورت باید ۱۰۰۰ یا بیشتر باشد", "portTooHigh": "پورت باید ۶۵۵۳۵ یا کمتر باشد" diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 79d5bafaa..52e451b38 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "Port {{oldPort}} sedang digunakan. Port proxy campuran dialihkan ke {{newPort}} dan disimpan.", + "automaticFallback": "Port {{oldPort}} sedang digunakan, jadi sesi ini menggunakan {{newPort}}. Pengaturan Anda tidak berubah dan {{oldPort}} akan dicoba lagi saat mulai berikutnya.", "automaticFallbackFailed": "Pengalihan otomatis port proxy campuran gagal: {{error}}", "portTooLow": "Port harus 1000 atau lebih tinggi", "portTooHigh": "Port harus 65535 atau lebih rendah" diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 9afda431c..bfdb7fbda 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "ポート {{oldPort}} は使用中です。混合プロキシポートを {{newPort}} に切り替えて保存しました。", + "automaticFallback": "ポート {{oldPort}} が使用中のため、今回は {{newPort}} で動作します。設定は変更されておらず、次回起動時に {{oldPort}} を再試行します。", "automaticFallbackFailed": "混合プロキシポートの自動切り替えに失敗しました: {{error}}", "portTooLow": "ポート番号は 1000 以上である必要があります", "portTooHigh": "ポート番号は 65535 以下である必要があります" diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index d82e1dec6..554334d3f 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "포트 설정이 저장되었습니다", "saveFailed": "포트 설정 저장에 실패했습니다", - "automaticFallback": "포트 {{oldPort}}이(가) 사용 중입니다. 혼합 프록시 포트를 {{newPort}}(으)로 전환하고 저장했습니다.", + "automaticFallback": "포트 {{oldPort}}이(가) 사용 중이어서 이번 실행은 {{newPort}}을(를) 사용합니다. 설정은 변경되지 않았으며 다음 시작 시 {{oldPort}}을(를) 다시 시도합니다.", "automaticFallbackFailed": "혼합 프록시 포트 자동 전환에 실패했습니다: {{error}}", "portTooLow": "포트는 1000 이상이어야 합니다", "portTooHigh": "포트는 65535 이하여야 합니다" diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index be258a365..f2226a3e5 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -292,7 +292,7 @@ "portInUse": "Порт {{port}} уже используется", "saved": "Настройки портов сохранены", "saveFailed": "Не удалось сохранить настройки портов", - "automaticFallback": "Порт {{oldPort}} занят. Смешанный прокси-порт переключён на {{newPort}} и сохранён.", + "automaticFallback": "Порт {{oldPort}} занят, поэтому этот запуск использует {{newPort}}. Настройка не изменена, при следующем запуске {{oldPort}} будет опробован снова.", "automaticFallbackFailed": "Не удалось автоматически переключить смешанный прокси-порт: {{error}}", "portTooLow": "Порт должен быть не меньше 1000", "portTooHigh": "Порт должен быть не больше 65535" diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index bcb91920b..d5babd1e1 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "{{oldPort}} portu kullanımda. Karma proxy portu {{newPort}} olarak değiştirildi ve kaydedildi.", + "automaticFallback": "{{oldPort}} portu kullanımda olduğu için bu oturum {{newPort}} portunu kullanıyor. Ayarınız değişmedi ve sonraki başlatmada {{oldPort}} yeniden denenecek.", "automaticFallbackFailed": "Karma proxy portu otomatik olarak değiştirilemedi: {{error}}", "portTooLow": "Bağlantı noktası 1000 veya daha yüksek olmalıdır", "portTooHigh": "Bağlantı noktası 65535 veya daha düşük olmalıdır" diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 9d18cc19f..dc2a3a1fc 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", - "automaticFallback": "Port {{oldPort}} was in use. The mixed proxy port was switched to {{newPort}} and saved.", + "automaticFallback": "Port {{oldPort}} was in use, so this session runs on {{newPort}}. Your setting is unchanged and {{oldPort}} is retried on the next start.", "automaticFallbackFailed": "Automatic mixed proxy port fallback failed: {{error}}", "portTooLow": "Порт 1000 дән ким булмаска тиеш", "portTooHigh": "Порт 65535 тән артмаска тиеш" diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 30fa4d24a..ae72e5fdf 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -292,7 +292,7 @@ "portInUse": "端口 {{port}} 已被占用", "saved": "端口设置已保存", "saveFailed": "端口设置保存失败", - "automaticFallback": "端口 {{oldPort}} 已被占用,混合代理端口已自动切换并保存为 {{newPort}}。", + "automaticFallback": "端口 {{oldPort}} 已被占用,本次运行改用 {{newPort}}。设置未更改,下次启动会重新尝试 {{oldPort}}。", "automaticFallbackFailed": "混合代理端口自动切换失败:{{error}}", "portTooLow": "端口号不能小于 1000", "portTooHigh": "端口号不能大于 65535" diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index dc3f8d758..7edde889d 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -292,7 +292,7 @@ "portInUse": "Port {{port}} is already in use", "saved": "連結埠設定已儲存", "saveFailed": "連結埠設定儲存失敗", - "automaticFallback": "連結埠 {{oldPort}} 已被佔用,混合代理連結埠已自動切換並儲存為 {{newPort}}。", + "automaticFallback": "連結埠 {{oldPort}} 已被佔用,本次執行改用 {{newPort}}。設定未變更,下次啟動會重新嘗試 {{oldPort}}。", "automaticFallbackFailed": "混合代理連結埠自動切換失敗:{{error}}", "portTooLow": "連結埠不能小於 1000", "portTooHigh": "連結埠不能大於 65535"