diff --git a/Changelog.md b/Changelog.md index 1d0b13ffb..a7add527d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -12,6 +12,10 @@ - 修复 Windows 升级后因文件权限异常无法启动、需要手动清理配置的问题 - 修复 Windows 服务启动类型被改为手动后,软件启动卡住约两分钟的问题,并提供一键修复 +**🍎 macOS** + +- 修复 macOS 关闭 TUN 或退出后,系统 DNS 仍停留在 `114.114.114.114` 导致无法解析域名的问题 +
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 2f3e7f7f0..50f56f03d 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -311,6 +311,12 @@ impl CoreManager { let run_path = Config::write_runtime_file(&yaml).await?; self.apply_config(run_path).await?; transaction.commit(); + // 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 30cc45ede..4ea262b64 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 346485ac7..733c6b1d9 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -394,6 +394,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 2205e787a..2d90890dd 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -1761,12 +1761,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 1bd8da356..c01f799af 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -53,6 +53,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_message( "app_restart::core_stop_failed", cleanup_result.stop_error.unwrap_or_default(), diff --git a/src-tauri/src/feat/window.rs b/src-tauri/src/feat/window.rs index 0c13b3d1b..d07fd0971 100644 --- a/src-tauri/src/feat/window.rs +++ b/src-tauri/src/feat/window.rs @@ -81,16 +81,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 @@ -128,6 +133,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_message( "app_quit::core_stop_failed", cleanup_result.stop_error.unwrap_or_default(), @@ -178,6 +196,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 49cd25edb..5a1a78ea5 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -21,6 +21,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; @@ -102,7 +103,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(())