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> { + let Some(value) = config.get(key) else { + return Ok(None); + }; + let port = match value { + Value::Number(port) => port.as_u64().and_then(|port| u16::try_from(port).ok()), + Value::String(port) => port.parse().ok(), + _ => None, + } + .ok_or_else(|| anyhow!("{key} must be an integer between 1 and 65535"))?; + if port == 0 { + bail!("{key} must be an integer between 1 and 65535"); + } + Ok(Some(port)) +} + +fn internal_conflict(claims: &[BindClaim]) -> Option { + for (index, claim) in claims.iter().enumerate() { + if claims[..index].iter().any(|existing| claims_overlap(existing, claim)) { + return Some(conflict_outcome(claim)); + } + } + None +} + +fn claims_overlap(left: &BindClaim, right: &BindClaim) -> bool { + left.port == right.port + && left.transport == right.transport + && left.address.is_ipv4() == right.address.is_ipv4() + && (left.address == right.address || left.address.is_unspecified() || right.address.is_unspecified()) +} + +fn probe_claims(claims: &[BindClaim]) -> ListenerProbeOutcome { + let mut sockets = Vec::with_capacity(claims.len()); + for claim in claims { + match bind_claim(claim) { + Ok(socket) => sockets.push(socket), + Err(error) if is_bind_conflict(&error) => return conflict_outcome(claim), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::AddrNotAvailable | io::ErrorKind::InvalidInput | io::ErrorKind::Unsupported + ) => + { + return ListenerProbeOutcome::Invalid { + message: format!( + "{} cannot listen on {} over {}: {}", + claim.name, + claim.socket_addr(), + transport_name(claim.transport), + error + ), + }; + } + Err(error) => { + return ListenerProbeOutcome::Indeterminate { + message: format!( + "unable to verify {} on {} over {}: {}", + claim.name, + claim.socket_addr(), + transport_name(claim.transport), + error + ), + }; + } + } + } + debug_assert_eq!(sockets.len(), claims.len()); + ListenerProbeOutcome::Available +} + +fn bind_claim(claim: &BindClaim) -> io::Result { + let domain = if claim.address.is_ipv4() { + Domain::IPV4 + } else { + Domain::IPV6 + }; + let (socket_type, protocol) = match claim.transport { + ListenerTransport::Tcp => (Type::STREAM, Protocol::TCP), + ListenerTransport::Udp => (Type::DGRAM, Protocol::UDP), + }; + let socket = Socket::new(domain, socket_type, Some(protocol))?; + if claim.address.is_ipv6() { + socket.set_only_v6(true)?; + } + socket.set_reuse_address(false)?; + #[cfg(windows)] + set_exclusive_address_use(&socket)?; + socket.bind(&SockAddr::from(claim.socket_addr()))?; + if claim.transport == ListenerTransport::Tcp { + socket.listen(1)?; + } + Ok(socket) +} + +fn is_bind_conflict(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::AddrInUse { + return true; + } + + #[cfg(windows)] + { + use windows_sys::Win32::Networking::WinSock::WSAEACCES; + + error.raw_os_error() == Some(WSAEACCES) + } + #[cfg(not(windows))] + false +} + +#[cfg(windows)] +fn set_exclusive_address_use(socket: &Socket) -> io::Result<()> { + use std::{mem::size_of, os::windows::io::AsRawSocket as _}; + use windows_sys::Win32::Networking::WinSock::{SO_EXCLUSIVEADDRUSE, SOCKET, SOCKET_ERROR, SOL_SOCKET, setsockopt}; + + let enabled = 1i32; + // Rust exposes `RawSocket` as `u64` for compatibility, while WinSock's + // `SOCKET` is pointer-sized. The value originated from WinSock, so it fits + // in `SOCKET` on every Windows target. + let raw_socket = socket.as_raw_socket() as SOCKET; + // SAFETY: the socket is valid and `enabled` remains alive for the duration of the call. + let result = unsafe { + setsockopt( + raw_socket, + SOL_SOCKET, + SO_EXCLUSIVEADDRUSE, + (&raw const enabled).cast(), + size_of::() as i32, + ) + }; + if result == SOCKET_ERROR { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +const fn conflict_outcome(claim: &BindClaim) -> ListenerProbeOutcome { + ListenerProbeOutcome::Conflict { + port: claim.port, + transport: claim.transport, + } +} + +const fn transport_name(transport: ListenerTransport) -> &'static str { + match transport { + ListenerTransport::Tcp => "TCP", + ListenerTransport::Udp => "UDP", + } +} + +#[cfg(test)] +mod tests { + use super::{ + ListenerBindScope, ListenerProbe, ListenerProbeOutcome, ListenerTransport, probe_listener, + probe_proxy_port_change, + }; + use serde_json::json; + use serde_yaml_ng::Mapping; + use std::net::{Ipv4Addr, TcpListener, UdpSocket}; + + fn mapping(yaml: &str) -> anyhow::Result { + Ok(serde_yaml_ng::from_str(yaml)?) + } + + #[test] + fn probe_reports_tcp_conflicts_on_the_requested_address() -> anyhow::Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let outcome = probe_listener(&ListenerProbe { + address: format!("127.0.0.1:{port}"), + transports: vec![ListenerTransport::Tcp], + }); + assert_eq!( + outcome, + ListenerProbeOutcome::Conflict { + port, + transport: ListenerTransport::Tcp + } + ); + Ok(()) + } + + #[test] + fn probe_reports_udp_conflicts_on_the_requested_address() -> anyhow::Result<()> { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = socket.local_addr()?.port(); + let outcome = probe_listener(&ListenerProbe { + address: format!("127.0.0.1:{port}"), + transports: vec![ListenerTransport::Udp], + }); + assert_eq!( + outcome, + ListenerProbeOutcome::Conflict { + port, + transport: ListenerTransport::Udp + } + ); + Ok(()) + } + + #[test] + fn probe_does_not_treat_invalid_addresses_as_available() { + let outcome = probe_listener(&ListenerProbe { + address: "not-a-listener".into(), + transports: vec![ListenerTransport::Tcp], + }); + assert!(matches!(outcome, ListenerProbeOutcome::Invalid { .. })); + } + + #[test] + fn localhost_matches_mihomo_ipv4_resolution() -> anyhow::Result<()> { + let (addresses, port) = super::parse_listener_address("localhost:7890")?; + assert_eq!(addresses, vec![std::net::IpAddr::V4(Ipv4Addr::LOCALHOST)]); + assert_eq!(port, 7890); + + let proxy_addresses = + super::proxy_bind_addresses(&mapping("allow-lan: true\nbind-address: localhost\nipv6: true\n")?)?; + assert_eq!(proxy_addresses, addresses); + Ok(()) + } + + #[test] + fn proxy_probe_skips_sockets_owned_by_the_running_core() -> anyhow::Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let runtime = mapping(&format!("ipv6: false\nmixed-port: {port}\n"))?; + assert_eq!( + probe_proxy_port_change(&runtime, &runtime, true), + ListenerProbeOutcome::Available + ); + assert!(matches!( + probe_proxy_port_change(&runtime, &runtime, false), + ListenerProbeOutcome::Conflict { .. } + )); + Ok(()) + } + + #[test] + fn proxy_probe_rejects_duplicate_candidate_ports() -> anyhow::Result<()> { + let current = mapping("ipv6: false\nmixed-port: 31000\n")?; + let candidate = mapping("ipv6: false\nmixed-port: 31001\nsocks-port: 31001\n")?; + assert_eq!( + probe_proxy_port_change(¤t, &candidate, true), + ListenerProbeOutcome::Conflict { + port: 31001, + transport: ListenerTransport::Tcp + } + ); + Ok(()) + } + + #[test] + fn proxy_probe_checks_udp_for_mixed_and_socks_ports() -> anyhow::Result<()> { + for key in ["mixed-port", "socks-port"] { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = socket.local_addr()?.port(); + // Exclude the running core's TCP claim so this assertion isolates UDP + // without depending on process-global TCP port availability. + let current = mapping(&format!("ipv6: false\nport: {port}\n"))?; + let candidate = mapping(&format!("ipv6: false\n{key}: {port}\n"))?; + assert_eq!( + probe_proxy_port_change(¤t, &candidate, true), + ListenerProbeOutcome::Conflict { + port, + transport: ListenerTransport::Udp + } + ); + } + Ok(()) + } + + #[test] + fn startup_scope_uses_the_runtime_lan_binding() -> anyhow::Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = listener.local_addr()?.port(); + let scope = + ListenerBindScope::from_mapping(&mapping("allow-lan: true\nbind-address: 0.0.0.0\nipv6: false\n")?)?; + assert!(!scope.mixed_port_is_available(port)); + Ok(()) + } + + #[test] + fn startup_scope_rejects_udp_only_conflicts() -> anyhow::Result<()> { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0))?; + let port = socket.local_addr()?.port(); + let scope = ListenerBindScope::from_mapping(&mapping("ipv6: false\n")?)?; + assert!(!scope.mixed_port_is_available(port)); + Ok(()) + } + + #[test] + fn proxy_settings_keep_the_camel_case_command_contract() -> anyhow::Result<()> { + let settings = serde_json::from_value::(json!({ + "mixedPort": 7897, + "socks": { "enabled": false, "port": 7898 }, + "http": { "enabled": false, "port": 7899 }, + "redir": { "enabled": false, "port": 7895 }, + "tproxy": { "enabled": false, "port": 7896 } + }))?; + assert_eq!(settings.mixed_port, 7897); + Ok(()) + } +} diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index 70a8b5022..eb8dc9806 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -5,6 +5,7 @@ mod state; use anyhow::Result; use arc_swap::{ArcSwap, ArcSwapOption}; use clash_verge_logger::AsyncLogger; +use clash_verge_logging::{Type, logging}; use once_cell::sync::Lazy; use std::{ fmt, @@ -199,17 +200,63 @@ impl CoreManager { self.job_handle.store(handle.map(Arc::new)); } - fn try_start_config_update(&self) -> bool { + pub(crate) fn try_start_config_update(&self) -> bool { !self.config_update_in_progress.swap(true, Ordering::AcqRel) } - fn finish_config_update(&self) { + pub(crate) fn finish_config_update(&self) { self.config_update_in_progress.store(false, Ordering::Release); } pub async fn init(&self) -> Result { - self.start_core().await?; - Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning)) + const MAX_PORT_FALLBACK_RETRIES: usize = 3; + + if let Some(reason) = crate::config::Config::startup_core_block_reason() { + anyhow::bail!("core startup blocked after mixed proxy port fallback failure: {reason}"); + } + + let mut retries = 0; + loop { + match self.start_core().await { + Ok(()) => { + crate::config::Config::notify_startup_mixed_port_fallback(); + return Ok(!matches!(*self.get_running_mode(), RunningMode::NotRunning)); + } + Err(start_error) if retries < MAX_PORT_FALLBACK_RETRIES => { + if !matches!(*self.get_running_mode(), RunningMode::NotRunning) { + crate::config::Config::notify_startup_mixed_port_fallback(); + return Err(start_error); + } + match crate::config::Config::retry_startup_mixed_port_fallback().await { + Ok(true) => { + retries += 1; + logging!( + warn, + Type::Core, + "Retrying core startup after mixed proxy port fallback ({}/{})", + retries, + MAX_PORT_FALLBACK_RETRIES + ); + } + Ok(false) => { + crate::config::Config::notify_startup_mixed_port_fallback(); + return Err(start_error); + } + Err(fallback_error) => { + crate::config::Config::block_startup_core(&fallback_error); + return Err(anyhow::anyhow!( + "core startup failed: {start_error:#}; mixed proxy port fallback failed: \ + {fallback_error:#}" + )); + } + } + } + Err(error) => { + crate::config::Config::notify_startup_mixed_port_fallback(); + return Err(error); + } + } + } } } diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 287ca8f25..465c76593 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -2,6 +2,7 @@ pub mod autostart; pub mod backup; pub mod handle; pub mod hotkey; +pub mod listener; pub mod logger; pub mod manager; mod notification; diff --git a/src-tauri/src/feat/listener.rs b/src-tauri/src/feat/listener.rs new file mode 100644 index 000000000..e63241f90 --- /dev/null +++ b/src-tauri/src/feat/listener.rs @@ -0,0 +1,254 @@ +use crate::{ + config::{ + Config, ConfigType, + snapshot::{FileSnapshot, capture_config_files, restore_files}, + }, + core::{ + CoreManager, + handle::Handle, + listener::{ + ListenerProbe, ListenerProbeOutcome, ProxyPortSettings, SaveProxyPortsOutcome, + probe_listener as probe_listener_sync, probe_proxy_port_change, + }, + manager::RunningMode, + validate::CoreConfigValidator, + }, + process::AsyncHandler, +}; +use anyhow::{Context as _, Result, anyhow, bail}; +use clash_verge_logging::{Type, logging}; +use scopeguard::defer; + +pub async fn probe_listener(request: ListenerProbe) -> Result { + AsyncHandler::spawn_blocking(move || probe_listener_sync(&request)) + .await + .context("listener probe task failed") +} + +#[allow(clippy::cognitive_complexity)] +pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result { + settings.validate()?; + let manager = CoreManager::global(); + if !manager.try_start_config_update() { + bail!("configuration update is already running"); + } + defer! { + manager.finish_config_update(); + } + + let current = current_runtime_mapping().await?; + stage_proxy_ports(&settings).await; + if let Err(error) = Config::generate().await { + discard_proxy_port_drafts().await; + return Err(error).context("failed to generate candidate proxy port configuration"); + } + let candidate = match latest_runtime_mapping().await { + Ok(candidate) => candidate, + Err(error) => { + discard_proxy_port_drafts().await; + return Err(error); + } + }; + let was_running = !matches!(*manager.get_running_mode(), RunningMode::NotRunning); + let assessment = match probe_proxy_ports(current.clone(), candidate.clone(), was_running).await { + Ok(assessment) => assessment, + Err(error) => { + discard_proxy_port_drafts().await; + return Err(error); + } + }; + match rejected_save_outcome(assessment) { + Ok(Some(outcome)) => { + discard_proxy_port_drafts().await; + return Ok(outcome); + } + Ok(None) => {} + Err(error) => { + discard_proxy_port_drafts().await; + return Err(error); + } + } + + let validation = match CoreConfigValidator::global().validate_config_outcome().await { + Ok(validation) => validation, + Err(error) => { + discard_proxy_port_drafts().await; + return Err(error).context("failed to validate candidate proxy port configuration"); + } + }; + if !validation.is_valid() { + discard_proxy_port_drafts().await; + bail!("candidate proxy port configuration is invalid: {validation}"); + } + + let snapshots = match capture_config_files().await { + Ok(snapshots) => snapshots, + Err(error) => { + discard_proxy_port_drafts().await; + return Err(error); + } + }; + if let Err(error) = Config::generate_file(ConfigType::Run).await { + discard_proxy_port_drafts().await; + return match restore_files(&snapshots).await { + Ok(()) => Err(error).context("failed to persist candidate Runtime Configuration"), + Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")), + }; + } + + if was_running && let Err(activation_error) = manager.restart_core().await { + if let Err(rollback_error) = rollback_proxy_ports(&snapshots, was_running).await { + return Err(anyhow!( + "failed to activate proxy port configuration: {activation_error:#}; \ + configuration rollback failed: {rollback_error:#}" + )); + } + + let post_failure_assessment = probe_proxy_ports(current, candidate, was_running).await?; + if let Some(outcome) = rejected_save_outcome(post_failure_assessment)? { + logging!( + warn, + Type::Config, + "Proxy port became unavailable while Mihomo was restarting: {activation_error:#}" + ); + return Ok(outcome); + } + return Err(activation_error).context("Mihomo rejected the proxy port configuration"); + } + + if let Err(persist_error) = persist_proxy_port_sources().await { + return match rollback_proxy_ports(&snapshots, was_running).await { + Ok(()) => Err(persist_error), + Err(rollback_error) => Err(anyhow!( + "{persist_error:#}; configuration rollback failed: {rollback_error:#}" + )), + }; + } + + Config::clash().await.apply(); + Config::verge().await.apply(); + Config::runtime().await.apply(); + Handle::refresh_clash(); + Handle::refresh_verge(); + logging!(info, Type::Config, "Proxy port configuration applied and persisted"); + Ok(SaveProxyPortsOutcome::Saved) +} + +async fn current_runtime_mapping() -> Result { + Config::runtime() + .await + .data_arc() + .config + .clone() + .ok_or_else(|| anyhow!("current Runtime Configuration is unavailable")) +} + +async fn latest_runtime_mapping() -> Result { + Config::runtime() + .await + .latest_arc() + .config + .clone() + .ok_or_else(|| anyhow!("candidate Runtime Configuration is unavailable")) +} + +async fn probe_proxy_ports( + current: serde_yaml_ng::Mapping, + candidate: serde_yaml_ng::Mapping, + current_core_is_running: bool, +) -> Result { + AsyncHandler::spawn_blocking(move || probe_proxy_port_change(¤t, &candidate, current_core_is_running)) + .await + .context("proxy port probe task failed") +} + +fn rejected_save_outcome(assessment: ListenerProbeOutcome) -> Result> { + match assessment { + ListenerProbeOutcome::Available => Ok(None), + ListenerProbeOutcome::Conflict { port, transport } => { + Ok(Some(SaveProxyPortsOutcome::Conflict { port, transport })) + } + ListenerProbeOutcome::Invalid { message } => bail!("{message}"), + ListenerProbeOutcome::Indeterminate { message } => bail!("{message}"), + } +} + +async fn stage_proxy_ports(settings: &ProxyPortSettings) { + Config::clash().await.edit_draft(|draft| { + draft.0.insert("mixed-port".into(), settings.mixed_port.into()); + draft.0.insert("socks-port".into(), settings.socks.port.into()); + draft.0.insert("port".into(), settings.http.port.into()); + #[cfg(not(target_os = "windows"))] + draft.0.insert("redir-port".into(), settings.redir.port.into()); + #[cfg(target_os = "linux")] + draft.0.insert("tproxy-port".into(), settings.tproxy.port.into()); + }); + Config::verge().await.edit_draft(|draft| { + draft.verge_mixed_port = Some(settings.mixed_port); + draft.verge_socks_port = Some(settings.socks.port); + draft.verge_socks_enabled = Some(settings.socks.enabled); + draft.verge_port = Some(settings.http.port); + draft.verge_http_enabled = Some(settings.http.enabled); + #[cfg(not(target_os = "windows"))] + { + draft.verge_redir_port = Some(settings.redir.port); + draft.verge_redir_enabled = Some(settings.redir.enabled); + } + #[cfg(target_os = "linux")] + { + draft.verge_tproxy_port = Some(settings.tproxy.port); + draft.verge_tproxy_enabled = Some(settings.tproxy.enabled); + } + }); +} + +async fn persist_proxy_port_sources() -> Result<()> { + Config::clash() + .await + .latest_arc() + .save_config() + .await + .context("failed to persist Application Merge Configuration")?; + Config::verge() + .await + .latest_arc() + .save_file() + .await + .context("failed to persist selected proxy ports")?; + Ok(()) +} + +async fn discard_proxy_port_drafts() { + Config::clash().await.discard(); + Config::verge().await.discard(); + Config::runtime().await.discard(); +} + +async fn rollback_proxy_ports(snapshots: &[FileSnapshot], was_running: bool) -> Result<()> { + discard_proxy_port_drafts().await; + let file_result = restore_files(snapshots).await; + if file_result.is_err() { + let _ = Config::generate_file(ConfigType::Run).await; + } + + let lifecycle_result = if was_running { + let manager = CoreManager::global(); + if matches!(*manager.get_running_mode(), RunningMode::NotRunning) { + manager.start_core().await + } else { + manager.restart_core().await + } + } else { + Ok(()) + }; + + match (file_result, lifecycle_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(file_error), Ok(())) => Err(file_error), + (Ok(()), Err(lifecycle_error)) => Err(lifecycle_error).context("failed to restore the previous core"), + (Err(file_error), Err(lifecycle_error)) => Err(anyhow!( + "failed to restore configuration files: {file_error:#}; \ + failed to restore the previous core: {lifecycle_error:#}" + )), + } +} diff --git a/src-tauri/src/feat/mod.rs b/src-tauri/src/feat/mod.rs index 725878664..2d353fe23 100644 --- a/src-tauri/src/feat/mod.rs +++ b/src-tauri/src/feat/mod.rs @@ -2,6 +2,7 @@ mod backup; mod clash; mod config; mod icon; +mod listener; mod profile; mod proxy; mod window; @@ -11,6 +12,7 @@ pub use backup::*; pub use clash::*; pub use config::*; pub use icon::*; +pub use listener::*; pub use profile::*; pub use proxy::*; pub use window::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 690a1b427..031367418 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -134,7 +134,8 @@ mod app_init { tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime, tauri_plugin_clash_verge_sysinfo::commands::app_is_admin, tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info, - cmd::is_port_in_use, + cmd::probe_listener, + cmd::save_proxy_ports, cmd::get_sys_proxy, cmd::get_auto_proxy, cmd::get_embedded_server_port, diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 80f71f038..65af1430a 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -9,6 +9,7 @@ pub mod linux; pub mod macos_launch_guard; pub mod network; pub mod notification; +pub mod port; pub mod resolve; #[cfg(target_os = "windows")] pub mod schtasks; diff --git a/src-tauri/src/utils/port.rs b/src-tauri/src/utils/port.rs new file mode 100644 index 000000000..4b7689c09 --- /dev/null +++ b/src-tauri/src/utils/port.rs @@ -0,0 +1,53 @@ +use std::collections::HashSet; + +pub const MIN_FALLBACK_PORT: u16 = 1025; + +pub fn find_next_available_port(current: u16, reserved: &HashSet, mut is_available: F) -> Option +where + F: FnMut(u16) -> bool, +{ + let mut candidate = next_candidate(current); + + for _ in MIN_FALLBACK_PORT..=u16::MAX { + if candidate != current && !reserved.contains(&candidate) && is_available(candidate) { + return Some(candidate); + } + candidate = next_candidate(candidate); + } + + None +} + +const fn next_candidate(port: u16) -> u16 { + if port < MIN_FALLBACK_PORT || port == u16::MAX { + MIN_FALLBACK_PORT + } else { + port + 1 + } +} + +#[cfg(test)] +mod tests { + use super::{MIN_FALLBACK_PORT, find_next_available_port}; + use std::collections::HashSet; + + #[test] + fn skips_reserved_and_unavailable_ports() { + let reserved = HashSet::from([7898, 7899]); + let port = find_next_available_port(7897, &reserved, |candidate| candidate != 7900); + assert_eq!(port, Some(7901)); + } + + #[test] + fn wraps_after_the_highest_port() { + let reserved = HashSet::from([MIN_FALLBACK_PORT]); + let port = find_next_available_port(u16::MAX, &reserved, |_| true); + assert_eq!(port, Some(MIN_FALLBACK_PORT + 1)); + } + + #[test] + fn never_returns_the_current_port() { + let port = find_next_available_port(20_000, &HashSet::new(), |candidate| candidate == 20_000); + assert_eq!(port, None); + } +} diff --git a/src/components/home/clash-info-card.tsx b/src/components/home/clash-info-card.tsx index fdd63eb63..505f28e77 100644 --- a/src/components/home/clash-info-card.tsx +++ b/src/components/home/clash-info-card.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useClash } from '@/hooks/use-clash' +import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useClashConfigData, useRulesData, @@ -25,6 +26,7 @@ export const ClashInfoCard = () => { const { t } = useTranslation() const { version: clashVersion } = useClash() const { clashConfig } = useClashConfigData() + const displayedMixedPort = useDisplayedMixedPort() const { rules } = useRulesData() const { uptime } = useUptimeData() const { systemProxyAddress } = useSystemData() @@ -61,7 +63,7 @@ export const ClashInfoCard = () => { {t('home.components.clashInfo.fields.mixedPort')} - {clashConfig.mixedPort || '-'} + {displayedMixedPort} @@ -87,6 +89,7 @@ export const ClashInfoCard = () => { }, [ clashConfig, clashVersion, + displayedMixedPort, t, formattedUptime, rules.length, diff --git a/src/components/setting/mods/clash-port-viewer.tsx b/src/components/setting/mods/clash-port-viewer.tsx index 51b1da192..d45d7ff9a 100644 --- a/src/components/setting/mods/clash-port-viewer.tsx +++ b/src/components/setting/mods/clash-port-viewer.tsx @@ -9,13 +9,13 @@ import { TextField, } from '@mui/material' import { useLockFn, useRequest } from 'ahooks' -import { forwardRef, useImperativeHandle, useRef, useState } from 'react' +import { forwardRef, useImperativeHandle, useState } from 'react' import { useTranslation } from 'react-i18next' import { BaseDialog, Switch } from '@/components/base' -import { useClashInfo } from '@/hooks/use-clash' +import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useVerge } from '@/hooks/use-verge' -import { isPortInUse } from '@/services/cmds' +import { saveProxyPorts } from '@/services/cmds' import { showNotice } from '@/services/notice-service' import getSystem from '@/utils/get-system' @@ -31,14 +31,12 @@ const generateRandomPort = () => export const ClashPortViewer = forwardRef((_, ref) => { const { t } = useTranslation() - const { clashInfo, patchInfo } = useClashInfo() - const { verge, patchVerge } = useVerge() + const { verge } = useVerge() + const displayedMixedPort = useDisplayedMixedPort() const [open, setOpen] = useState(false) // Mixed Port - const [mixedPort, setMixedPort] = useState( - verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897, - ) + const [mixedPort, setMixedPort] = useState(displayedMixedPort) // 其他端口状态 const [socksPort, setSocksPort] = useState(verge?.verge_socks_port ?? 7898) @@ -58,50 +56,35 @@ export const ClashPortViewer = forwardRef((_, ref) => { verge?.verge_tproxy_enabled ?? false, ) - // 保存打开对话框时的原始值,用于在检测到端口被占用时恢复 - const originalPortsRef = useRef | null>(null) - // 添加保存请求,防止GUI卡死 - const { loading, run: saveSettings } = useRequest( - async (params: { clashConfig: any; vergeConfig: any }) => { - const { clashConfig, vergeConfig } = params - await Promise.all([patchInfo(clashConfig), patchVerge(vergeConfig)]) + const { loading, runAsync: saveSettings } = useRequest(saveProxyPorts, { + manual: true, + onSuccess: (outcome) => { + if (outcome.status === 'conflict') { + showNotice.error('settings.modals.clashPort.messages.portInUse', { + port: outcome.port, + }) + return + } + setOpen(false) + showNotice.success('settings.modals.clashPort.messages.saved') }, - { - manual: true, - onSuccess: () => { - setOpen(false) - showNotice.success('settings.modals.clashPort.messages.saved') - }, - onError: (error) => { - showNotice.error('settings.modals.clashPort.messages.saveFailed', error) - }, + onError: (error) => { + showNotice.error('settings.modals.clashPort.messages.saveFailed', error) }, - ) + }) useImperativeHandle(ref, () => ({ open: () => { - originalPortsRef.current = { - mixedPort: verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897, - socksPort: verge?.verge_socks_port ?? 7898, - socksEnabled: verge?.verge_socks_enabled ?? false, - httpPort: verge?.verge_port ?? 7899, - httpEnabled: verge?.verge_http_enabled ?? false, - redirPort: verge?.verge_redir_port ?? 7895, - redirEnabled: verge?.verge_redir_enabled ?? false, - tproxyPort: verge?.verge_tproxy_port ?? 7896, - tproxyEnabled: verge?.verge_tproxy_enabled ?? false, - } - - setMixedPort(originalPortsRef.current.mixedPort) - setSocksPort(originalPortsRef.current.socksPort) - setSocksEnabled(originalPortsRef.current.socksEnabled) - setHttpPort(originalPortsRef.current.httpPort) - setHttpEnabled(originalPortsRef.current.httpEnabled) - setRedirPort(originalPortsRef.current.redirPort) - setRedirEnabled(originalPortsRef.current.redirEnabled) - setTproxyPort(originalPortsRef.current.tproxyPort) - setTproxyEnabled(originalPortsRef.current.tproxyEnabled) + setMixedPort(displayedMixedPort) + setSocksPort(verge?.verge_socks_port ?? 7898) + setSocksEnabled(verge?.verge_socks_enabled ?? false) + setHttpPort(verge?.verge_port ?? 7899) + setHttpEnabled(verge?.verge_http_enabled ?? false) + setRedirPort(verge?.verge_redir_port ?? 7895) + setRedirEnabled(verge?.verge_redir_enabled ?? false) + setTproxyPort(verge?.verge_tproxy_port ?? 7896) + setTproxyEnabled(verge?.verge_tproxy_enabled ?? false) setOpen(true) }, close: () => setOpen(false), @@ -136,80 +119,13 @@ export const ClashPortViewer = forwardRef((_, ref) => { return } - const original = originalPortsRef.current - const changedPorts: number[] = [] - - if (mixedPort !== original?.mixedPort) changedPorts.push(mixedPort) - if (socksEnabled && socksPort !== original?.socksPort) - changedPorts.push(socksPort) - if (httpEnabled && httpPort !== original?.httpPort) - changedPorts.push(httpPort) - if (redirEnabled && redirPort !== original?.redirPort) - changedPorts.push(redirPort) - if (tproxyEnabled && tproxyPort !== original?.tproxyPort) - changedPorts.push(tproxyPort) - - for (const port of changedPorts) { - try { - const inUse = await isPortInUse(port) - if (inUse) { - showNotice.error('settings.modals.clashPort.messages.portInUse', { - port, - }) - if (original) { - setMixedPort(original.mixedPort) - setSocksPort(original.socksPort) - setSocksEnabled(original.socksEnabled) - setHttpPort(original.httpPort) - setHttpEnabled(original.httpEnabled) - setRedirPort(original.redirPort) - setRedirEnabled(original.redirEnabled) - setTproxyPort(original.tproxyPort) - setTproxyEnabled(original.tproxyEnabled) - } else { - setMixedPort( - verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897, - ) - setSocksPort(verge?.verge_socks_port ?? 7898) - setSocksEnabled(verge?.verge_socks_enabled ?? false) - setHttpPort(verge?.verge_port ?? 7899) - setHttpEnabled(verge?.verge_http_enabled ?? false) - setRedirPort(verge?.verge_redir_port ?? 7895) - setRedirEnabled(verge?.verge_redir_enabled ?? false) - setTproxyPort(verge?.verge_tproxy_port ?? 7896) - setTproxyEnabled(verge?.verge_tproxy_enabled ?? false) - } - return - } - } catch (error) { - showNotice.error(error) - return - } - } - - // 准备配置数据 - const clashConfig = { - 'mixed-port': mixedPort, - 'socks-port': socksPort, - port: httpPort, - 'redir-port': redirPort, - 'tproxy-port': tproxyPort, - } - - const vergeConfig = { - verge_mixed_port: mixedPort, - verge_socks_port: socksPort, - verge_socks_enabled: socksEnabled, - verge_port: httpPort, - verge_http_enabled: httpEnabled, - verge_redir_port: redirPort, - verge_redir_enabled: redirEnabled, - verge_tproxy_port: tproxyPort, - verge_tproxy_enabled: tproxyEnabled, - } - - // 提交保存请求 - saveSettings({ clashConfig, vergeConfig }) + await saveSettings({ + mixedPort, + socks: { enabled: socksEnabled, port: socksPort }, + http: { enabled: httpEnabled, port: httpPort }, + redir: { enabled: redirEnabled, port: redirPort }, + tproxy: { enabled: tproxyEnabled, port: tproxyPort }, + }) }) return ( diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 0dd5f4e42..706bbfc17 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -32,6 +32,7 @@ import { TooltipIcon, } from '@/components/base' import { EditorViewer } from '@/components/profile/editor-viewer' +import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useSystemProxyState } from '@/hooks/use-system-proxy-state' import { useVerge } from '@/hooks/use-verge' import { useClashConfigData, useSystemData } from '@/providers/app-data-context' @@ -188,27 +189,19 @@ export const SysproxyViewer = forwardRef((props, ref) => { }, [clashConfig?.mixedPort, enabled, value.pac, invalidateProxyState]) const { systemProxyAddress } = useSystemData() + const displayedMixedPort = useDisplayedMixedPort() // 为当前状态计算系统代理地址 const getSystemProxyAddress = useMemo(() => { - if (!clashConfig) return '-' - const isPacMode = value.pac ?? false if (isPacMode) { const host = value.proxy_host || '127.0.0.1' - const port = verge?.verge_mixed_port || clashConfig.mixedPort || 7897 - return `${host}:${port}` + return `${host}:${displayedMixedPort}` } else { return systemProxyAddress } - }, [ - value.pac, - value.proxy_host, - verge?.verge_mixed_port, - clashConfig, - systemProxyAddress, - ]) + }, [value.pac, value.proxy_host, displayedMixedPort, systemProxyAddress]) const getCurrentPacUrl = useMemo(() => { const host = value.proxy_host || '127.0.0.1' return embeddedServerPort @@ -377,7 +370,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { if (pacContent) { pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host) // 将 mixed-port 转换为字符串 - const mixedPortStr = (clashConfig?.mixedPort || '').toString() + const mixedPortStr = displayedMixedPort.toString() pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr) } diff --git a/src/components/setting/mods/tunnels-viewer.tsx b/src/components/setting/mods/tunnels-viewer.tsx index 71b0ec9f0..1efb1a81c 100644 --- a/src/components/setting/mods/tunnels-viewer.tsx +++ b/src/components/setting/mods/tunnels-viewer.tsx @@ -25,7 +25,7 @@ import { useTranslation } from 'react-i18next' import { BaseDialog } from '@/components/base' import { useClash } from '@/hooks/use-clash' import { useProxiesData } from '@/providers/app-data-context' -import { isPortInUse } from '@/services/cmds' +import { probeListener, type ListenerTransport } from '@/services/cmds' import { showNotice } from '@/services/notice-service' import { isInteractableMember, @@ -59,12 +59,14 @@ interface TunnelProxyOption { member: ResolvedProxyMember } +type TunnelNetwork = ListenerTransport | 'tcp+udp' + interface TunnelFormValues { localAddr: string localPort: string targetAddr: string targetPort: string - network: string + network: TunnelNetwork group: string proxy: ProxyMemberOccurrenceBinding | null } @@ -222,14 +224,6 @@ export const TunnelsViewer = forwardRef((_, ref) => { ) return } - const inUse = await isPortInUse(Number(localPort)) - if (inUse) { - showNotice.error('settings.modals.clashPort.messages.portInUse', { - port: localPort, - }) - return - } - // 目标地址校验 (host) const targetHost = normalizeHost(targetAddr) if (!targetHost) { @@ -260,8 +254,10 @@ export const TunnelsViewer = forwardRef((_, ref) => { } // 构造新 entry + const transports: ListenerTransport[] = + network === 'tcp+udp' ? ['tcp', 'udp'] : [network] const entry: TunnelEntry = { - network: network === 'tcp+udp' ? ['tcp', 'udp'] : [network], + network: transports, address: formatHostPort(localHost, localPort), target: formatHostPort(targetHost, targetPort), ...(latestSelectedProxy @@ -269,8 +265,29 @@ export const TunnelsViewer = forwardRef((_, ref) => { : {}), } + const candidateTunnels = [...draftTunnels, entry] + try { + const outcome = await probeListener({ + address: entry.address, + transports, + }) + if (outcome.status === 'conflict') { + showNotice.error('settings.modals.clashPort.messages.portInUse', { + port: outcome.port, + }) + return + } + if (outcome.status !== 'available') { + showNotice.error(outcome.message) + return + } + } catch (error) { + showNotice.error(error) + return + } + // 写入配置 + 清空输入 - setDraftTunnels((prev) => [...prev, entry]) + setDraftTunnels(candidateTunnels) setValues((v) => ({ ...v, @@ -369,7 +386,7 @@ export const TunnelsViewer = forwardRef((_, ref) => { onChange={(e) => setValues((v) => ({ ...v, - network: e.target.value as string, + network: e.target.value as TunnelNetwork, })) } > diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index 64bd74b5c..1ab467ce9 100644 --- a/src/components/setting/setting-clash.tsx +++ b/src/components/setting/setting-clash.tsx @@ -9,6 +9,7 @@ import { updateGeo, type LogLevel } from 'tauri-plugin-mihomo-api' import { DialogRef, Switch, TooltipIcon } from '@/components/base' import { useClash } from '@/hooks/use-clash' import { useClashLog } from '@/hooks/use-clash-log' +import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useVerge } from '@/hooks/use-verge' import { invoke_uwp_tool } from '@/services/cmds' import { showNotice } from '@/services/notice-service' @@ -36,6 +37,7 @@ const SettingClash = ({ onError }: Props) => { const { clash, version, mutateClash, patchClash } = useClash() const { verge, patchVerge } = useVerge() + const displayedMixedPort = useDisplayedMixedPort() const [, setClashLog] = useClashLog() const { @@ -45,8 +47,6 @@ const SettingClash = ({ onError }: Props) => { 'unified-delay': unifiedDelay, } = clash ?? {} - const { verge_mixed_port } = verge ?? {} - // 独立跟踪DNS设置开关状态 const [dnsSettingsEnabled, setDnsSettingsEnabled] = useState(() => { return verge?.enable_dns_settings ?? false @@ -223,7 +223,7 @@ const SettingClash = ({ onError }: Props) => { autoComplete="new-password" disabled={false} size="small" - value={verge_mixed_port ?? 7897} + value={displayedMixedPort} sx={{ width: 100, input: { py: '7.5px', cursor: 'pointer' } }} onClick={(e) => { portRef.current?.open() diff --git a/src/hooks/use-displayed-mixed-port.ts b/src/hooks/use-displayed-mixed-port.ts new file mode 100644 index 000000000..59183dce2 --- /dev/null +++ b/src/hooks/use-displayed-mixed-port.ts @@ -0,0 +1,18 @@ +import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash' +import { useVerge } from '@/hooks/use-verge' +import { useClashConfigData } from '@/providers/app-data-context' +import { resolveDisplayedMixedPort } from '@/utils/mixed-port' + +export const useDisplayedMixedPort = () => { + const { clashConfig } = useClashConfigData() + const { data: runtimeConfig } = useRuntimeConfig() + const { clashInfo } = useClashInfo() + const { verge } = useVerge() + + return resolveDisplayedMixedPort({ + live: clashConfig?.mixedPort, + runtime: runtimeConfig?.['mixed-port'], + selected: verge?.verge_mixed_port, + merge: clashInfo?.mixed_port, + }) +} diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index e8a0cb38a..1abde6091 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -1,8 +1,9 @@ import { useRef } from 'react' import { closeAllConnections } from 'tauri-plugin-mihomo-api' +import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useVerge } from '@/hooks/use-verge' -import { useClashConfigData, useSystemData } from '@/providers/app-data-context' +import { useSystemData } from '@/providers/app-data-context' import { getAutotemProxy, getEmbeddedServerPort } from '@/services/cmds' import { revalidateQueries, useQuery } from '@/services/query-client' @@ -10,7 +11,7 @@ import { revalidateQueries, useQuery } from '@/services/query-client' export const useSystemProxyState = () => { const { verge, mutateVerge, patchVerge } = useVerge() const { sysproxy } = useSystemData() - const { clashConfig } = useClashConfigData() + const displayedMixedPort = useDisplayedMixedPort() const { data: autoproxy } = useQuery({ queryKey: ['getAutotemProxy'], queryFn: getAutotemProxy, @@ -22,12 +23,7 @@ export const useSystemProxyState = () => { queryFn: getEmbeddedServerPort, }) - const { - enable_system_proxy, - proxy_auto_config, - proxy_host, - verge_mixed_port, - } = verge ?? {} + const { enable_system_proxy, proxy_auto_config, proxy_host } = verge ?? {} // OS 实际状态:enable + 地址匹配本应用 const indicator = (() => { @@ -38,8 +34,7 @@ export const useSystemProxyState = () => { return autoproxy.url === `http://${host}:${pacPort}/commands/pac` } else { if (!sysproxy?.enable) return false - const port = verge_mixed_port || clashConfig?.mixedPort || 7897 - return sysproxy.server === `${host}:${port}` + return sysproxy.server === `${host}:${displayedMixedPort}` } })() diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index d28c05aaf..1d1b0a9b4 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -286,6 +286,8 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", + "automaticFallback": "المنفذ {{oldPort}} مستخدم. تم تبديل منفذ الوكيل المختلط إلى {{newPort}} وحفظه.", + "automaticFallbackFailed": "فشل التبديل التلقائي لمنفذ الوكيل المختلط: {{error}}", "portTooLow": "يجب ألا يقل المنفذ عن 1000", "portTooHigh": "يجب ألا يزيد المنفذ عن 65535" } diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 7171450a8..981ecdfca 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -286,6 +286,8 @@ "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.", + "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 cffe9fa59..fe0718e73 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -286,6 +286,8 @@ "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.", + "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 935e7d357..d253ea2bf 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -286,6 +286,8 @@ "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ó.", + "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 50a3a79d1..32681ed29 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -286,6 +286,8 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", + "automaticFallback": "پورت {{oldPort}} در حال استفاده بود. پورت پراکسی ترکیبی به {{newPort}} تغییر کرد و ذخیره شد.", + "automaticFallbackFailed": "تغییر خودکار پورت پراکسی ترکیبی ناموفق بود: {{error}}", "portTooLow": "پورت باید ۱۰۰۰ یا بیشتر باشد", "portTooHigh": "پورت باید ۶۵۵۳۵ یا کمتر باشد" } diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 88c2217d0..3876feede 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -286,6 +286,8 @@ "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.", + "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 b7b958d13..981f0810d 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -286,6 +286,8 @@ "portInUse": "Port {{port}} is already in use", "saved": "Port settings saved", "saveFailed": "Failed to save port settings", + "automaticFallback": "ポート {{oldPort}} は使用中です。混合プロキシポートを {{newPort}} に切り替えて保存しました。", + "automaticFallbackFailed": "混合プロキシポートの自動切り替えに失敗しました: {{error}}", "portTooLow": "ポート番号は 1000 以上である必要があります", "portTooHigh": "ポート番号は 65535 以下である必要があります" } diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 947ceed4f..2e594d487 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -286,6 +286,8 @@ "portInUse": "Port {{port}} is already in use", "saved": "포트 설정이 저장되었습니다", "saveFailed": "포트 설정 저장에 실패했습니다", + "automaticFallback": "포트 {{oldPort}}이(가) 사용 중입니다. 혼합 프록시 포트를 {{newPort}}(으)로 전환하고 저장했습니다.", + "automaticFallbackFailed": "혼합 프록시 포트 자동 전환에 실패했습니다: {{error}}", "portTooLow": "포트는 1000 이상이어야 합니다", "portTooHigh": "포트는 65535 이하여야 합니다" } diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 05159a280..86e341922 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -286,6 +286,8 @@ "portInUse": "Порт {{port}} уже используется", "saved": "Настройки портов сохранены", "saveFailed": "Не удалось сохранить настройки портов", + "automaticFallback": "Порт {{oldPort}} занят. Смешанный прокси-порт переключён на {{newPort}} и сохранён.", + "automaticFallbackFailed": "Не удалось автоматически переключить смешанный прокси-порт: {{error}}", "portTooLow": "Порт должен быть не меньше 1000", "portTooHigh": "Порт должен быть не больше 65535" } diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index a73e81693..3b2e70568 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -286,6 +286,8 @@ "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.", + "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 916d1eba1..23220ad54 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -286,6 +286,8 @@ "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.", + "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 940667365..6810de12c 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -286,6 +286,8 @@ "portInUse": "端口 {{port}} 已被占用", "saved": "端口设置已保存", "saveFailed": "端口设置保存失败", + "automaticFallback": "端口 {{oldPort}} 已被占用,混合代理端口已自动切换并保存为 {{newPort}}。", + "automaticFallbackFailed": "混合代理端口自动切换失败:{{error}}", "portTooLow": "端口号不能小于 1000", "portTooHigh": "端口号不能大于 65535" } diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index 76eda5055..b0412853d 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -286,6 +286,8 @@ "portInUse": "Port {{port}} is already in use", "saved": "連結埠設定已儲存", "saveFailed": "連結埠設定儲存失敗", + "automaticFallback": "連結埠 {{oldPort}} 已被佔用,混合代理連結埠已自動切換並儲存為 {{newPort}}。", + "automaticFallbackFailed": "混合代理連結埠自動切換失敗:{{error}}", "portTooLow": "連結埠不能小於 1000", "portTooHigh": "連結埠不能大於 65535" } diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index fe34f5a1a..346c44e89 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -45,6 +45,7 @@ export const useLayoutEvents = ( 'getProxyView', 'getVersion', 'getClashConfig', + 'getClashInfo', 'getClashMode', 'getRuntimeConfig', 'getRules', diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index d0d8530fa..82c8c520f 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -89,6 +89,18 @@ export const handleNoticeMessage = ( 'settings.feedback.notifications.clash.changeFailed', msg, ), + 'mixed_port::fallback': () => { + const [oldPort, newPort] = msg.split(',') + showNotice.info('settings.modals.clashPort.messages.automaticFallback', { + oldPort, + newPort, + }) + }, + 'mixed_port::fallback_error': () => + showNotice.error( + 'settings.modals.clashPort.messages.automaticFallbackFailed', + { error: msg }, + ), } const handler = handlers[status] diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index 21765d284..8e7dc5afb 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -6,6 +6,7 @@ import { getRules, } from 'tauri-plugin-mihomo-api' +import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash' import { useVerge } from '@/hooks/use-verge' import { getAppUptime, @@ -14,6 +15,7 @@ import { getSystemProxy, } from '@/services/cmds' import { revalidateQueries, useQuery } from '@/services/query-client' +import { resolveDisplayedMixedPort } from '@/utils/mixed-port' import { ClashConfigContext, @@ -53,6 +55,8 @@ export const AppDataProvider = ({ children: React.ReactNode }) => { const { verge } = useVerge() + const { data: runtimeConfig } = useRuntimeConfig() + const { clashInfo } = useClashInfo() const { data: proxyView, @@ -233,17 +237,22 @@ export const AppDataProvider = ({ ) const systemValue = useMemo(() => { + const displayedMixedPort = resolveDisplayedMixedPort({ + live: clashConfig?.mixedPort, + runtime: runtimeConfig?.['mixed-port'], + selected: verge?.verge_mixed_port, + merge: clashInfo?.mixed_port, + }) + const calculateSystemProxyAddress = () => { - if (!verge || !clashConfig) return '-' + if (!verge) return '-' const isPacMode = verge.proxy_auto_config ?? false if (isPacMode) { // PAC模式:显示我们期望设置的代理地址 const proxyHost = verge.proxy_host || '127.0.0.1' - const proxyPort = - verge.verge_mixed_port || clashConfig.mixedPort || 7897 - return `${proxyHost}:${proxyPort}` + return `${proxyHost}:${displayedMixedPort}` } else { // HTTP代理模式:优先使用系统地址,但如果格式不正确则使用期望地址 const systemServer = sysproxy?.server @@ -256,9 +265,7 @@ export const AppDataProvider = ({ } else { // 系统地址无效,返回期望的代理地址 const proxyHost = verge.proxy_host || '127.0.0.1' - const proxyPort = - verge.verge_mixed_port || clashConfig.mixedPort || 7897 - return `${proxyHost}:${proxyPort}` + return `${proxyHost}:${displayedMixedPort}` } } } @@ -268,7 +275,7 @@ export const AppDataProvider = ({ runningMode, systemProxyAddress: calculateSystemProxyAddress(), } - }, [sysproxy, runningMode, verge, clashConfig]) + }, [sysproxy, runningMode, verge, clashConfig, runtimeConfig, clashInfo]) const uptimeValue = useMemo(() => ({ uptime: uptimeData || 0 }), [uptimeData]) diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 42c9746b9..5ec309f05 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -429,11 +429,44 @@ export async function getNextUpdateTime(uid: string) { return invoke('get_next_update_time', { uid }) } -export const isPortInUse = async (port: number) => { - try { - return await invoke('is_port_in_use', { port }) - } catch (error) { - console.error('检查端口使用状态失败:', error) - return false - } +interface ToggleableProxyPort { + enabled: boolean + port: number +} + +export interface ProxyPortSettings { + mixedPort: number + socks: ToggleableProxyPort + http: ToggleableProxyPort + redir: ToggleableProxyPort + tproxy: ToggleableProxyPort +} + +export type ListenerTransport = 'tcp' | 'udp' + +export interface ListenerProbe { + address: string + transports: ListenerTransport[] +} + +export type ListenerProbeOutcome = + | { status: 'available' } + | { + status: 'conflict' + port: number + transport: ListenerTransport + } + | { status: 'invalid'; message: string } + | { status: 'indeterminate'; message: string } + +export type SaveProxyPortsOutcome = + | { status: 'saved' } + | { status: 'conflict'; port: number; transport: ListenerTransport } + +export const probeListener = async (request: ListenerProbe) => { + return invoke('probe_listener', { request }) +} + +export const saveProxyPorts = async (settings: ProxyPortSettings) => { + return invoke('save_proxy_ports', { settings }) } diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index de14429a7..b555a6afe 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -536,6 +536,8 @@ export const translationKeys = [ 'settings.modals.clashPort.messages.portInUse', 'settings.modals.clashPort.messages.saved', 'settings.modals.clashPort.messages.saveFailed', + 'settings.modals.clashPort.messages.automaticFallback', + 'settings.modals.clashPort.messages.automaticFallbackFailed', 'settings.modals.clashPort.messages.portTooLow', 'settings.modals.clashPort.messages.portTooHigh', 'settings.modals.clashCore.variants.release', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index bbd064bfe..c7ed9187b 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -893,6 +893,8 @@ export interface TranslationResources { tproxy: string } messages: { + automaticFallback: string + automaticFallbackFailed: string portInUse: string portTooHigh: string portTooLow: string diff --git a/src/utils/mixed-port.ts b/src/utils/mixed-port.ts new file mode 100644 index 000000000..dda951b65 --- /dev/null +++ b/src/utils/mixed-port.ts @@ -0,0 +1,20 @@ +interface MixedPortSources { + live?: number | null + runtime?: number | null + selected?: number | null + merge?: number | null +} + +const isValidPort = (port: number | null | undefined): port is number => + typeof port === 'number' && + Number.isInteger(port) && + port >= 1 && + port <= 65535 + +export const resolveDisplayedMixedPort = ({ + live, + runtime, + selected, + merge, +}: MixedPortSources): number => + [live, runtime, selected, merge].find(isValidPort) ?? 7897