diff --git a/Changelog.md b/Changelog.md
index d6db6c761..e1812bc00 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -27,6 +27,11 @@
- 修复检测更新后又发布新版时,应用内更新失败
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
+**🍎 macOS**
+
+- 修复 macOS TUN 模式下原有 DNS 被清除、关闭后 DNS 未恢复及联网后设置未生效
+- 修复 macOS 断网时误报代理已回退直连,恢复联网后自动重试系统代理
+
@@ -40,4 +45,8 @@
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
+**🍎 macOS**
+
+- 优化 macOS TUN 模式的系统 DNS,增加阿里和 Cloudflare 地址
+
diff --git a/scripts/set_dns.sh b/scripts/set_dns.sh
index 2b3469fcc..be836cf6f 100644
--- a/scripts/set_dns.sh
+++ b/scripts/set_dns.sh
@@ -32,7 +32,9 @@ function is_valid_ip() {
# 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"
+ local dictionary
+ dictionary=$(printf 'show %s\nquit\n' "$1" | scutil) || return 1
+ sed -n "s/^ *$2 : //p" <<<"$dictionary"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
@@ -50,31 +52,62 @@ function dns_of() {
echo "$dns"
}
-[ $# -lt 1 ] && echo "Usage: $0 " && exit 1
-! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1
+[ $# -lt 1 ] && echo "Usage: $0 [legacy state directory]" >&2 && exit 1
+dns_servers=()
+while read -r server; do
+ ! is_valid_ip "$server" && echo "$server is not a valid IP address." >&2 && exit 1
+ dns_servers+=("$server")
+done <<<"$1"
# 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
+service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) || exit 1
+hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName) || exit 1
+[ -z "$hardware_port" ] && echo "No primary network service; waiting for the network." >&2 && exit 75
original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; }
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
+# Key old single-slot backups before import so a later network switch cannot reassign them.
+for state_dir in "$PWD" "${2:-$PWD}"; do
+ if [ -f "$state_dir/.original_dns.txt" ]; then
+ mkdir -p "$state_dir/.original_dns" || exit 1
+ if [ -f "$state_dir/.original_dns/$service_id" ]; then
+ rm -f "$state_dir/.original_dns.txt" || exit 1
+ else
+ mv "$state_dir/.original_dns.txt" "$state_dir/.original_dns/$service_id" || exit 1
+ fi
fi
+done
+if [ ! -f "$backup" ] && [ -n "$2" ] && [ -f "$2/$backup" ]; then
+ cp "$2/$backup" ".original_dns/.$service_id" || exit 1
+ mv ".original_dns/.$service_id" "$backup" || exit 1
+fi
+backup_header='# clash-verge-dns-managed'
+function save_backup() {
+ printf '%s\n%s\n' "$backup_header" "$1" >".original_dns/.$service_id" &&
+ mv ".original_dns/.$service_id" "$backup"
+}
+
+saved_dns=
+legacy_backup=false
+if [ -f "$backup" ]; then
+ saved_dns=$(cat "$backup") || exit 1
+ case "$saved_dns" in
+ "$backup_header"$'\n'*) saved_dns=${saved_dns#*$'\n'} ;;
+ *) legacy_backup=true ;;
+ esac
fi
-# 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"
+ # Only an unmarked historical backup owns the old single-address placeholder.
+ if [ "$original_dns" != "114.114.114.114" ] || [ "$legacy_backup" != true ]; then
+ save_backup "$original_dns" || exit 1
+ legacy_backup=false
+ fi
+ networksetup -setdnsservers "$hardware_port" "${dns_servers[@]}"
[ "$(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
+# Keep legacy ownership until the new list is verified, including a retry after a failed marker write.
+if [ "$legacy_backup" = true ]; then
+ save_backup "$saved_dns" || exit 1
fi
diff --git a/scripts/unset_dns.sh b/scripts/unset_dns.sh
index 03f925e7d..29b8e9543 100644
--- a/scripts/unset_dns.sh
+++ b/scripts/unset_dns.sh
@@ -1,9 +1,11 @@
#!/bin/bash
-[ $# -lt 1 ] && echo "Usage: $0 " && exit 1
+[ $# -lt 1 ] && echo "Usage: $0 [legacy state directory]" >&2 && exit 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"
+ local dictionary
+ dictionary=$(printf 'show %s\nquit\n' "$1" | scutil) || return 1
+ sed -n "s/^ *$2 : //p" <<<"$dictionary"
}
# Prints the service's DNS servers, or "empty" when none is set; fails on any other answer.
@@ -21,42 +23,69 @@ function dns_of() {
echo "$dns"
}
-result=0
-# Older releases kept a single backup for whichever service was primary.
-if [ -f .original_dns.txt ]; then
- 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
+backup_header='# clash-verge-dns-managed'
+completed_backups=()
-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
+function restore_dns() {
+ local result=0
+ # Older releases kept a single backup for whichever service was primary.
+ if [ -f .original_dns.txt ]; then
+ 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
- # 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
+
+ for backup in .original_dns/*; do
+ [ -f "$backup" ] || continue
+ # Current backups take precedence even after their DNS has already been restored.
+ if [ "$PWD" != "$state_dir" ] && [ -f "$state_dir/$backup" ]; then
+ completed_backups+=("$PWD/$backup")
+ continue
+ fi
+ hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName) || { result=1; continue; }
+ # 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
- fi
- rm -f "$backup" || result=1
-done
-rmdir .original_dns 2>/dev/null
+ original_dns=$(cat "$backup") || { result=1; continue; }
+ legacy_backup=false
+ case "$original_dns" in
+ "$backup_header"$'\n'*) original_dns=${original_dns#*$'\n'} ;;
+ *) legacy_backup=true ;;
+ esac
+ # A service no longer on its recorded placeholder was changed by someone else.
+ if [ "$current_dns" = "$1" ] || { [ "$legacy_backup" = true ] && [ "$current_dns" = "114.114.114.114" ]; }; then
+ 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
+ completed_backups+=("$PWD/$backup")
+ done
+ return $result
+}
+
+result=0
+state_dir=$PWD
+restore_dns "$1" || result=1
+if [ -n "$2" ] && [ "$2" != "$PWD" ]; then
+ { cd "$2" && restore_dns "$1"; } || result=1
+fi
+# Remove superseded legacy backups first; a cleanup failure must leave current authority intact.
+if [ "$result" -eq 0 ]; then
+ for ((index=${#completed_backups[@]}-1; index>=0; index--)); do
+ rm -f "${completed_backups[index]}" || { result=1; break; }
+ done
+fi
+rmdir "$state_dir/.original_dns" "${2:-$state_dir}/.original_dns" 2>/dev/null
exit $result
diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs
index 62a8776b4..7fa46c610 100644
--- a/src-tauri/src/core/manager/lifecycle.rs
+++ b/src-tauri/src/core/manager/lifecycle.rs
@@ -559,12 +559,13 @@ impl CoreManager {
})?;
#[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.
+ // Only automatic restores can wait for the watcher; explicit requests must report failure.
#[cfg(target_os = "macos")]
- if crate::core::network_watch::is_armed()
- && !crate::utils::resolve::is_resolve_done()
+ let defer_until_network = crate::core::network_watch::is_armed()
+ && crate::core::notification::what_was_asked()
+ == crate::core::notification::FailedOperation::SystemProxyRestore;
+ #[cfg(target_os = "macos")]
+ if defer_until_network
&& Config::verge()
.await
.latest_arc()
@@ -579,7 +580,18 @@ impl CoreManager {
);
return Ok(());
}
- proxy_control::apply().await?;
+ if let Err(error) = proxy_control::apply().await {
+ #[cfg(target_os = "macos")]
+ if defer_until_network && proxy_control::is_missing_network_service_failure(&error) {
+ logging!(
+ debug,
+ Type::Core,
+ "network service disappeared; the system proxy waits for its return"
+ );
+ return Ok(());
+ }
+ return Err(error);
+ }
if !expectation.is_valid(
self.get_running_mode().as_ref(),
self.current_core_readiness_generation(),
diff --git a/src-tauri/src/core/network_watch.rs b/src-tauri/src/core/network_watch.rs
index 1cfa36139..eca89604b 100644
--- a/src-tauri/src/core/network_watch.rs
+++ b/src-tauri/src/core/network_watch.rs
@@ -49,6 +49,7 @@ async fn reapply() {
.enable_system_proxy
.unwrap_or_default()
{
+ crate::utils::resolve::dns::sync_public_dns().await;
return;
}
if !proxy_control::has_network_service().await {
diff --git a/src-tauri/src/core/proxy_control.rs b/src-tauri/src/core/proxy_control.rs
index 19bc09456..097c7a840 100644
--- a/src-tauri/src/core/proxy_control.rs
+++ b/src-tauri/src/core/proxy_control.rs
@@ -180,6 +180,14 @@ pub const fn is_missing_network_service(_error: &sysproxy::Error) -> bool {
false
}
+pub fn is_missing_network_service_failure(error: &anyhow::Error) -> bool {
+ !SystemProxyStateUnknown::is_in(error)
+ && SysproxyFailure::from_chain(error).is_none()
+ && error
+ .downcast_ref::()
+ .is_some_and(is_missing_network_service)
+}
+
/// Distinguish a misplaced sidecar from a missing privileged service.
async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error {
if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) {
@@ -359,6 +367,13 @@ fn service_apply_result(requested: &MacosProxyConfig, outcome: ProxyApplyOutcome
ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()),
// Direct fallback satisfies a disable request.
ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()),
+ // Older helpers encode a failed service lookup as fallback even when no OS write occurred.
+ #[cfg(target_os = "macos")]
+ ProxyApplyOutcome::DirectFallback { message }
+ if message == sysproxy::Error::NoActiveNetworkService.to_string() =>
+ {
+ Err(sysproxy::Error::NoActiveNetworkService.into())
+ }
ProxyApplyOutcome::DirectFallback { message } => {
Err(SysproxyFailure::DirectFallback { detail: message }.into())
}
@@ -609,6 +624,10 @@ pub async fn refresh_guard() -> Result<()> {
logging!(debug, Type::Core, "proxy guard superseded (generation {generation})");
break;
}
+ Err(error) if is_missing_network_service_failure(&error) => {
+ consecutive_failures = 0;
+ logging!(debug, Type::Core, "system proxy guard waits for a network service");
+ }
Err(error) => {
consecutive_failures += 1;
logging!(
@@ -1110,10 +1129,11 @@ mod tests {
.err()
.unwrap_or_else(|| anyhow::anyhow!("an enable that never reached the OS must fail"));
- assert_eq!(
- SysproxyFailure::from_chain(&error).map(SysproxyFailure::code),
- Some("SYSPROXY_DIRECT_FALLBACK")
- );
+ assert!(super::is_missing_network_service_failure(&error));
+ assert!(SysproxyFailure::from_chain(&error).is_none());
+ assert!(!super::is_missing_network_service_failure(
+ &error.context(SystemProxyStateUnknown)
+ ));
}
#[cfg(target_os = "macos")]
diff --git a/src-tauri/src/utils/resolve/dns.rs b/src-tauri/src/utils/resolve/dns.rs
index 0a27acfaa..ac1b1bda1 100644
--- a/src-tauri/src/utils/resolve/dns.rs
+++ b/src-tauri/src/utils/resolve/dns.rs
@@ -4,16 +4,14 @@ use serde_yaml_ng::{Mapping, Value};
use std::{
io::Read as _,
os::unix::process::CommandExt as _,
- path::{Path, PathBuf},
+ path::PathBuf,
process::{Command, Stdio},
time::{Duration, Instant},
};
use tokio::sync::Mutex;
-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";
+const PLACEHOLDER_DNS: &str = "114.114.114.114\n223.5.5.5\n1.1.1.1";
// 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);
@@ -26,14 +24,6 @@ fn dns_state_dir() -> anyhow::Result {
Ok(dir)
}
-fn restore_dns_state_dir(resource_dir: &Path, state_dir: PathBuf) -> PathBuf {
- if resource_dir.join(DNS_STATE_FILE).exists() || resource_dir.join(DNS_BACKUP_DIR).exists() {
- resource_dir.to_path_buf()
- } else {
- state_dir
- }
-}
-
// 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);
@@ -79,25 +69,19 @@ pub async fn restore_public_dns() -> anyhow::Result<()> {
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;
+ let result = run_dns_script("set_dns.sh", true).await;
match result {
- Ok(()) => logging!(info, Type::Config, "set system dns successfully"),
+ Ok(DnsScriptOutcome::Complete) => logging!(info, Type::Config, "set system dns successfully"),
+ Ok(DnsScriptOutcome::Deferred) => {
+ logging!(debug, Type::Config, "system DNS waits for a primary network service")
+ }
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 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
- }
- .await;
+ let result = run_dns_script("unset_dns.sh", false).await.map(|_| ());
match &result {
Ok(()) => logging!(info, Type::Config, "unset system dns successfully"),
Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"),
@@ -105,12 +89,21 @@ async fn unset_public_dns() -> anyhow::Result<()> {
result
}
+enum DnsScriptOutcome {
+ Complete,
+ Deferred,
+}
+
/// 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<()> {
+async fn run_dns_script(script_name: &str, allow_deferred: bool) -> anyhow::Result {
+ let resource_dir = crate::utils::dirs::app_resources_dir()?;
+ let script = resource_dir.join(script_name);
+ let dir = dns_state_dir()?;
ensure!(script.exists(), "DNS script not found: {}", script.display());
let mut child = Command::new("bash")
- .arg(script)
+ .arg(&script)
.arg(PLACEHOLDER_DNS)
+ .arg(resource_dir)
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::piped())
@@ -134,41 +127,18 @@ async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> {
if let Some(mut pipe) = child.stderr.take() {
pipe.read_to_string(&mut stderr)?;
}
+ // EX_TEMPFAIL is reserved by set_dns.sh for a network service that is not ready.
+ if allow_deferred && status.code() == Some(75) {
+ return Ok(DnsScriptOutcome::Deferred);
+ }
ensure!(
status.success(),
"exit code {}: {}",
status.code().unwrap_or(-1),
stderr.trim()
);
- Ok(())
+ Ok(DnsScriptOutcome::Complete)
})
.await
.context("DNS script task failed")?
}
-
-#[cfg(test)]
-mod tests {
-
- #[test]
- #[allow(clippy::expect_used)]
- fn restore_dns_state_dir_defaults_to_app_data_but_honors_legacy_file() {
- use super::restore_dns_state_dir;
-
- let root = std::env::temp_dir().join(format!("clash-verge-dns-{}", nanoid::nanoid!()));
- let resource_dir = root.join("resources");
- let state_dir = root.join("app-data");
- std::fs::create_dir_all(&resource_dir).expect("create test resource directory");
-
- 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");
- }
-}