chore: sync dev and trim redundant backend tests

This commit is contained in:
Tunglies
2026-10-06 13:53:56 +08:00
31 changed files with 519 additions and 407 deletions
+6
View File
@@ -3,6 +3,7 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
@@ -11,6 +12,10 @@
- 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题
- 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复
**🍎 macOS**
- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题
</details>
<details>
@@ -22,6 +27,7 @@
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化端口设置窗口的实现,精简重复代码
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
+3 -2
View File
@@ -31,6 +31,7 @@ A Clash Meta GUI based on <a href="https://github.com/tauri-apps/tauri">Tauri</a
请到发布页面下载对应的安装包:[Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases)<br>
Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package<br>
Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
支持 Windows (x64/x86)、Linux (x64/arm64) 和 macOS 11+ (intel/apple)。
#### 我应当怎样选择发行版
@@ -50,14 +51,14 @@ Supports Windows (x64/x86), Linux (x64/arm64) and macOS 11+ (intel/apple).
### ✈️ [AI云边 -- 全新架构机场 ClaudeBorder](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
🔥热销中使用本链接注册即送 3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
🔥热销中使用本链接注册即送 **3 天免费试用**,每日 **1GB 流量**:👉 [点此注册](https://cruise.54678999.xyz/#/register?code=58q5UJZc)
#### AI云边 -- 全新架构机场。
- 💻 多次**技术迭代后**全新亮相。
- 🗺 全**高速稳定**正价节点。
- 🌏 **海外团队**,不跑路
- 🚀 线路**冗余**设计,自动化运维**对抗各类封锁
- 🚀 线路**冗余**设计,**自动化运维**对抗各类封锁
- 👨‍🦲 团队架构师为**大厂**网络架构师
- 💰 极致**稳定**,亲民价**价格**
- 🌐 全面支持**流媒体及各AI访问**
+41 -19
View File
@@ -30,29 +30,51 @@ function is_valid_ip() {
is_valid_ipv4 "$1" || is_valid_ipv6 "$1"
}
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
[ $# -lt 1 ] && echo "Usage: $0 <IP address>" && exit 1
! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
nic=$(route -n get default | grep "interface" | awk '{print $2}')
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# Backups are keyed by service ID, which survives renaming the service and renumbering its interface.
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName)
[ -z "$hardware_port" ] && echo "No primary network service." && exit 1
original_dns=$(networksetup -getdnsservers "$hardware_port")
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
is_valid_dns=false
for ip in $original_dns; do
ip=$(echo "$ip" | tr -d '[:space:]')
if [ -n "$ip" ] && (is_valid_ipv4 "$ip" || is_valid_ipv6 "$ip"); then
is_valid_dns=true
break
backup=".original_dns/$service_id"
mkdir -p .original_dns || exit 1
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
if [ -f "$backup" ]; then
rm -f .original_dns.txt || exit 1
else
mv .original_dns.txt "$backup" || exit 1
fi
done
if [ "$is_valid_dns" = false ]; then
echo "empty" >.original_dns.txt
else
echo "$original_dns" >.original_dns.txt
fi
networksetup -setdnsservers "$hardware_port" "$1"
# The placeholder is ours, not the user's: keep the backup taken before it was applied.
if [ "$original_dns" != "$1" ]; then
echo "$original_dns" >"$backup" || exit 1
networksetup -setdnsservers "$hardware_port" "$1"
[ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; }
elif [ ! -f "$backup" ]; then
echo "empty" >"$backup" || exit 1
fi
+57 -8
View File
@@ -1,13 +1,62 @@
#!/bin/bash
nic=$(route -n get default | grep "interface" | awk '{print $2}')
[ $# -lt 1 ] && echo "Usage: $0 <placeholder IP address>" && exit 1
hardware_port=$(networksetup -listnetworkserviceorder | awk -v dev="$nic" '
/^\([0-9]+\) /{port=$0; sub(/^\([0-9]+\) /, "", port)}
/\(Hardware Port:/{interface=$NF;sub(/\)/, "", interface); if (interface == dev) {print port; exit}}
')
# Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent.
function sc_value() {
printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
function dns_of() {
local dns line ip='^[0-9A-Fa-f]*[.:][0-9A-Fa-f.:]*(%[[:alnum:]_.-]+)?$'
dns=$(networksetup -getdnsservers "$1") || return 1
if [ "$dns" = "There aren't any DNS Servers set on $1." ]; then
echo "empty"
return
fi
[ -n "$dns" ] || return 1
while read -r line; do
[[ $line =~ $ip ]] || return 1
done <<<"$dns"
echo "$dns"
}
result=0
# Older releases kept a single backup for whichever service was primary.
if [ -f .original_dns.txt ]; then
original_dns=$(cat .original_dns.txt)
networksetup -setdnsservers "$hardware_port" $original_dns
rm -rf .original_dns.txt
service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService)
if [ -z "$service_id" ] || ! mkdir -p .original_dns; then
result=1
elif [ -f ".original_dns/$service_id" ]; then
rm -f .original_dns.txt || result=1
elif ! mv .original_dns.txt ".original_dns/$service_id"; then
result=1
fi
fi
for backup in .original_dns/*; do
[ -f "$backup" ] || continue
hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName)
# Kept for a service that is gone: there is nothing to write it back to.
[ -n "$hardware_port" ] || continue
if ! current_dns=$(dns_of "$hardware_port"); then
echo "Cannot read DNS servers of $hardware_port." >&2
result=1
continue
fi
# A service no longer on the placeholder was changed by someone else, and that change stands.
if [ "$current_dns" = "$1" ]; then
original_dns=$(cat "$backup") || { result=1; continue; }
# Older releases could back up the placeholder itself.
[ "$original_dns" = "$1" ] && original_dns=empty
networksetup -setdnsservers "$hardware_port" $original_dns
if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then
echo "Failed to restore DNS for $hardware_port." >&2
result=1
continue
fi
fi
rm -f "$backup" || result=1
done
rmdir .original_dns 2>/dev/null
exit $result
+6
View File
@@ -343,6 +343,12 @@ impl CoreManager {
let run_path = Config::write_runtime_file(&yaml).await?;
self.apply_config(run_path).await?;
// Under the lifecycle lock, so a stop for exit waits for this write instead of outrunning it.
#[cfg(target_os = "macos")]
{
let _lifecycle = self.lifecycle_lock.lock().await;
crate::utils::resolve::dns::sync_public_dns().await;
}
Ok(ValidationOutcome::Valid)
}
+20 -2
View File
@@ -515,7 +515,13 @@ impl CoreManager {
}
async fn stop_sidecar_after_proxy_clear(&self) -> Result<()> {
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await
let result =
run_sidecar_termination_transition(proxy_control::clear, || self.stop_core_by_sidecar_unprepared()).await;
#[cfg(target_os = "macos")]
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::utils::resolve::dns::sync_public_dns().await;
}
result
}
pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> {
@@ -544,6 +550,8 @@ impl CoreManager {
.ok_or_else(|| {
anyhow::anyhow!("cannot apply system proxy before core readiness").context(SysproxyFailure::CoreNotReady)
})?;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
// At login the app usually beats the network. Leave the write to the network watcher
// rather than fail — only if the watcher is actually live; a user toggling while
// offline still fails fast.
@@ -615,6 +623,8 @@ impl CoreManager {
tracing::Span::current().record("decision", tracing::field::debug(&startup));
if matches!(startup, StartupDecision::Wait) {
self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
#[cfg(target_os = "windows")]
self.try_sidecar_after_service_unavailable().await?;
return Ok(());
@@ -649,6 +659,8 @@ impl CoreManager {
// A failed start must not leave a locally reported running mode.
if result.is_err() {
self.rollback_failed_start().await;
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
return result;
}
@@ -737,11 +749,17 @@ impl CoreManager {
async fn stop_core_unprepared_inner(&self) -> Result<()> {
CLASH_LOGGER.clear_logs();
match *self.get_running_mode() {
let result = match *self.get_running_mode() {
RunningMode::Service => self.stop_core_by_service().await,
RunningMode::Sidecar => self.stop_core_by_sidecar_unprepared().await,
RunningMode::NotRunning => Ok(()),
};
// A Sidecar whose exit wait failed is already marked stopped; a core still running keeps its DNS.
#[cfg(target_os = "macos")]
if matches!(*self.get_running_mode(), RunningMode::NotRunning) {
crate::utils::resolve::dns::sync_public_dns().await;
}
result
}
#[tracing::instrument(skip_all, level = "info")]
+2
View File
@@ -393,6 +393,8 @@ impl CoreManager {
self.set_job_handle(None);
proxy_control::stop_guard().await;
self.core_stopped();
#[cfg(target_os = "macos")]
crate::utils::resolve::dns::sync_public_dns().await;
}
}
+7 -1
View File
@@ -1784,12 +1784,18 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) {
);
mark_service_unavailable_after_owner_loss(&RUN_STATE, reason);
proxy_control::stop_guard().await;
let policy = owner_recovery_policy(reason, cfg!(target_os = "macos"));
// Clear while still in Service mode with the session: on macOS it routes through the helper.
if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy {
if policy.reset_system_proxy {
clear_proxy_after_owner_loss().await;
}
clear_active_service_session();
CoreManager::global().core_stopped();
// A displaced Service may still run TUN for its new owner, and DNS settings are system-wide.
#[cfg(target_os = "macos")]
if policy.reset_system_proxy {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
async fn clear_proxy_after_owner_loss() {
-16
View File
@@ -1,8 +1,5 @@
use serde_yaml_ng::{Mapping, Value};
#[cfg(target_os = "macos")]
use crate::process::AsyncHandler;
macro_rules! revise {
($map: expr, $key: expr, $val: expr) => {
let ret_key = Value::String($key.into());
@@ -46,22 +43,9 @@ pub fn use_tun(mut config: Mapping, enable: bool) -> Mapping {
if ipv6_val && !dns_val.contains_key(Value::from("fake-ip-range6")) {
revise!(dns_val, "fake-ip-range6", "2001:2::0/64");
}
#[cfg(target_os = "macos")]
{
AsyncHandler::spawn(move || async move {
crate::utils::resolve::dns::restore_public_dns().await;
crate::utils::resolve::dns::set_public_dns("114.114.114.114".to_string()).await;
});
}
}
revise!(config, "dns", dns_val);
} else {
#[cfg(target_os = "macos")]
AsyncHandler::spawn(move || async move {
crate::utils::resolve::dns::restore_public_dns().await;
});
}
revise!(tun_val, "enable", enable);
+13
View File
@@ -55,6 +55,19 @@ pub async fn restart_app() {
if !cleanup_result.core_stopped {
handle::Handle::global().clear_is_exiting();
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
#[cfg(target_os = "macos")]
{
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
*manager.get_running_mode(),
crate::core::manager::RunningMode::NotRunning
) {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
handle::Handle::notice(
NoticeStatus::AppRestartCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
-15
View File
@@ -41,9 +41,6 @@ macro_rules! verge_registry {
$( $(#[$cfg])* { select_field!(effects, $field, $rule); } )*
effects
}
#[cfg(test)]
const VERGE_FIELDS: &[&str] = &[ $( $(#[$cfg])* stringify!($field), )* ];
};
}
@@ -165,18 +162,6 @@ pub(super) fn clash_effects(patch: &serde_yaml_ng::Mapping) -> Effects {
mod tests {
use super::*;
#[test]
fn every_serialized_verge_field_is_registered() -> Result<(), serde_json::Error> {
let fields: std::collections::BTreeMap<String, serde_json::Value> =
serde_json::from_value(serde_json::to_value(IVerge::default())?)?;
for field in fields.keys() {
assert!(VERGE_FIELDS.contains(&field.as_str()), "unregistered field: {field}");
}
let unique: BTreeSet<_> = VERGE_FIELDS.iter().collect();
assert_eq!(unique.len(), VERGE_FIELDS.len());
Ok(())
}
fn verge_patch(mutate: impl FnOnce(&mut IVerge)) -> IVerge {
let mut patch = IVerge::default();
mutate(&mut patch);
+23 -2
View File
@@ -82,16 +82,21 @@ where
async fn restore_dns_after_core_stop() -> bool {
#[cfg(target_os = "macos")]
// Outlasts the script's own deadline, so a hung restore is killed with its process group before exit.
match timeout(
Duration::from_millis(1000),
crate::utils::resolve::dns::DNS_SCRIPT_TIMEOUT + Duration::from_secs(1),
crate::utils::resolve::dns::restore_public_dns(),
)
.await
{
Ok(_) => {
Ok(Ok(())) => {
logging!(debug, Type::Window, "DNS设置已恢复");
true
}
Ok(Err(err)) => {
logging!(warn, Type::Window, "恢复DNS设置失败: {err:#}");
false
}
Err(_) => {
logging!(warn, Type::Window, "恢复DNS设置超时");
false
@@ -129,6 +134,19 @@ pub async fn quit() -> clash_verge_signal::ShutdownOutcome {
if should_abort_exit_after_cleanup(cleanup_result.core_stopped) {
handle::Handle::global().clear_is_exiting();
// A failed stop may still have marked the core stopped, and exit cleanup skipped the DNS restore.
// A core that kept running keeps its DNS, whatever an unapplied config draft says.
#[cfg(target_os = "macos")]
{
let manager = CoreManager::global();
let _lifecycle = manager.lifecycle_lock.lock().await;
if matches!(
*manager.get_running_mode(),
crate::core::manager::RunningMode::NotRunning
) {
crate::utils::resolve::dns::sync_public_dns().await;
}
}
handle::Handle::notice(
NoticeStatus::AppQuitCoreStopFailed,
cleanup_result.stop_error.unwrap_or_default(),
@@ -179,6 +197,9 @@ pub async fn clean_async() -> CleanupResult {
pub async fn clean_session_ending_best_effort() -> CleanupResult {
#[cfg(target_os = "windows")]
let stop_timeout = Duration::from_secs(2);
// TODO(macOS): a DNS script already blocked when the session ends holds the lifecycle lock for up to
// DNS_SCRIPT_TIMEOUT, past this deadline; the stop is cancelled, the DNS restore skipped, and the
// script can still write after the app exits.
#[cfg(not(target_os = "windows"))]
let stop_timeout = Duration::from_secs(3);
+124 -94
View File
@@ -1,123 +1,149 @@
use anyhow::Context as _;
use anyhow::{Context as _, bail, ensure};
use clash_verge_logging::{Type, logging};
#[cfg(target_os = "macos")]
use std::path::Path;
use std::path::PathBuf;
use serde_yaml_ng::{Mapping, Value};
use std::{
io::Read as _,
os::unix::process::CommandExt as _,
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
#[cfg(target_os = "macos")]
const DNS_STATE_FILE: &str = ".original_dns.txt";
const DNS_BACKUP_DIR: &str = ".original_dns";
// Any address routed into TUN works, since dns-hijack answers it; the value only matters if left behind.
const PLACEHOLDER_DNS: &str = "114.114.114.114";
// Scripts run under lifecycle_lock, and networksetup waits for other preference writers without a limit.
pub(crate) const DNS_SCRIPT_TIMEOUT: Duration = Duration::from_secs(5);
static DNS_LOCK: Mutex<()> = Mutex::const_new(());
fn dns_state_dir() -> anyhow::Result<PathBuf> {
// The DNS scripts persist .original_dns.txt relative to their working directory.
// The DNS scripts persist their backups relative to their working directory.
let dir = crate::utils::dirs::app_home_dir()?;
std::fs::create_dir_all(&dir).with_context(|| format!("failed to create DNS state directory {}", dir.display()))?;
Ok(dir)
}
#[cfg(target_os = "macos")]
fn restore_dns_state_dir(resource_dir: &Path, state_dir: PathBuf) -> PathBuf {
if resource_dir.join(DNS_STATE_FILE).exists() {
if resource_dir.join(DNS_STATE_FILE).exists() || resource_dir.join(DNS_BACKUP_DIR).exists() {
resource_dir.to_path_buf()
} else {
state_dir
}
}
pub async fn set_public_dns(dns_server: String) {
use crate::{core::handle, utils::dirs};
use tauri_plugin_shell::ShellExt as _;
let app_handle = handle::Handle::app_handle();
logging!(debug, Type::Config, "try to set system dns");
let resource_dir = match dirs::app_resources_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get resource directory: {}", e);
return;
}
// Without the placeholder, a LAN resolver is reached outside TUN and fake-ip is bypassed.
fn needs_public_dns(config: &Mapping) -> bool {
let section = |name: &str| config.get(name).and_then(Value::as_mapping);
let enabled = |name: &str| {
section(name)
.and_then(|map| map.get("enable"))
.and_then(Value::as_bool)
.unwrap_or(false)
};
let script = resource_dir.join("set_dns.sh");
if !script.exists() {
logging!(error, Type::Config, "DNS script not found: {}", script.display());
enabled("tun")
&& enabled("dns")
&& section("dns")
.and_then(|dns| dns.get("enhanced-mode"))
.and_then(Value::as_str)
== Some("fake-ip")
}
/// Sets the placeholder DNS while the running config needs it, and restores the system DNS otherwise.
pub async fn sync_public_dns() {
use crate::{
config::Config,
core::{CoreManager, handle::Handle, manager::RunningMode},
};
let _guard = DNS_LOCK.lock().await;
// Exit cleanup restores DNS on its own budget; a sync queued behind it must not undo that.
if Handle::global().is_exiting() {
return;
}
let script = script.to_string_lossy().into_owned();
let state_dir = match dns_state_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
return;
}
};
match app_handle
.shell()
.command("bash")
.args([script, dns_server])
.current_dir(state_dir)
.status()
.await
{
Ok(status) => {
if status.success() {
logging!(info, Type::Config, "set system dns successfully");
} else {
let code = status.code().unwrap_or(-1);
logging!(error, Type::Config, "set system dns failed: {code}");
}
}
Err(err) => {
logging!(error, Type::Config, "set system dns failed: {err}");
}
let core_running = !matches!(*CoreManager::global().get_running_mode(), RunningMode::NotRunning);
let runtime = Config::runtime().await.latest_arc();
if core_running && runtime.config.as_ref().is_some_and(needs_public_dns) {
set_public_dns().await;
} else {
let _ = unset_public_dns().await;
}
}
#[cfg(target_os = "macos")]
pub async fn restore_public_dns() {
use crate::{core::handle, utils::dirs};
use tauri_plugin_shell::ShellExt as _;
let app_handle = handle::Handle::app_handle();
pub async fn restore_public_dns() -> anyhow::Result<()> {
let _guard = DNS_LOCK.lock().await;
unset_public_dns().await
}
async fn set_public_dns() {
logging!(debug, Type::Config, "try to set system dns");
let result = async {
let script = crate::utils::dirs::app_resources_dir()?.join("set_dns.sh");
run_dns_script(&script, dns_state_dir()?).await
}
.await;
match result {
Ok(()) => logging!(info, Type::Config, "set system dns successfully"),
Err(err) => logging!(error, Type::Config, "set system dns failed: {err:#}"),
}
}
async fn unset_public_dns() -> anyhow::Result<()> {
logging!(debug, Type::Config, "try to unset system dns");
let resource_dir = match dirs::app_resources_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get resource directory: {}", e);
return;
}
};
let script = resource_dir.join("unset_dns.sh");
if !script.exists() {
logging!(error, Type::Config, "DNS script not found: {}", script.display());
return;
let result = async {
let resource_dir = crate::utils::dirs::app_resources_dir()?;
let state_dir = restore_dns_state_dir(&resource_dir, dns_state_dir()?);
run_dns_script(&resource_dir.join("unset_dns.sh"), state_dir).await
}
let script = script.to_string_lossy().into_owned();
let state_dir = match dns_state_dir() {
Ok(dir) => dir,
Err(e) => {
logging!(error, Type::Config, "Failed to get DNS state directory: {e:#}");
return;
}
};
let state_dir = restore_dns_state_dir(&resource_dir, state_dir);
match app_handle
.shell()
.command("bash")
.args([script])
.current_dir(state_dir)
.status()
.await
{
Ok(status) => {
if status.success() {
logging!(info, Type::Config, "unset system dns successfully");
} else {
let code = status.code().unwrap_or(-1);
logging!(error, Type::Config, "unset system dns failed: {code}");
.await;
match &result {
Ok(()) => logging!(info, Type::Config, "unset system dns successfully"),
Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"),
}
result
}
/// On timeout the script's whole process group is killed, so no networksetup it started writes late.
async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> {
ensure!(script.exists(), "DNS script not found: {}", script.display());
let mut child = Command::new("bash")
.arg(script)
.arg(PLACEHOLDER_DNS)
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.with_context(|| format!("failed to run {}", script.display()))?;
crate::process::AsyncHandler::spawn_blocking(move || {
let deadline = Instant::now() + DNS_SCRIPT_TIMEOUT;
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if Instant::now() >= deadline {
unsafe { libc::killpg(libc::pid_t::try_from(child.id())?, libc::SIGKILL) };
child.wait()?;
bail!("timed out after {} s", DNS_SCRIPT_TIMEOUT.as_secs());
}
std::thread::sleep(Duration::from_millis(20));
};
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
pipe.read_to_string(&mut stderr)?;
}
Err(err) => {
logging!(error, Type::Config, "unset system dns failed: {err}");
}
}
ensure!(
status.success(),
"exit code {}: {}",
status.code().unwrap_or(-1),
stderr.trim()
);
Ok(())
})
.await
.context("DNS script task failed")?
}
#[cfg(test)]
@@ -125,7 +151,6 @@ mod tests {
#[test]
#[allow(clippy::expect_used)]
#[cfg(target_os = "macos")]
fn restore_dns_state_dir_defaults_to_app_data_but_honors_legacy_file() {
use super::restore_dns_state_dir;
@@ -137,6 +162,11 @@ mod tests {
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), state_dir);
std::fs::write(resource_dir.join(".original_dns.txt"), "empty").expect("create legacy DNS state");
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), resource_dir);
// A migrated legacy backup whose restore failed must still be retried there.
std::fs::remove_file(resource_dir.join(".original_dns.txt")).expect("remove legacy DNS state");
std::fs::create_dir(resource_dir.join(".original_dns")).expect("create migrated DNS state");
assert_eq!(restore_dns_state_dir(&resource_dir, state_dir), resource_dir);
std::fs::remove_dir_all(root).expect("remove test directory");
+2 -1
View File
@@ -22,6 +22,7 @@ use crate::{
use clash_verge_logging::{Type, logging, logging_error};
use clash_verge_signal;
#[cfg(target_os = "macos")]
pub mod dns;
mod scheme;
pub(crate) mod window;
@@ -103,7 +104,7 @@ pub async fn resolve_reset_async() -> Result<(), anyhow::Error> {
#[cfg(target_os = "macos")]
{
use dns::restore_public_dns;
restore_public_dns().await;
restore_public_dns().await?;
}
Ok(())
-16
View File
@@ -218,20 +218,4 @@ mod tests {
assert_eq!(result, Err("terminal"));
assert_eq!(visited, [0, 1]);
}
#[test]
fn backoff_keeps_the_attempt_budget_and_exponential_cap() {
let policy = RetryPolicy {
attempts: NonZeroUsize::MIN.saturating_add(4),
backoff: Backoff::Exponential {
base: Duration::from_millis(10),
cap: Duration::from_millis(25),
},
jitter: false,
};
assert_eq!(
policy.delays().collect::<Vec<_>>(),
[10, 20, 25, 25].map(Duration::from_millis)
);
}
}
+170 -231
View File
@@ -13,10 +13,12 @@ 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 { saveProxyPorts } from '@/services/cmds'
import { type ProxyPortSettings, saveProxyPorts } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import getSystem from '@/utils/get-system'
const OS = getSystem()
@@ -26,35 +28,141 @@ interface ClashPortViewerRef {
close: () => void
}
type ListenerKey = Exclude<keyof ProxyPortSettings, 'mixedPort'>
const LISTENERS: {
key: ListenerKey
label: TranslationKey
visible: boolean
}[] = [
{
key: 'socks',
label: 'settings.modals.clashPort.fields.socks',
visible: true,
},
{
key: 'http',
label: 'settings.modals.clashPort.fields.http',
visible: true,
},
{
key: 'redir',
label: 'settings.modals.clashPort.fields.redir',
visible: OS !== 'windows',
},
{
key: 'tproxy',
label: 'settings.modals.clashPort.fields.tproxy',
visible: OS === 'linux',
},
]
const generateRandomPort = () =>
Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025
const isValidPort = (port: number) => port >= 1 && port <= 65535
const readPortSettings = (
verge: IVergeConfig | undefined,
mixedPort: number,
): ProxyPortSettings => ({
mixedPort,
socks: {
enabled: verge?.verge_socks_enabled ?? false,
port: verge?.verge_socks_port ?? 7898,
},
http: {
enabled: verge?.verge_http_enabled ?? false,
port: verge?.verge_port ?? 7899,
},
redir: {
enabled: verge?.verge_redir_enabled ?? false,
port: verge?.verge_redir_port ?? 7895,
},
tproxy: {
enabled: verge?.verge_tproxy_enabled ?? false,
port: verge?.verge_tproxy_port ?? 7896,
},
})
interface PortRowProps {
label: string
secondary?: string
port: number
enabled: boolean
onPortChange: (port: number) => void
// Without it the switch stays locked on, as the mixed port cannot be disabled.
onEnabledChange?: (enabled: boolean) => void
}
const PortRow = ({
label,
secondary,
port,
enabled,
onPortChange,
onEnabledChange,
}: PortRowProps) => {
const { t } = useTranslation()
return (
<ListItem sx={{ padding: '4px 0', minHeight: 36 }}>
<ListItemText
primary={label}
secondary={secondary}
slotProps={{
primary: { sx: { fontSize: 12 } },
secondary: { sx: { fontSize: 12 } },
}}
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={port}
onChange={(e) =>
onPortChange(+e.target.value?.replace(/\D+/, '').slice(0, 5))
}
disabled={!enabled}
slotProps={{ htmlInput: { style: { fontSize: 12 } } }}
/>
<IconButton
size="small"
onClick={() => onPortChange(generateRandomPort())}
title={t('settings.modals.clashPort.actions.random')}
disabled={!enabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={enabled}
disabled={!onEnabledChange}
onChange={(_, c) => onEnabledChange?.(c)}
sx={{ ml: 0.5, opacity: onEnabledChange ? undefined : 0.7 }}
/>
</div>
</ListItem>
)
}
export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
const { t } = useTranslation()
const { verge } = useVerge()
const { clashInfo } = useClashInfo()
const configuredMixedPort =
verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897
const displayedMixedPort = useDisplayedMixedPort()
const [open, setOpen] = useState(false)
const [ports, setPorts] = useState(() =>
readPortSettings(verge, configuredMixedPort),
)
// Mixed Port
const [mixedPort, setMixedPort] = useState(displayedMixedPort)
// 其他端口状态
const [socksPort, setSocksPort] = useState(verge?.verge_socks_port ?? 7898)
const [socksEnabled, setSocksEnabled] = useState(
verge?.verge_socks_enabled ?? false,
)
const [httpPort, setHttpPort] = useState(verge?.verge_port ?? 7899)
const [httpEnabled, setHttpEnabled] = useState(
verge?.verge_http_enabled ?? false,
)
const [redirPort, setRedirPort] = useState(verge?.verge_redir_port ?? 7895)
const [redirEnabled, setRedirEnabled] = useState(
verge?.verge_redir_enabled ?? false,
)
const [tproxyPort, setTproxyPort] = useState(verge?.verge_tproxy_port ?? 7896)
const [tproxyEnabled, setTproxyEnabled] = useState(
verge?.verge_tproxy_enabled ?? false,
)
const updateListener = (
key: ListenerKey,
patch: Partial<ProxyPortSettings[ListenerKey]>,
) => setPorts((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }))
// 添加保存请求,防止GUI卡死
const { loading, runAsync: saveSettings } = useRequest(saveProxyPorts, {
@@ -76,56 +184,33 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
useImperativeHandle(ref, () => ({
open: () => {
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)
setPorts(readPortSettings(verge, configuredMixedPort))
setOpen(true)
},
close: () => setOpen(false),
}))
// TODO 减少代码复杂度,性能开支
const onSave = useLockFn(async () => {
const listeners = LISTENERS.map(({ key }) => ports[key])
// The backend also rejects out-of-range ports on disabled listeners.
const allPorts = [ports.mixedPort, ...listeners.map(({ port }) => port)]
if (!allPorts.every(isValidPort)) {
showNotice.error('settings.modals.clashPort.messages.invalidPort')
return
}
// 端口冲突检测
const portList = [
mixedPort,
socksEnabled ? socksPort : -1,
httpEnabled ? httpPort : -1,
redirEnabled ? redirPort : -1,
tproxyEnabled ? tproxyPort : -1,
].filter((p) => p !== -1)
if (new Set(portList).size !== portList.length) {
const enabledPorts = [
ports.mixedPort,
...listeners.filter(({ enabled }) => enabled).map(({ port }) => port),
]
if (new Set(enabledPorts).size !== enabledPorts.length) {
showNotice.error('settings.modals.clashPort.messages.duplicatePort')
return
}
// 验证端口范围
const isValidPort = (port: number) => port >= 1 && port <= 65535
const allPortsValid = [
mixedPort,
socksEnabled ? socksPort : 0,
httpEnabled ? httpPort : 0,
redirEnabled ? redirPort : 0,
tproxyEnabled ? tproxyPort : 0,
].every((port) => port === 0 || isValidPort(port))
if (!allPortsValid) {
return
}
await saveSettings({
mixedPort,
socks: { enabled: socksEnabled, port: socksPort },
http: { enabled: httpEnabled, port: httpPort },
redir: { enabled: redirEnabled, port: redirPort },
tproxy: { enabled: tproxyEnabled, port: tproxyPort },
})
await saveSettings(ports)
})
return (
@@ -151,177 +236,31 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
onOk={onSave}
>
<List sx={{ width: '100%' }}>
<ListItem sx={{ padding: '4px 0', minHeight: 36 }}>
<ListItemText
primary={t('settings.modals.clashPort.fields.mixed')}
slotProps={{ primary: { sx: { fontSize: 12 } } }}
<PortRow
label={t('settings.modals.clashPort.fields.mixed')}
secondary={
displayedMixedPort !== configuredMixedPort
? t('settings.modals.clashPort.messages.runningPort', {
port: displayedMixedPort,
})
: undefined
}
port={ports.mixedPort}
enabled
onPortChange={(mixedPort) =>
setPorts((prev) => ({ ...prev, mixedPort }))
}
/>
{LISTENERS.filter(({ visible }) => visible).map(({ key, label }) => (
<PortRow
key={key}
label={t(label)}
port={ports[key].port}
enabled={ports[key].enabled}
onPortChange={(port) => updateListener(key, { port })}
onEnabledChange={(enabled) => updateListener(key, { enabled })}
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={mixedPort}
onChange={(e) =>
setMixedPort(+e.target.value?.replace(/\D+/, '').slice(0, 5))
}
slotProps={{ htmlInput: { style: { fontSize: 12 } } }}
/>
<IconButton
size="small"
onClick={() => setMixedPort(generateRandomPort())}
title={t('settings.modals.clashPort.actions.random')}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={true}
disabled={true}
sx={{ ml: 0.5, opacity: 0.7 }}
/>
</div>
</ListItem>
<ListItem sx={{ padding: '4px 0', minHeight: 36 }}>
<ListItemText
primary={t('settings.modals.clashPort.fields.socks')}
slotProps={{ primary: { sx: { fontSize: 12 } } }}
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={socksPort}
onChange={(e) =>
setSocksPort(+e.target.value?.replace(/\D+/, '').slice(0, 5))
}
disabled={!socksEnabled}
slotProps={{ htmlInput: { style: { fontSize: 12 } } }}
/>
<IconButton
size="small"
onClick={() => setSocksPort(generateRandomPort())}
title={t('settings.modals.clashPort.actions.random')}
disabled={!socksEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={socksEnabled}
onChange={(_, c) => setSocksEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
<ListItem sx={{ padding: '4px 0', minHeight: 36 }}>
<ListItemText
primary={t('settings.modals.clashPort.fields.http')}
slotProps={{ primary: { sx: { fontSize: 12 } } }}
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={httpPort}
onChange={(e) =>
setHttpPort(+e.target.value?.replace(/\D+/, '').slice(0, 5))
}
disabled={!httpEnabled}
slotProps={{ htmlInput: { style: { fontSize: 12 } } }}
/>
<IconButton
size="small"
onClick={() => setHttpPort(generateRandomPort())}
title={t('settings.modals.clashPort.actions.random')}
disabled={!httpEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={httpEnabled}
onChange={(_, c) => setHttpEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
{OS !== 'windows' && (
<ListItem sx={{ padding: '4px 0', minHeight: 36 }}>
<ListItemText
primary={t('settings.modals.clashPort.fields.redir')}
slotProps={{ primary: { sx: { fontSize: 12 } } }}
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={redirPort}
onChange={(e) =>
setRedirPort(+e.target.value?.replace(/\D+/, '').slice(0, 5))
}
disabled={!redirEnabled}
slotProps={{ htmlInput: { style: { fontSize: 12 } } }}
/>
<IconButton
size="small"
onClick={() => setRedirPort(generateRandomPort())}
title={t('settings.modals.clashPort.actions.random')}
disabled={!redirEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={redirEnabled}
onChange={(_, c) => setRedirEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
)}
{OS === 'linux' && (
<ListItem sx={{ padding: '4px 0', minHeight: 36 }}>
<ListItemText
primary={t('settings.modals.clashPort.fields.tproxy')}
slotProps={{ primary: { sx: { fontSize: 12 } } }}
/>
<div style={{ display: 'flex', alignItems: 'center' }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={tproxyPort}
onChange={(e) =>
setTproxyPort(+e.target.value?.replace(/\D+/, '').slice(0, 5))
}
disabled={!tproxyEnabled}
slotProps={{ htmlInput: { style: { fontSize: 12 } } }}
/>
<IconButton
size="small"
onClick={() => setTproxyPort(generateRandomPort())}
title={t('settings.modals.clashPort.actions.random')}
disabled={!tproxyEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={tproxyEnabled}
onChange={(_, c) => setTproxyEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
)}
))}
</List>
</BaseDialog>
)
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "المنفذ المستخدم حاليًا: {{port}}",
"duplicatePort": "يجب أن تستخدم المستمعات المفعّلة منافذ مختلفة",
"invalidPort": "أدخل منفذًا بين 1 و65535",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"automaticFallback": "المنفذ {{oldPort}} مستخدم، لذا تعمل هذه الجلسة على {{newPort}}. لم يتغيّر إعدادك وسيُعاد تجربة {{oldPort}} عند التشغيل التالي.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "Aktuell verwendeter Port: {{port}}",
"duplicatePort": "Aktivierte Listener müssen unterschiedliche Ports verwenden",
"invalidPort": "Geben Sie einen Port zwischen 1 und 65535 ein",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"automaticFallback": "Port {{oldPort}} ist belegt, daher läuft diese Sitzung auf {{newPort}}. Die Einstellung bleibt unverändert und {{oldPort}} wird beim nächsten Start erneut versucht.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "Currently using port {{port}}",
"duplicatePort": "Enabled listeners must use different ports",
"invalidPort": "Enter a port between 1 and 65535",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"automaticFallback": "Port {{oldPort}} was in use, so this session runs on {{newPort}}. Your setting is unchanged and {{oldPort}} is retried on the next start.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "Puerto actual: {{port}}",
"duplicatePort": "Los servicios de escucha habilitados deben usar puertos diferentes",
"invalidPort": "Introduce un puerto entre 1 y 65535",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"automaticFallback": "El puerto {{oldPort}} está en uso, por lo que esta sesión usa {{newPort}}. Tu configuración no ha cambiado y se volverá a intentar {{oldPort}} en el próximo inicio.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "درگاه فعلی: {{port}}",
"duplicatePort": "شنونده‌های فعال باید از درگاه‌های متفاوت استفاده کنند",
"invalidPort": "شمارهٔ درگاهی بین 1 و 65535 وارد کنید",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"automaticFallback": "پورت {{oldPort}} در حال استفاده بود، بنابراین این اجرا از {{newPort}} استفاده می‌کند. تنظیم شما تغییر نکرده و در اجرای بعدی {{oldPort}} دوباره امتحان می‌شود.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "Port yang sedang digunakan: {{port}}",
"duplicatePort": "Listener yang diaktifkan harus menggunakan port yang berbeda",
"invalidPort": "Masukkan port antara 1 dan 65535",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"automaticFallback": "Port {{oldPort}} sedang digunakan, jadi sesi ini menggunakan {{newPort}}. Pengaturan Anda tidak berubah dan {{oldPort}} akan dicoba lagi saat mulai berikutnya.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "現在使用中のポート:{{port}}",
"duplicatePort": "有効なリスナーには異なるポートを指定してください",
"invalidPort": "1〜65535のポート番号を入力してください",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"automaticFallback": "ポート {{oldPort}} が使用中のため、今回は {{newPort}} で動作します。設定は変更されておらず、次回起動時に {{oldPort}} を再試行します。",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "현재 사용 중인 포트: {{port}}",
"duplicatePort": "활성화된 리스너는 서로 다른 포트를 사용해야 합니다",
"invalidPort": "1에서 65535 사이의 포트 번호를 입력하세요",
"saved": "포트 설정이 저장되었습니다",
"saveFailed": "포트 설정 저장에 실패했습니다",
"automaticFallback": "포트 {{oldPort}}이(가) 사용 중이어서 이번 실행은 {{newPort}}을(를) 사용합니다. 설정은 변경되지 않았으며 다음 시작 시 {{oldPort}}을(를) 다시 시도합니다.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Порт {{port}} уже используется",
"runningPort": "Сейчас используется порт {{port}}",
"duplicatePort": "Включённые слушатели должны использовать разные порты",
"invalidPort": "Введите порт от 1 до 65535",
"saved": "Настройки портов сохранены",
"saveFailed": "Не удалось сохранить настройки портов",
"automaticFallback": "Порт {{oldPort}} занят, поэтому этот запуск использует {{newPort}}. Настройка не изменена, при следующем запуске {{oldPort}} будет опробован снова.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "{{port}} portu zaten kullanımda",
"runningPort": "Şu anda kullanılan port: {{port}}",
"duplicatePort": "Etkin dinleyiciler farklı portlar kullanmalıdır",
"invalidPort": "1 ile 65535 arasında bir port girin",
"saved": "Port ayarları kaydedildi",
"saveFailed": "Port ayarları kaydedilemedi",
"automaticFallback": "{{oldPort}} portu kullanımda olduğu için bu oturum {{newPort}} portunu kullanıyor. Ayarınız değişmedi ve sonraki başlatmada {{oldPort}} yeniden denenecek.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "Хәзер {{port}} порты кулланыла",
"duplicatePort": "Кабызылган тыңлаучылар төрле портлар кулланырга тиеш",
"invalidPort": "1 белән 65535 арасындагы порт номерын кертегез",
"saved": "Port settings saved",
"saveFailed": "Failed to save port settings",
"automaticFallback": "Port {{oldPort}} was in use, so this session runs on {{newPort}}. Your setting is unchanged and {{oldPort}} is retried on the next start.",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "端口 {{port}} 已被占用",
"runningPort": "当前运行端口:{{port}}",
"duplicatePort": "已启用的监听端口不能重复",
"invalidPort": "请输入 1 至 65535 之间的端口号",
"saved": "端口设置已保存",
"saveFailed": "端口设置保存失败",
"automaticFallback": "端口 {{oldPort}} 已被占用,本次运行改用 {{newPort}}。设置未更改,下次启动会重新尝试 {{oldPort}}。",
+3
View File
@@ -290,6 +290,9 @@
},
"messages": {
"portInUse": "Port {{port}} is already in use",
"runningPort": "目前執行中的連接埠:{{port}}",
"duplicatePort": "已啟用的監聽連接埠不能重複",
"invalidPort": "請輸入 1 至 65535 之間的連接埠號碼",
"saved": "連結埠設定已儲存",
"saveFailed": "連結埠設定儲存失敗",
"automaticFallback": "連結埠 {{oldPort}} 已被佔用,本次執行改用 {{newPort}}。設定未變更,下次啟動會重新嘗試 {{oldPort}}。",
+3
View File
@@ -571,6 +571,9 @@ export const translationKeys = [
'settings.modals.clashPort.fields.tproxy',
'settings.modals.clashPort.actions.random',
'settings.modals.clashPort.messages.portInUse',
'settings.modals.clashPort.messages.runningPort',
'settings.modals.clashPort.messages.duplicatePort',
'settings.modals.clashPort.messages.invalidPort',
'settings.modals.clashPort.messages.saved',
'settings.modals.clashPort.messages.saveFailed',
'settings.modals.clashPort.messages.automaticFallback',
+3
View File
@@ -962,9 +962,12 @@ export interface TranslationResources {
messages: {
automaticFallback: string
automaticFallbackFailed: string
duplicatePort: string
invalidPort: string
portInUse: string
portTooHigh: string
portTooLow: string
runningPort: string
saved: string
saveFailed: string
}