diff --git a/Changelog.md b/Changelog.md index 563cbc13d..a7add527d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,6 +3,7 @@
🐞 修复问题 +- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 - 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 - 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 @@ -11,6 +12,10 @@ - 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 - 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复 +**🍎 macOS** + +- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 +
@@ -22,6 +27,7 @@
🚀 优化改进 +- 优化端口设置窗口的实现,精简重复代码 - 优化侧边栏流量图表的 CPU 占用 **🖥️ Windows** diff --git a/README.md b/README.md index f76d4138c..2a65c2e6b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A Clash Meta GUI based on Tauri Go to the [Release page](https://github.com/clash-verge-rev/clash-verge-rev/releases) to download the corresponding installation package
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访问** diff --git a/scripts/set_dns.sh b/scripts/set_dns.sh index c3e03e209..2b3469fcc 100644 --- a/scripts/set_dns.sh +++ b/scripts/set_dns.sh @@ -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 " && 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 diff --git a/scripts/unset_dns.sh b/scripts/unset_dns.sh index 405809f80..03f925e7d 100644 --- a/scripts/unset_dns.sh +++ b/scripts/unset_dns.sh @@ -1,13 +1,62 @@ #!/bin/bash -nic=$(route -n get default | grep "interface" | awk '{print $2}') +[ $# -lt 1 ] && echo "Usage: $0 " && 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 diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index 619a29403..437b6daf1 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -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) } diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index c3f366375..e7366aec3 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -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")] diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index 8994e2aa5..5a7aadece 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -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; } } diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 095938231..a65fe3c21 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -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() { diff --git a/src-tauri/src/enhance/tun.rs b/src-tauri/src/enhance/tun.rs index ad7c7183f..f5d1af7ab 100644 --- a/src-tauri/src/enhance/tun.rs +++ b/src-tauri/src/enhance/tun.rs @@ -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); diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 4efe74714..1b261e1cb 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -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(), diff --git a/src-tauri/src/feat/effects.rs b/src-tauri/src/feat/effects.rs index 93c6a0b26..93177df3b 100644 --- a/src-tauri/src/feat/effects.rs +++ b/src-tauri/src/feat/effects.rs @@ -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 = - 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); diff --git a/src-tauri/src/feat/window.rs b/src-tauri/src/feat/window.rs index e22d4eca8..733743bac 100644 --- a/src-tauri/src/feat/window.rs +++ b/src-tauri/src/feat/window.rs @@ -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); diff --git a/src-tauri/src/utils/resolve/dns.rs b/src-tauri/src/utils/resolve/dns.rs index 35e53be1e..0a27acfaa 100644 --- a/src-tauri/src/utils/resolve/dns.rs +++ b/src-tauri/src/utils/resolve/dns.rs @@ -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 { - // 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"); diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 696931257..f96939eb8 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -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(()) diff --git a/src-tauri/src/utils/retry.rs b/src-tauri/src/utils/retry.rs index b34373d86..5becccac1 100644 --- a/src-tauri/src/utils/retry.rs +++ b/src-tauri/src/utils/retry.rs @@ -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::>(), - [10, 20, 25, 25].map(Duration::from_millis) - ); - } } diff --git a/src/components/setting/mods/clash-port-viewer.tsx b/src/components/setting/mods/clash-port-viewer.tsx index d45d7ff9a..e0ffbf6f7 100644 --- a/src/components/setting/mods/clash-port-viewer.tsx +++ b/src/components/setting/mods/clash-port-viewer.tsx @@ -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 + +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 ( + + +
+ + onPortChange(+e.target.value?.replace(/\D+/, '').slice(0, 5)) + } + disabled={!enabled} + slotProps={{ htmlInput: { style: { fontSize: 12 } } }} + /> + onPortChange(generateRandomPort())} + title={t('settings.modals.clashPort.actions.random')} + disabled={!enabled} + sx={{ mr: 0.5 }} + > + + + onEnabledChange?.(c)} + sx={{ ml: 0.5, opacity: onEnabledChange ? undefined : 0.7 }} + /> +
+
+ ) +} + export const ClashPortViewer = forwardRef((_, 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, + ) => setPorts((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })) // 添加保存请求,防止GUI卡死 const { loading, runAsync: saveSettings } = useRequest(saveProxyPorts, { @@ -76,56 +184,33 @@ export const ClashPortViewer = forwardRef((_, 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((_, ref) => { onOk={onSave} > - - + setPorts((prev) => ({ ...prev, mixedPort })) + } + /> + {LISTENERS.filter(({ visible }) => visible).map(({ key, label }) => ( + updateListener(key, { port })} + onEnabledChange={(enabled) => updateListener(key, { enabled })} /> -
- - setMixedPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setMixedPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - sx={{ mr: 0.5 }} - > - - - -
-
- - - -
- - setSocksPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - disabled={!socksEnabled} - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setSocksPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - disabled={!socksEnabled} - sx={{ mr: 0.5 }} - > - - - setSocksEnabled(c)} - sx={{ ml: 0.5 }} - /> -
-
- - - -
- - setHttpPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - disabled={!httpEnabled} - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setHttpPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - disabled={!httpEnabled} - sx={{ mr: 0.5 }} - > - - - setHttpEnabled(c)} - sx={{ ml: 0.5 }} - /> -
-
- - {OS !== 'windows' && ( - - -
- - setRedirPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - disabled={!redirEnabled} - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setRedirPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - disabled={!redirEnabled} - sx={{ mr: 0.5 }} - > - - - setRedirEnabled(c)} - sx={{ ml: 0.5 }} - /> -
-
- )} - - {OS === 'linux' && ( - - -
- - setTproxyPort(+e.target.value?.replace(/\D+/, '').slice(0, 5)) - } - disabled={!tproxyEnabled} - slotProps={{ htmlInput: { style: { fontSize: 12 } } }} - /> - setTproxyPort(generateRandomPort())} - title={t('settings.modals.clashPort.actions.random')} - disabled={!tproxyEnabled} - sx={{ mr: 0.5 }} - > - - - setTproxyEnabled(c)} - sx={{ ml: 0.5 }} - /> -
-
- )} + ))}
) diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 456b10271..a92d77c6d 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -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}} عند التشغيل التالي.", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 0498d2d0e..a31ec24ff 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -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.", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index c614ef0b7..83fc780c8 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -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.", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 2a3ba8c6b..4d00690cd 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -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.", diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 3332e07bd..573c5c2c3 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -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}} دوباره امتحان می‌شود.", diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index d523c89c4..e7d425c08 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -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.", diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 1259f1409..6a8530aa4 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -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}} を再試行します。", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 9c60c69db..241f4f9fa 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -290,6 +290,9 @@ }, "messages": { "portInUse": "Port {{port}} is already in use", + "runningPort": "현재 사용 중인 포트: {{port}}", + "duplicatePort": "활성화된 리스너는 서로 다른 포트를 사용해야 합니다", + "invalidPort": "1에서 65535 사이의 포트 번호를 입력하세요", "saved": "포트 설정이 저장되었습니다", "saveFailed": "포트 설정 저장에 실패했습니다", "automaticFallback": "포트 {{oldPort}}이(가) 사용 중이어서 이번 실행은 {{newPort}}을(를) 사용합니다. 설정은 변경되지 않았으며 다음 시작 시 {{oldPort}}을(를) 다시 시도합니다.", diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 6111fd6a9..e8541bdd8 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -290,6 +290,9 @@ }, "messages": { "portInUse": "Порт {{port}} уже используется", + "runningPort": "Сейчас используется порт {{port}}", + "duplicatePort": "Включённые слушатели должны использовать разные порты", + "invalidPort": "Введите порт от 1 до 65535", "saved": "Настройки портов сохранены", "saveFailed": "Не удалось сохранить настройки портов", "automaticFallback": "Порт {{oldPort}} занят, поэтому этот запуск использует {{newPort}}. Настройка не изменена, при следующем запуске {{oldPort}} будет опробован снова.", diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 958cd04a4..262f730c0 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -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.", diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 1bb591f7c..774b90f0b 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -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.", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 7316ef9b7..38e37127d 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -290,6 +290,9 @@ }, "messages": { "portInUse": "端口 {{port}} 已被占用", + "runningPort": "当前运行端口:{{port}}", + "duplicatePort": "已启用的监听端口不能重复", + "invalidPort": "请输入 1 至 65535 之间的端口号", "saved": "端口设置已保存", "saveFailed": "端口设置保存失败", "automaticFallback": "端口 {{oldPort}} 已被占用,本次运行改用 {{newPort}}。设置未更改,下次启动会重新尝试 {{oldPort}}。", diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index 92fe5138f..35e956b28 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -290,6 +290,9 @@ }, "messages": { "portInUse": "Port {{port}} is already in use", + "runningPort": "目前執行中的連接埠:{{port}}", + "duplicatePort": "已啟用的監聽連接埠不能重複", + "invalidPort": "請輸入 1 至 65535 之間的連接埠號碼", "saved": "連結埠設定已儲存", "saveFailed": "連結埠設定儲存失敗", "automaticFallback": "連結埠 {{oldPort}} 已被佔用,本次執行改用 {{newPort}}。設定未變更,下次啟動會重新嘗試 {{oldPort}}。", diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 508041d6e..8a7b52465 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -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', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index b70018fa1..f238585f1 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -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 }