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
This commit is contained in:
wonfen
2026-07-25 05:47:28 +08:00
committed by GitHub
parent 91d0643b5c
commit 7796259b03
47 changed files with 1644 additions and 192 deletions
Generated
+1
View File
@@ -1124,6 +1124,7 @@ dependencies = [
"serde_json",
"serde_yaml_ng",
"smartstring",
"socket2 0.6.4",
"sysproxy",
"tauri",
"tauri-build",
+2
View File
@@ -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",
+3
View File
@@ -5,12 +5,15 @@
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
- 新增混合代理端口冲突自动回退:启动时检测端口占用并持久化可用端口,同时统一首页、设置与系统代理中的生效端口显示
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- TUN 模式设置也遵循 原始配置 < Merge 覆写 < Script 脚本 < GUI
- 优化监听端口冲突检测:代理端口按最终 Runtime 监听范围校验并协调三层持久化,隧道按实际地址与 TCP/UDP 协议检测且保留热重载
- 优化代理数据处理:统一通过单一 IPC 获取有序代理视图,并增强刷新、链路选择、重复判断及异常状态下的稳定性
- 新增 macOS 启动门禁,避免多用户环境下的实例冲突
- 加强 Service 多用户隔离与身份认证,支持安全的 Owner 接管
+12 -6
View File
@@ -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"
+1
View File
@@ -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"
+15
View File
@@ -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<ListenerProbeOutcome> {
feat::probe_listener(request).await.stringify_err()
}
#[tauri::command]
pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult<SaveProxyPortsOutcome> {
feat::save_proxy_ports(settings).await.stringify_err()
}
+2
View File
@@ -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::*;
-6
View File
@@ -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<Vec<NetworkInterface>> {
Ok(result)
}
#[tauri::command]
pub fn is_port_in_use(port: u16) -> bool {
TcpListener::bind(("127.0.0.1", port)).is_err()
}
+16 -1
View File
@@ -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))
+2
View File
@@ -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::*};
+327
View File
@@ -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<Mutex<Option<MixedPortFallback>>> = Lazy::new(|| Mutex::new(None));
static STARTUP_CORE_BLOCKED: AtomicBool = AtomicBool::new(false);
static STARTUP_CORE_BLOCK_REASON: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
impl Config {
pub(crate) async fn resolve_startup_mixed_port() -> Result<bool> {
Self::resolve_startup_mixed_port_inner().await
}
pub(crate) async fn retry_startup_mixed_port_fallback() -> Result<bool> {
Self::resolve_startup_mixed_port_inner().await
}
async fn resolve_startup_mixed_port_inner() -> Result<bool> {
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<String> {
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<u16> {
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<u16> {
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));
}
}
+91
View File
@@ -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<Vec<u8>>,
}
async fn capture_files(paths: &[PathBuf]) -> Result<Vec<FileSnapshot>> {
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<Vec<FileSnapshot>> {
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(())
}
}
+589
View File
@@ -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<ListenerTransport>,
}
#[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<IpAddr>,
}
impl ListenerBindScope {
pub(crate) fn from_mapping(config: &Mapping) -> Result<Self> {
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::<Vec<_>>();
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::<Vec<_>>();
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<Vec<BindClaim>> {
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<Vec<IpAddr>> {
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<IpAddr>, 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::<u16>()
.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<Option<u16>> {
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<ListenerProbeOutcome> {
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<Socket> {
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::<i32>() 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<Mapping> {
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(&current, &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(&current, &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::<super::ProxyPortSettings>(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(())
}
}
+51 -4
View File
@@ -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<bool> {
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);
}
}
}
}
}
+1
View File
@@ -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;
+254
View File
@@ -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<ListenerProbeOutcome> {
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<SaveProxyPortsOutcome> {
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<serde_yaml_ng::Mapping> {
Config::runtime()
.await
.data_arc()
.config
.clone()
.ok_or_else(|| anyhow!("current Runtime Configuration is unavailable"))
}
async fn latest_runtime_mapping() -> Result<serde_yaml_ng::Mapping> {
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<ListenerProbeOutcome> {
AsyncHandler::spawn_blocking(move || probe_proxy_port_change(&current, &candidate, current_core_is_running))
.await
.context("proxy port probe task failed")
}
fn rejected_save_outcome(assessment: ListenerProbeOutcome) -> Result<Option<SaveProxyPortsOutcome>> {
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:#}"
)),
}
}
+2
View File
@@ -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::*;
+2 -1
View File
@@ -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,
+1
View File
@@ -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;
+53
View File
@@ -0,0 +1,53 @@
use std::collections::HashSet;
pub const MIN_FALLBACK_PORT: u16 = 1025;
pub fn find_next_available_port<F>(current: u16, reserved: &HashSet<u16>, mut is_available: F) -> Option<u16>
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);
}
}
+4 -1
View File
@@ -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')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
{clashConfig.mixedPort || '-'}
{displayedMixedPort}
</Typography>
</Stack>
<Divider />
@@ -87,6 +89,7 @@ export const ClashInfoCard = () => {
}, [
clashConfig,
clashVersion,
displayedMixedPort,
t,
formattedUptime,
rules.length,
+36 -120
View File
@@ -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<ClashPortViewerRef>((_, 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<ClashPortViewerRef>((_, ref) => {
verge?.verge_tproxy_enabled ?? false,
)
// 保存打开对话框时的原始值,用于在检测到端口被占用时恢复
const originalPortsRef = useRef<Record<string, any> | 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<ClashPortViewerRef>((_, 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 (
@@ -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<DialogRef>((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<DialogRef>((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)
}
+30 -13
View File
@@ -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<TunnelsViewerRef>((_, 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<TunnelsViewerRef>((_, 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<TunnelsViewerRef>((_, 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<TunnelsViewerRef>((_, ref) => {
onChange={(e) =>
setValues((v) => ({
...v,
network: e.target.value as string,
network: e.target.value as TunnelNetwork,
}))
}
>
+3 -3
View File
@@ -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()
+18
View File
@@ -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,
})
}
+5 -10
View File
@@ -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}`
}
})()
+2
View File
@@ -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"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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": "پورت باید ۶۵۵۳۵ یا کمتر باشد"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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 以下である必要があります"
}
+2
View File
@@ -286,6 +286,8 @@
"portInUse": "Port {{port}} is already in use",
"saved": "포트 설정이 저장되었습니다",
"saveFailed": "포트 설정 저장에 실패했습니다",
"automaticFallback": "포트 {{oldPort}}이(가) 사용 중입니다. 혼합 프록시 포트를 {{newPort}}(으)로 전환하고 저장했습니다.",
"automaticFallbackFailed": "혼합 프록시 포트 자동 전환에 실패했습니다: {{error}}",
"portTooLow": "포트는 1000 이상이어야 합니다",
"portTooHigh": "포트는 65535 이하여야 합니다"
}
+2
View File
@@ -286,6 +286,8 @@
"portInUse": "Порт {{port}} уже используется",
"saved": "Настройки портов сохранены",
"saveFailed": "Не удалось сохранить настройки портов",
"automaticFallback": "Порт {{oldPort}} занят. Смешанный прокси-порт переключён на {{newPort}} и сохранён.",
"automaticFallbackFailed": "Не удалось автоматически переключить смешанный прокси-порт: {{error}}",
"portTooLow": "Порт должен быть не меньше 1000",
"portTooHigh": "Порт должен быть не больше 65535"
}
+2
View File
@@ -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"
}
+2
View File
@@ -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 тән артмаска тиеш"
}
+2
View File
@@ -286,6 +286,8 @@
"portInUse": "端口 {{port}} 已被占用",
"saved": "端口设置已保存",
"saveFailed": "端口设置保存失败",
"automaticFallback": "端口 {{oldPort}} 已被占用,混合代理端口已自动切换并保存为 {{newPort}}。",
"automaticFallbackFailed": "混合代理端口自动切换失败:{{error}}",
"portTooLow": "端口号不能小于 1000",
"portTooHigh": "端口号不能大于 65535"
}
+2
View File
@@ -286,6 +286,8 @@
"portInUse": "Port {{port}} is already in use",
"saved": "連結埠設定已儲存",
"saveFailed": "連結埠設定儲存失敗",
"automaticFallback": "連結埠 {{oldPort}} 已被佔用,混合代理連結埠已自動切換並儲存為 {{newPort}}。",
"automaticFallbackFailed": "混合代理連結埠自動切換失敗:{{error}}",
"portTooLow": "連結埠不能小於 1000",
"portTooHigh": "連結埠不能大於 65535"
}
@@ -45,6 +45,7 @@ export const useLayoutEvents = (
'getProxyView',
'getVersion',
'getClashConfig',
'getClashInfo',
'getClashMode',
'getRuntimeConfig',
'getRules',
@@ -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]
+15 -8
View File
@@ -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])
+40 -7
View File
@@ -429,11 +429,44 @@ export async function getNextUpdateTime(uid: string) {
return invoke<number | null>('get_next_update_time', { uid })
}
export const isPortInUse = async (port: number) => {
try {
return await invoke<boolean>('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<ListenerProbeOutcome>('probe_listener', { request })
}
export const saveProxyPorts = async (settings: ProxyPortSettings) => {
return invoke<SaveProxyPortsOutcome>('save_proxy_ports', { settings })
}
+2
View File
@@ -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',
+2
View File
@@ -893,6 +893,8 @@ export interface TranslationResources {
tproxy: string
}
messages: {
automaticFallback: string
automaticFallbackFailed: string
portInUse: string
portTooHigh: string
portTooLow: string
+20
View File
@@ -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