mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-11 21:12:09 +08:00
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:
Generated
+1
@@ -1124,6 +1124,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_yaml_ng",
|
||||
"smartstring",
|
||||
"socket2 0.6.4",
|
||||
"sysproxy",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,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::*};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(¤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::<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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤t, &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,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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "پورت باید ۶۵۵۳۵ یا کمتر باشد"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 以下である必要があります"
|
||||
}
|
||||
|
||||
@@ -286,6 +286,8 @@
|
||||
"portInUse": "Port {{port}} is already in use",
|
||||
"saved": "포트 설정이 저장되었습니다",
|
||||
"saveFailed": "포트 설정 저장에 실패했습니다",
|
||||
"automaticFallback": "포트 {{oldPort}}이(가) 사용 중입니다. 혼합 프록시 포트를 {{newPort}}(으)로 전환하고 저장했습니다.",
|
||||
"automaticFallbackFailed": "혼합 프록시 포트 자동 전환에 실패했습니다: {{error}}",
|
||||
"portTooLow": "포트는 1000 이상이어야 합니다",
|
||||
"portTooHigh": "포트는 65535 이하여야 합니다"
|
||||
}
|
||||
|
||||
@@ -286,6 +286,8 @@
|
||||
"portInUse": "Порт {{port}} уже используется",
|
||||
"saved": "Настройки портов сохранены",
|
||||
"saveFailed": "Не удалось сохранить настройки портов",
|
||||
"automaticFallback": "Порт {{oldPort}} занят. Смешанный прокси-порт переключён на {{newPort}} и сохранён.",
|
||||
"automaticFallbackFailed": "Не удалось автоматически переключить смешанный прокси-порт: {{error}}",
|
||||
"portTooLow": "Порт должен быть не меньше 1000",
|
||||
"portTooHigh": "Порт должен быть не больше 65535"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 тән артмаска тиеш"
|
||||
}
|
||||
|
||||
@@ -286,6 +286,8 @@
|
||||
"portInUse": "端口 {{port}} 已被占用",
|
||||
"saved": "端口设置已保存",
|
||||
"saveFailed": "端口设置保存失败",
|
||||
"automaticFallback": "端口 {{oldPort}} 已被占用,混合代理端口已自动切换并保存为 {{newPort}}。",
|
||||
"automaticFallbackFailed": "混合代理端口自动切换失败:{{error}}",
|
||||
"portTooLow": "端口号不能小于 1000",
|
||||
"portTooHigh": "端口号不能大于 65535"
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -893,6 +893,8 @@ export interface TranslationResources {
|
||||
tproxy: string
|
||||
}
|
||||
messages: {
|
||||
automaticFallback: string
|
||||
automaticFallbackFailed: string
|
||||
portInUse: string
|
||||
portTooHigh: string
|
||||
portTooLow: string
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user