From 7796259b0394df73bf35a6f1984a7f8d96603690 Mon Sep 17 00:00:00 2001
From: wonfen <96291150+wonfen@users.noreply.github.com>
Date: Sat, 25 Jul 2026 05:47:28 +0800
Subject: [PATCH] feat: automatic mixed port fallback (#7579)
* feat: automatically select an available mixed proxy port
* fix: coordinate listener port changes
* fix: detect Windows listener port conflicts
---
Cargo.lock | 1 +
Cargo.toml | 2 +
Changelog.md | 3 +
Makefile.toml | 18 +-
src-tauri/Cargo.toml | 1 +
src-tauri/src/cmd/listener.rs | 15 +
src-tauri/src/cmd/mod.rs | 2 +
src-tauri/src/cmd/network.rs | 6 -
src-tauri/src/config/config.rs | 17 +-
src-tauri/src/config/mod.rs | 2 +
src-tauri/src/config/port.rs | 327 ++++++++++
src-tauri/src/config/snapshot.rs | 91 +++
src-tauri/src/core/listener.rs | 589 ++++++++++++++++++
src-tauri/src/core/manager/mod.rs | 55 +-
src-tauri/src/core/mod.rs | 1 +
src-tauri/src/feat/listener.rs | 254 ++++++++
src-tauri/src/feat/mod.rs | 2 +
src-tauri/src/lib.rs | 3 +-
src-tauri/src/utils/mod.rs | 1 +
src-tauri/src/utils/port.rs | 53 ++
src/components/home/clash-info-card.tsx | 5 +-
.../setting/mods/clash-port-viewer.tsx | 156 ++---
.../setting/mods/sysproxy-viewer.tsx | 17 +-
.../setting/mods/tunnels-viewer.tsx | 43 +-
src/components/setting/setting-clash.tsx | 6 +-
src/hooks/use-displayed-mixed-port.ts | 18 +
src/hooks/use-system-proxy-state.ts | 15 +-
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 +
src/pages/_layout/hooks/use-layout-events.ts | 1 +
.../_layout/utils/notification-handlers.ts | 12 +
src/providers/app-data-provider.tsx | 23 +-
src/services/cmds.ts | 47 +-
src/types/generated/i18n-keys.ts | 2 +
src/types/generated/i18n-resources.ts | 2 +
src/utils/mixed-port.ts | 20 +
47 files changed, 1644 insertions(+), 192 deletions(-)
create mode 100644 src-tauri/src/cmd/listener.rs
create mode 100644 src-tauri/src/config/port.rs
create mode 100644 src-tauri/src/config/snapshot.rs
create mode 100644 src-tauri/src/core/listener.rs
create mode 100644 src-tauri/src/feat/listener.rs
create mode 100644 src-tauri/src/utils/port.rs
create mode 100644 src/hooks/use-displayed-mixed-port.ts
create mode 100644 src/utils/mixed-port.ts
diff --git a/Cargo.lock b/Cargo.lock
index 124dbef6e..174a212e3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1124,6 +1124,7 @@ dependencies = [
"serde_json",
"serde_yaml_ng",
"smartstring",
+ "socket2 0.6.4",
"sysproxy",
"tauri",
"tauri-build",
diff --git a/Cargo.toml b/Cargo.toml
index ee7bf1805..a05a3af8e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -74,11 +74,13 @@ serde = { version = "1.0.228" }
serde_json = { version = "1.0.150" }
serde_yaml_ng = { version = "0.10.0" }
bitflags = { version = "2.13.0" }
+socket2 = "0.6.4"
# *** For Windows platform only ***
deelevate = "0.2.0"
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
+ "Win32_Networking_WinSock",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Storage_FileSystem",
diff --git a/Changelog.md b/Changelog.md
index 57e02ccc2..938487d41 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -5,12 +5,15 @@
✨ 新增功能
+- 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示
+
🚀 优化改进
- TUN 模式设置也遵循 原始配置 < Merge 覆写 < Script 脚本 < GUI
+- 优化监听端口冲突检测:代理端口按最终 Runtime 监听范围校验并协调三层持久化,隧道按实际地址与 TCP/UDP 协议检测且保留热重载
- 优化代理数据处理:统一通过单一 IPC 获取有序代理视图,并增强刷新、链路选择、重复判断及异常状态下的稳定性
- 新增 macOS 启动门禁,避免多用户环境下的实例冲突
- 加强 Service 多用户隔离与身份认证,支持安全的 Owner 接管
diff --git a/Makefile.toml b/Makefile.toml
index 2365028e9..5aab6b7ad 100644
--- a/Makefile.toml
+++ b/Makefile.toml
@@ -25,7 +25,8 @@ command = "pnpm"
args = ["typecheck"]
[tasks.typecheck.windows]
-command = "pnpm.cmd"
+command = "cmd.exe"
+args = ["/d", "/c", "pnpm", "typecheck"]
[tasks.knip-report]
description = "Report unused frontend code and dependencies"
@@ -33,7 +34,8 @@ command = "pnpm"
args = ["knip:report"]
[tasks.knip-report.windows]
-command = "pnpm.cmd"
+command = "cmd.exe"
+args = ["/d", "/c", "pnpm", "knip:report"]
[tasks.knip-check]
description = "Fail on unused frontend code and dependencies"
@@ -41,7 +43,8 @@ command = "pnpm"
args = ["knip:check"]
[tasks.knip-check.windows]
-command = "pnpm.cmd"
+command = "cmd.exe"
+args = ["/d", "/c", "pnpm", "knip:check"]
[tasks.lint-staged]
description = "Run lint-staged for staged files"
@@ -49,7 +52,8 @@ command = "pnpm"
args = ["exec", "lint-staged"]
[tasks.lint-staged.windows]
-command = "pnpm.cmd"
+command = "cmd.exe"
+args = ["/d", "/c", "pnpm", "exec", "lint-staged"]
[tasks.i18n-format]
description = "Format i18n keys"
@@ -57,7 +61,8 @@ command = "pnpm"
args = ["i18n:format"]
[tasks.i18n-format.windows]
-command = "pnpm.cmd"
+command = "cmd.exe"
+args = ["/d", "/c", "pnpm", "i18n:format"]
[tasks.i18n-types]
description = "Generate i18n key types"
@@ -65,7 +70,8 @@ command = "pnpm"
args = ["i18n:types"]
[tasks.i18n-types.windows]
-command = "pnpm.cmd"
+command = "cmd.exe"
+args = ["/d", "/c", "pnpm", "i18n:types"]
[tasks.git-add]
description = "Add generated files back to git index"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index b12238aa7..74f6cc972 100755
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -59,6 +59,7 @@ serde_json = { workspace = true }
serde_yaml_ng = { workspace = true }
smartstring = { workspace = true, features = ["serde"] }
bitflags = { workspace = true }
+socket2 = { workspace = true }
warp = { version = "0.4.3", features = ["server"] }
open = "5.3.6"
dunce = "1.0.5"
diff --git a/src-tauri/src/cmd/listener.rs b/src-tauri/src/cmd/listener.rs
new file mode 100644
index 000000000..b96c26bec
--- /dev/null
+++ b/src-tauri/src/cmd/listener.rs
@@ -0,0 +1,15 @@
+use super::{CmdResult, StringifyErr as _};
+use crate::{
+ core::listener::{ListenerProbe, ListenerProbeOutcome, ProxyPortSettings, SaveProxyPortsOutcome},
+ feat,
+};
+
+#[tauri::command]
+pub async fn probe_listener(request: ListenerProbe) -> CmdResult {
+ feat::probe_listener(request).await.stringify_err()
+}
+
+#[tauri::command]
+pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult {
+ feat::save_proxy_ports(settings).await.stringify_err()
+}
diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs
index 606c47f69..4a588ba0f 100644
--- a/src-tauri/src/cmd/mod.rs
+++ b/src-tauri/src/cmd/mod.rs
@@ -14,6 +14,7 @@ pub mod app;
pub mod backup;
pub mod clash;
pub mod lightweight;
+pub mod listener;
pub mod media_unlock_checker;
pub mod network;
pub mod profile;
@@ -32,6 +33,7 @@ pub use app::*;
pub use backup::*;
pub use clash::*;
pub use lightweight::*;
+pub use listener::*;
pub use media_unlock_checker::*;
pub use network::*;
pub use profile::*;
diff --git a/src-tauri/src/cmd/network.rs b/src-tauri/src/cmd/network.rs
index c8e932327..0892a7281 100644
--- a/src-tauri/src/cmd/network.rs
+++ b/src-tauri/src/cmd/network.rs
@@ -5,7 +5,6 @@ use clash_verge_logging::{Type, logging};
use gethostname::gethostname;
use network_interface::NetworkInterface;
use serde_yaml_ng::Mapping;
-use std::net::TcpListener;
use sysproxy::{Autoproxy, Sysproxy};
use tauri_plugin_clash_verge_sysinfo;
@@ -104,8 +103,3 @@ pub fn get_network_interfaces_info() -> CmdResult> {
Ok(result)
}
-
-#[tauri::command]
-pub fn is_port_in_use(port: u16) -> bool {
- TcpListener::bind(("127.0.0.1", port)).is_err()
-}
diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs
index c2c3d713f..460869d07 100644
--- a/src-tauri/src/config/config.rs
+++ b/src-tauri/src/config/config.rs
@@ -111,7 +111,18 @@ impl Config {
}
pub async fn init_runtime_config() -> Result<()> {
- let validation_result = Self::generate_and_validate().await?;
+ let fallback_applied = match Self::resolve_startup_mixed_port().await {
+ Ok(applied) => applied,
+ Err(error) => {
+ Self::block_startup_core(&error);
+ return Err(error);
+ }
+ };
+ let validation_result = if fallback_applied {
+ None
+ } else {
+ Self::generate_and_validate().await?
+ };
if let Some((msg_type, msg_content)) = validation_result {
sleep(timing::STARTUP_ERROR_DELAY).await;
@@ -236,6 +247,10 @@ impl Config {
}
pub async fn verify_config_initialization() {
+ if Self::startup_core_block_reason().is_some() {
+ return;
+ }
+
let backoff = ExponentialBuilder::default()
.with_min_delay(std::time::Duration::from_millis(100))
.with_max_delay(std::time::Duration::from_secs(2))
diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs
index c41b28ba1..249db08dd 100644
--- a/src-tauri/src/config/mod.rs
+++ b/src-tauri/src/config/mod.rs
@@ -2,9 +2,11 @@ mod clash;
#[allow(clippy::module_inception)]
mod config;
mod encrypt;
+mod port;
mod prfitem;
pub mod profiles;
pub mod runtime;
+pub(crate) mod snapshot;
mod verge;
pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*};
diff --git a/src-tauri/src/config/port.rs b/src-tauri/src/config/port.rs
new file mode 100644
index 000000000..b1493f5c6
--- /dev/null
+++ b/src-tauri/src/config/port.rs
@@ -0,0 +1,327 @@
+use super::{
+ Config, ConfigType, IClashTemp, IVerge,
+ snapshot::{capture_config_files, restore_files},
+};
+use crate::{
+ constants::timing,
+ core::{
+ handle::Handle,
+ listener::ListenerBindScope,
+ owner_identity::current_owner_credentials,
+ service::{SERVICE_MANAGER, ServiceStatus},
+ validate::CoreConfigValidator,
+ },
+ process::AsyncHandler,
+ utils::port::find_next_available_port,
+};
+use anyhow::{Context as _, Result, anyhow, bail};
+use clash_verge_logging::{Type, logging};
+use once_cell::sync::Lazy;
+use parking_lot::Mutex;
+use serde_yaml_ng::Value;
+use std::{
+ collections::HashSet,
+ net::SocketAddr,
+ str::FromStr as _,
+ sync::atomic::{AtomicBool, Ordering},
+};
+
+#[derive(Clone, Copy)]
+struct MixedPortFallback {
+ original: u16,
+ current: u16,
+}
+
+static PENDING_FALLBACK_NOTICE: Lazy>> = Lazy::new(|| Mutex::new(None));
+static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
+static STARTUP_CORE_BLOCK_REASON: Lazy>> = Lazy::new(|| Mutex::new(None));
+
+impl Config {
+ pub(crate) async fn resolve_startup_mixed_port() -> Result {
+ Self::resolve_startup_mixed_port_inner().await
+ }
+
+ pub(crate) async fn retry_startup_mixed_port_fallback() -> Result {
+ Self::resolve_startup_mixed_port_inner().await
+ }
+
+ async fn resolve_startup_mixed_port_inner() -> Result {
+ let clash = Self::clash().await.latest_arc();
+ let verge = Self::verge().await.latest_arc();
+ let selected_port = clash.get_mixed_port();
+ let bind_scope =
+ ListenerBindScope::from_mapping(&clash.0).context("failed to derive mixed proxy listener scope")?;
+
+ if owned_service_core_uses_port(selected_port).await {
+ logging!(
+ info,
+ Type::Setup,
+ "Mixed proxy port {} belongs to the current user's managed core; skipping fallback",
+ selected_port
+ );
+ return Ok(false);
+ }
+
+ let selected_scope = bind_scope.clone();
+ let port_in_use = AsyncHandler::spawn_blocking(move || !selected_scope.mixed_port_is_available(selected_port))
+ .await
+ .context("mixed proxy port probe task failed")?;
+ if !port_in_use {
+ return Ok(false);
+ }
+
+ let reserved = configured_listener_ports(&clash, &verge);
+ let candidate = AsyncHandler::spawn_blocking(move || {
+ find_next_available_port(selected_port, &reserved, |port| {
+ bind_scope.mixed_port_is_available(port)
+ })
+ })
+ .await
+ .context("mixed proxy fallback scan task failed")?
+ .ok_or_else(|| anyhow!("no eligible mixed proxy port is available"))?;
+
+ Self::apply_startup_mixed_port_fallback(selected_port, candidate).await?;
+ Ok(true)
+ }
+
+ 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;
+
+ clash.edit_draft(|draft| {
+ draft.0.insert("mixed-port".into(), new_port.into());
+ });
+ verge.edit_draft(|draft| {
+ draft.verge_mixed_port = Some(new_port);
+ });
+
+ if let Err(error) = Self::generate().await {
+ clash.discard();
+ verge.discard();
+ runtime.discard();
+ return Err(error).context("failed to materialize runtime configuration with fallback port");
+ }
+
+ let validation = match CoreConfigValidator::global().validate_config_outcome().await {
+ Ok(outcome) => outcome,
+ Err(error) => {
+ clash.discard();
+ verge.discard();
+ runtime.discard();
+ return Err(error).context("failed to validate runtime configuration with fallback port");
+ }
+ };
+ if !validation.is_valid() {
+ clash.discard();
+ verge.discard();
+ runtime.discard();
+ bail!("runtime configuration with fallback port is invalid: {validation}");
+ }
+
+ let snapshots = match capture_config_files().await {
+ Ok(snapshots) => snapshots,
+ Err(error) => {
+ clash.discard();
+ verge.discard();
+ runtime.discard();
+ return Err(error);
+ }
+ };
+ 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;
+
+ if let Err(error) = persist_result {
+ clash.discard();
+ verge.discard();
+ runtime.discard();
+ return match restore_files(&snapshots).await {
+ Ok(()) => Err(error),
+ Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")),
+ };
+ }
+
+ clash.apply();
+ verge.apply();
+ runtime.apply();
+ 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
+ );
+ Ok(())
+ }
+
+ pub(crate) fn block_startup_core(error: &anyhow::Error) {
+ STARTUP_CORE_BLOCKED.store(true, Ordering::Release);
+ *STARTUP_CORE_BLOCK_REASON.lock() = Some(error.to_string());
+ report_fallback_error(error.to_string());
+ }
+
+ pub(crate) fn startup_core_block_reason() -> Option {
+ if STARTUP_CORE_BLOCKED.load(Ordering::Acquire) {
+ STARTUP_CORE_BLOCK_REASON.lock().clone()
+ } else {
+ None
+ }
+ }
+
+ pub(crate) fn notify_startup_mixed_port_fallback() {
+ let Some(change) = PENDING_FALLBACK_NOTICE.lock().take() else {
+ return;
+ };
+ AsyncHandler::spawn(move || async move {
+ tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
+ Handle::notice_message(
+ "mixed_port::fallback",
+ format!("{},{}", change.original, change.current),
+ );
+ });
+ }
+}
+
+fn record_fallback(old_port: u16, new_port: u16) {
+ let mut pending = PENDING_FALLBACK_NOTICE.lock();
+ match pending.as_mut() {
+ Some(change) => change.current = new_port,
+ None => {
+ *pending = Some(MixedPortFallback {
+ original: old_port,
+ current: new_port,
+ });
+ }
+ }
+}
+
+fn report_fallback_error(message: String) {
+ logging!(
+ error,
+ Type::Config,
+ "Automatic mixed proxy port fallback failed: {}",
+ message
+ );
+ AsyncHandler::spawn(move || async move {
+ tokio::time::sleep(timing::STARTUP_ERROR_DELAY).await;
+ Handle::notice_message("mixed_port::fallback_error", message);
+ });
+}
+
+// Only service-managed cores can survive into this startup phase; this app has not spawned its sidecar yet.
+async fn owned_service_core_uses_port(port: u16) -> bool {
+ if !matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready) {
+ return false;
+ }
+
+ let credentials = match current_owner_credentials() {
+ Ok(credentials) => credentials,
+ Err(error) => {
+ logging!(
+ warn,
+ Type::Service,
+ "Unable to identify current service owner while checking mixed proxy port: {error:#}"
+ );
+ return false;
+ }
+ };
+ let response = match clash_verge_service_ipc::get_status(&credentials).await {
+ Ok(response) => response,
+ Err(error) => {
+ logging!(
+ warn,
+ Type::Service,
+ "Unable to query current service owner while checking mixed proxy port: {error:#}"
+ );
+ return false;
+ }
+ };
+ let Some(status) = response.data else {
+ return false;
+ };
+ if response.code > 0 || !status.is_active || status.core_pid.is_none() {
+ return false;
+ }
+
+ match Handle::mihomo().await.get_base_config().await {
+ Ok(config) => config.mixed_port == port,
+ Err(error) => {
+ logging!(
+ warn,
+ Type::Service,
+ "Current user's service core is active but its mixed proxy port is unavailable: {error}; \
+ preserving the selected port until core replacement resolves ownership"
+ );
+ true
+ }
+ }
+}
+
+fn configured_listener_ports(clash: &IClashTemp, verge: &IVerge) -> HashSet {
+ let mut ports = HashSet::new();
+ for key in ["socks-port", "port", "redir-port", "tproxy-port"] {
+ if let Some(port) = mapping_port(&clash.0, key) {
+ ports.insert(port);
+ }
+ }
+
+ if let Ok(controller) = SocketAddr::from_str(IClashTemp::guard_external_controller(&clash.0).as_str()) {
+ ports.insert(controller.port());
+ }
+
+ ports.extend([verge.verge_socks_port, verge.verge_port].into_iter().flatten());
+ #[cfg(not(target_os = "windows"))]
+ if let Some(port) = verge.verge_redir_port {
+ ports.insert(port);
+ }
+ #[cfg(target_os = "linux")]
+ if let Some(port) = verge.verge_tproxy_port {
+ ports.insert(port);
+ }
+ ports
+}
+
+fn mapping_port(mapping: &serde_yaml_ng::Mapping, key: &str) -> Option {
+ mapping.get(key).and_then(|value| match value {
+ Value::String(port) => port.parse().ok(),
+ Value::Number(port) => port.as_u64().and_then(|port| u16::try_from(port).ok()),
+ _ => None,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::configured_listener_ports;
+ use crate::config::{IClashTemp, IVerge};
+
+ #[test]
+ fn configured_ports_include_disabled_listener_assignments() {
+ let ports = configured_listener_ports(&IClashTemp::template(), &IVerge::template());
+ assert!(ports.contains(&7898));
+ assert!(ports.contains(&7899));
+ assert!(ports.contains(&9097));
+ #[cfg(not(target_os = "windows"))]
+ assert!(ports.contains(&7895));
+ #[cfg(target_os = "linux")]
+ assert!(ports.contains(&7896));
+ }
+}
diff --git a/src-tauri/src/config/snapshot.rs b/src-tauri/src/config/snapshot.rs
new file mode 100644
index 000000000..4b1d8cc9f
--- /dev/null
+++ b/src-tauri/src/config/snapshot.rs
@@ -0,0 +1,91 @@
+use crate::utils::dirs;
+use anyhow::{Context as _, Result, bail};
+use std::{io::ErrorKind, path::PathBuf};
+
+pub(crate) struct FileSnapshot {
+ path: PathBuf,
+ content: Option>,
+}
+
+async fn capture_files(paths: &[PathBuf]) -> Result> {
+ let mut snapshots = Vec::with_capacity(paths.len());
+ for path in paths {
+ let content = match tokio::fs::read(path).await {
+ Ok(content) => Some(content),
+ Err(error) if error.kind() == ErrorKind::NotFound => None,
+ Err(error) => {
+ return Err(error).with_context(|| format!("failed to snapshot {}", path.display()));
+ }
+ };
+ snapshots.push(FileSnapshot {
+ path: path.clone(),
+ content,
+ });
+ }
+ Ok(snapshots)
+}
+
+pub(crate) async fn capture_config_files() -> Result> {
+ let runtime_path = dirs::app_home_dir()?.join(crate::constants::files::RUNTIME_CONFIG);
+ capture_files(&[dirs::clash_path()?, dirs::verge_path()?, runtime_path]).await
+}
+
+pub(crate) async fn restore_files(snapshots: &[FileSnapshot]) -> Result<()> {
+ let mut errors = Vec::new();
+ for snapshot in snapshots {
+ let result = match &snapshot.content {
+ Some(content) => tokio::fs::write(&snapshot.path, content).await,
+ None => match tokio::fs::remove_file(&snapshot.path).await {
+ Ok(()) => Ok(()),
+ Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
+ Err(error) => Err(error),
+ },
+ };
+ if let Err(error) = result {
+ errors.push(format!("{}: {}", snapshot.path.display(), error));
+ }
+ }
+
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ bail!("{}", errors.join("; "))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{capture_files, restore_files};
+
+ #[tokio::test]
+ async fn restores_every_configuration_layer_after_partial_write() -> anyhow::Result<()> {
+ let root = std::env::temp_dir().join(format!(
+ "config-rollback-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)?
+ .as_nanos()
+ ));
+ tokio::fs::create_dir_all(&root).await?;
+ let paths = [
+ root.join("config.yaml"),
+ root.join("verge.yaml"),
+ root.join("clash-verge.yaml"),
+ ];
+ for (index, path) in paths.iter().enumerate() {
+ tokio::fs::write(path, format!("original-{index}")).await?;
+ }
+
+ let snapshots = capture_files(&paths).await?;
+ tokio::fs::write(&paths[0], "changed").await?;
+ tokio::fs::write(&paths[1], "changed").await?;
+ tokio::fs::remove_file(&paths[2]).await?;
+ restore_files(&snapshots).await?;
+
+ for (index, path) in paths.iter().enumerate() {
+ assert_eq!(tokio::fs::read_to_string(path).await?, format!("original-{index}"));
+ }
+ tokio::fs::remove_dir_all(root).await?;
+ Ok(())
+ }
+}
diff --git a/src-tauri/src/core/listener.rs b/src-tauri/src/core/listener.rs
new file mode 100644
index 000000000..82ba97fe8
--- /dev/null
+++ b/src-tauri/src/core/listener.rs
@@ -0,0 +1,589 @@
+use anyhow::{Context as _, Result, anyhow, bail};
+use serde::{Deserialize, Serialize};
+use serde_yaml_ng::{Mapping, Value};
+use socket2::{Domain, Protocol, SockAddr, Socket, Type};
+use std::{
+ io,
+ net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
+ str::FromStr as _,
+};
+
+const PROXY_LISTENERS: [(&str, &str, &[ListenerTransport]); 5] = [
+ ("mixed-port", "mixed", &[ListenerTransport::Tcp, ListenerTransport::Udp]),
+ ("socks-port", "socks", &[ListenerTransport::Tcp, ListenerTransport::Udp]),
+ ("port", "http", &[ListenerTransport::Tcp]),
+ ("redir-port", "redir", &[ListenerTransport::Tcp]),
+ (
+ "tproxy-port",
+ "tproxy",
+ &[ListenerTransport::Tcp, ListenerTransport::Udp],
+ ),
+];
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProxyPortSettings {
+ pub mixed_port: u16,
+ pub socks: ToggleableProxyPort,
+ pub http: ToggleableProxyPort,
+ pub redir: ToggleableProxyPort,
+ pub tproxy: ToggleableProxyPort,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize)]
+pub struct ToggleableProxyPort {
+ pub enabled: bool,
+ pub port: u16,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListenerProbe {
+ pub address: String,
+ pub transports: Vec,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ListenerTransport {
+ Tcp,
+ Udp,
+}
+
+#[derive(Debug, PartialEq, Eq, Serialize)]
+#[serde(tag = "status", rename_all = "camelCase")]
+pub enum ListenerProbeOutcome {
+ Available,
+ Conflict { port: u16, transport: ListenerTransport },
+ Invalid { message: String },
+ Indeterminate { message: String },
+}
+
+#[derive(Debug, PartialEq, Eq, Serialize)]
+#[serde(tag = "status", rename_all = "camelCase")]
+pub enum SaveProxyPortsOutcome {
+ Saved,
+ Conflict { port: u16, transport: ListenerTransport },
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct ListenerBindScope {
+ addresses: Vec,
+}
+
+impl ListenerBindScope {
+ pub(crate) fn from_mapping(config: &Mapping) -> Result {
+ Ok(Self {
+ addresses: proxy_bind_addresses(config)?,
+ })
+ }
+
+ pub(crate) fn mixed_port_is_available(&self, port: u16) -> bool {
+ let claims = self
+ .addresses
+ .iter()
+ .copied()
+ .flat_map(|address| {
+ [ListenerTransport::Tcp, ListenerTransport::Udp]
+ .into_iter()
+ .map(move |transport| BindClaim::new("mixed", address, port, transport))
+ })
+ .collect::>();
+ matches!(probe_claims(&claims), ListenerProbeOutcome::Available)
+ }
+}
+
+impl ProxyPortSettings {
+ pub(crate) fn validate(&self) -> Result<()> {
+ if self.mixed_port == 0 {
+ bail!("mixed proxy port must be between 1 and 65535");
+ }
+ for (name, listener) in [
+ ("SOCKS", self.socks),
+ ("HTTP", self.http),
+ ("redir", self.redir),
+ ("TProxy", self.tproxy),
+ ] {
+ if listener.port == 0 {
+ bail!("{name} proxy port must be between 1 and 65535");
+ }
+ }
+ Ok(())
+ }
+}
+
+struct BindClaim {
+ name: &'static str,
+ address: IpAddr,
+ port: u16,
+ transport: ListenerTransport,
+}
+
+impl BindClaim {
+ const fn new(name: &'static str, address: IpAddr, port: u16, transport: ListenerTransport) -> Self {
+ Self {
+ name,
+ address,
+ port,
+ transport,
+ }
+ }
+
+ const fn socket_addr(&self) -> SocketAddr {
+ SocketAddr::new(self.address, self.port)
+ }
+}
+
+pub(crate) fn probe_listener(request: &ListenerProbe) -> ListenerProbeOutcome {
+ let (addresses, port) = match parse_listener_address(&request.address) {
+ Ok(address) => address,
+ Err(error) => {
+ return ListenerProbeOutcome::Invalid {
+ message: error.to_string(),
+ };
+ }
+ };
+ let mut transports = Vec::with_capacity(request.transports.len());
+ for transport in request.transports.iter().copied() {
+ if !transports.contains(&transport) {
+ transports.push(transport);
+ }
+ }
+ if transports.is_empty() {
+ return ListenerProbeOutcome::Invalid {
+ message: "listener transport list cannot be empty".into(),
+ };
+ }
+
+ let claims = addresses
+ .into_iter()
+ .flat_map(|address| {
+ transports
+ .iter()
+ .copied()
+ .map(move |transport| BindClaim::new("listener", address, port, transport))
+ })
+ .collect::>();
+ probe_claims(&claims)
+}
+
+pub(crate) fn probe_proxy_port_change(
+ current: &Mapping,
+ candidate: &Mapping,
+ current_core_is_running: bool,
+) -> ListenerProbeOutcome {
+ let current_claims = if current_core_is_running {
+ match proxy_claims(current) {
+ Ok(claims) => claims,
+ Err(error) => {
+ return ListenerProbeOutcome::Invalid {
+ message: format!("current proxy listener configuration is invalid: {error:#}"),
+ };
+ }
+ }
+ } else {
+ Vec::new()
+ };
+ let mut candidate_claims = match proxy_claims(candidate) {
+ Ok(claims) => claims,
+ Err(error) => {
+ return ListenerProbeOutcome::Invalid {
+ message: format!("candidate proxy listener configuration is invalid: {error:#}"),
+ };
+ }
+ };
+
+ if let Some(conflict) = internal_conflict(&candidate_claims) {
+ return conflict;
+ }
+
+ // A running core releases its proxy sockets during the coordinated restart.
+ candidate_claims.retain(|candidate| !current_claims.iter().any(|current| claims_overlap(current, candidate)));
+ probe_claims(&candidate_claims)
+}
+
+fn proxy_claims(config: &Mapping) -> Result> {
+ let addresses = proxy_bind_addresses(config)?;
+ let mut claims = Vec::new();
+ for (key, name, transports) in PROXY_LISTENERS {
+ let Some(port) = mapping_port(config, key)? else {
+ continue;
+ };
+ for address in &addresses {
+ for transport in transports {
+ claims.push(BindClaim::new(name, *address, port, *transport));
+ }
+ }
+ }
+ Ok(claims)
+}
+
+fn proxy_bind_addresses(config: &Mapping) -> Result> {
+ let ipv6 = config.get("ipv6").and_then(Value::as_bool).unwrap_or(true);
+ let allow_lan = config.get("allow-lan").and_then(Value::as_bool).unwrap_or(false);
+
+ if !allow_lan {
+ return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
+ }
+
+ let bind_address = config
+ .get("bind-address")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|address| !address.is_empty())
+ .unwrap_or("*");
+ if bind_address == "*" {
+ let mut addresses = vec![IpAddr::V4(Ipv4Addr::UNSPECIFIED)];
+ if ipv6 {
+ addresses.push(IpAddr::V6(Ipv6Addr::UNSPECIFIED));
+ }
+ return Ok(addresses);
+ }
+ if bind_address.eq_ignore_ascii_case("localhost") {
+ return Ok(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]);
+ }
+
+ let normalized = bind_address
+ .strip_prefix('[')
+ .and_then(|address| address.strip_suffix(']'))
+ .unwrap_or(bind_address);
+ Ok(vec![
+ IpAddr::from_str(normalized).with_context(|| format!("invalid bind-address {bind_address:?}"))?,
+ ])
+}
+
+fn parse_listener_address(address: &str) -> Result<(Vec, u16)> {
+ let address = address.trim();
+ if let Ok(socket) = SocketAddr::from_str(address) {
+ if socket.port() == 0 {
+ bail!("listener port must be between 1 and 65535");
+ }
+ return Ok((vec![socket.ip()], socket.port()));
+ }
+
+ let (host, port) = address
+ .rsplit_once(':')
+ .ok_or_else(|| anyhow!("listener address must include a port"))?;
+ let port = port
+ .parse::()
+ .with_context(|| format!("invalid listener port {port:?}"))?;
+ if port == 0 {
+ bail!("listener port must be between 1 and 65535");
+ }
+ let host = host
+ .trim()
+ .strip_prefix('[')
+ .and_then(|host| host.strip_suffix(']'))
+ .unwrap_or_else(|| host.trim());
+ if host.eq_ignore_ascii_case("localhost") {
+ return Ok((vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], port));
+ }
+ Ok((
+ vec![IpAddr::from_str(host).with_context(|| format!("invalid listener host {host:?}"))?],
+ port,
+ ))
+}
+
+fn mapping_port(config: &Mapping, key: &str) -> Result