diff --git a/src-tauri/src/cmd/clash.rs b/src-tauri/src/cmd/clash.rs index 4b70b136c..e60da59a8 100644 --- a/src-tauri/src/cmd/clash.rs +++ b/src-tauri/src/cmd/clash.rs @@ -127,6 +127,18 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { Ok(()) } +#[tauri::command] +pub fn take_dns_override_notice() -> bool { + crate::config::dns::take_dns_override_notice() +} + +#[tauri::command] +pub async fn set_dns_override(enabled: bool, confirmation: Option) -> CmdResult { + feat::set_dns_override(enabled, confirmation) + .await + .map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED")) +} + #[tauri::command] pub async fn apply_dns_config(apply: bool) -> CmdResult { if apply { @@ -169,6 +181,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { logging!(info, Type::Config, "Config regenerated successfully"); } + logging_error!(Type::Config, Config::sync_dns_override().await); handle::Handle::refresh_clash(); Ok(()) } diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index dcdd4b12b..2d790e7a3 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -180,6 +180,10 @@ pub async fn delete_profile(index: String) -> CmdResult { drop(config_update_guard); drop(profile_write_guard); + if should_update { + logging_error!(Type::Config, Config::sync_dns_override().await); + } + if let Err(e) = Tray::global().update_tooltip().await { logging!(warn, Type::Cmd, "异步更新托盘提示失败: {e:#}"); } @@ -249,6 +253,7 @@ async fn handle_success(current_value: Option<&String>) -> CmdResult { + logging_error!(Type::Config, Config::sync_dns_override().await); handle::Handle::refresh_clash(); Ok(ValidationOutcome::Valid) } diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index eccbd476c..23574692e 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -109,6 +109,7 @@ impl Config { } Self::runtime().await.apply(); + logging_error!(Type::Config, Self::sync_dns_override().await); Ok(()) } @@ -212,7 +213,7 @@ impl Config { } pub(crate) async fn generate_with_profiles(profiles: &IProfiles) -> Result<()> { - let (mut config, exists_keys, logs) = enhance::enhance(profiles).await?; + let (mut config, exists_keys, logs, dns_override) = enhance::enhance(profiles).await?; sanitize_tunnels_proxy(&mut config); // Apply only to generated core config so the saved choice survives the next launch. @@ -223,6 +224,7 @@ impl Config { Self::runtime().await.edit_draft(|d| { *d = IRuntime { config: Some(config), + dns_override: Some(dns_override), exists_keys, chain_logs: logs, } diff --git a/src-tauri/src/config/dns.rs b/src-tauri/src/config/dns.rs new file mode 100644 index 000000000..f752f14d9 --- /dev/null +++ b/src-tauri/src/config/dns.rs @@ -0,0 +1,211 @@ +use anyhow::Result; +use clash_verge_draft::DraftTransaction; +use serde_yaml_ng::{Mapping, Value}; +use sha2::{Digest as _, Sha256}; +use smartstring::alias::String; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::{Config, IVerge}; +use crate::core::handle::Handle; + +static PENDING_DNS_OVERRIDE_NOTICE: AtomicBool = AtomicBool::new(false); + +pub(crate) fn take_dns_override_notice() -> bool { + PENDING_DNS_OVERRIDE_NOTICE.swap(false, Ordering::Relaxed) +} + +const PROVIDER_DNS_FIELDS: [&str; 3] = [ + "proxy-server-nameserver", + "proxy-server-nameserver-policy", + "nameserver-policy", +]; + +pub(crate) fn dns_override_source(profile_uid: &str, config: &Mapping) -> Result> { + let Some(dns) = config.get("dns").and_then(Value::as_mapping) else { + return Ok(None); + }; + let fields: Mapping = PROVIDER_DNS_FIELDS + .iter() + .filter_map(|key| { + let value = dns.get(*key)?; + let nonempty = match value { + Value::Sequence(values) => !values.is_empty(), + Value::Mapping(values) => !values.is_empty(), + Value::String(value) => !value.trim().is_empty(), + _ => false, + }; + nonempty.then(|| (Value::from(*key), value.clone())) + }) + .collect(); + if fields.is_empty() { + return Ok(None); + } + + let mut canonical = serde_json::to_value(fields)?; + canonical.sort_all_objects(); + let mut digest = Sha256::new(); + digest.update(profile_uid.as_bytes()); + digest.update([0]); + digest.update(serde_json::to_vec(&canonical)?); + Ok(Some( + digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect(), + )) +} + +#[derive(Debug, Clone)] +pub(crate) struct DnsOverrideState { + pub source: Option, + pub enabled: bool, + requested: bool, + confirmation: Option, +} + +impl DnsOverrideState { + pub fn new(source: Option, requested: bool, confirmation: Option) -> Self { + let enabled = requested && (source.is_none() || source == confirmation); + Self { + source, + enabled, + requested, + confirmation, + } + } + + fn apply_to(&self, verge: &mut IVerge) -> bool { + // A later settings write must not be overwritten by an earlier runtime generation. + if verge.enable_dns_settings.unwrap_or(false) != self.requested + || verge.dns_override_confirmation != self.confirmation + { + return false; + } + let clear_confirmation = self.confirmation.is_some() && self.source != self.confirmation; + if self.enabled == self.requested && !clear_confirmation { + return false; + } + verge.enable_dns_settings = Some(self.enabled); + if clear_confirmation { + verge.dns_override_confirmation = None; + } + true + } +} + +impl Config { + pub(crate) async fn sync_dns_override() -> Result<()> { + let _config_write = Self::lock_config_write().await; + let state = Self::runtime().await.data_arc().dns_override.clone(); + let Some(state) = state else { + return Ok(()); + }; + let verge = Self::verge().await; + let transaction = DraftTransaction::begin(vec![&verge])?; + if !verge.edit_draft(|draft| state.apply_to(draft)) { + return Ok(()); + } + // The runtime is already committed; a disk failure cannot undo its effective settings. + transaction.commit(); + Handle::refresh_verge(); + if state.requested && !state.enabled { + PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed); + Handle::notice_message("dns_override::auto_disabled", ""); + } + verge.data_arc().save_file().await + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "tests assert by panicking")] +mod tests { + use super::{DnsOverrideState, dns_override_source}; + use crate::config::IVerge; + use anyhow::Result; + use serde_yaml_ng::Mapping; + + fn mapping(yaml: &str) -> Mapping { + serde_yaml_ng::from_str(yaml).expect("valid fixture") + } + + #[test] + fn dns_override_detects_each_original_field_but_not_empty_placeholders() -> Result<()> { + for field in [ + "proxy-server-nameserver: [https://doh.pub/dns-query]", + "proxy-server-nameserver-policy: {'www.yournode.com': '114.114.114.114'}", + "nameserver-policy: {'+.quandao.com': ['https://doh.dohcore.com:2096/dns-query/#skip-cert-verify=true']}", + ] { + assert!(dns_override_source("profile", &mapping(&format!("dns: {{{field}}}")))?.is_some()); + assert!(dns_override_source("profile", &mapping(field))?.is_none()); + } + for yaml in [ + "{}", + "dns: {nameserver: [1.1.1.1]}", + "dns: {proxy-server-nameserver: [], proxy-server-nameserver-policy: null, nameserver-policy: {}}", + ] { + assert!(dns_override_source("profile", &mapping(yaml))?.is_none()); + } + Ok(()) + } + + #[test] + fn dns_override_confirmation_survives_formatting_but_not_a_different_source() -> Result<()> { + let original = mapping("dns: {nameserver-policy: {a.example: 1.1.1.1, b.example: [8.8.8.8]}}"); + let source = dns_override_source("one", &original)?; + let reordered = + mapping("dns:\n nameserver-policy:\n b.example: [8.8.8.8]\n a.example: 1.1.1.1\nport: 7890"); + assert_eq!(source, dns_override_source("one", &reordered)?); + assert!(DnsOverrideState::new(source.clone(), true, source.clone()).enabled); + assert!(!DnsOverrideState::new(dns_override_source("two", &original)?, true, source.clone()).enabled); + let changed = mapping("dns: {nameserver-policy: {a.example: 9.9.9.9, b.example: [8.8.8.8]}}"); + assert!(!DnsOverrideState::new(dns_override_source("one", &changed)?, true, source).enabled); + Ok(()) + } + + #[test] + fn dns_override_confirmation_expires_on_restart() -> Result<()> { + let source = Some("provider-dns".into()); + let confirmed = IVerge { + enable_dns_settings: Some(true), + dns_override_confirmation: source.clone(), + ..IVerge::default() + }; + assert!(DnsOverrideState::new(source.clone(), true, confirmed.dns_override_confirmation.clone()).enabled); + for saved in [ + serde_yaml_ng::to_string(&confirmed)?, + "enable_dns_settings: true\ndns_override_confirmation: provider-dns".to_owned(), + ] { + let mut restarted: IVerge = serde_yaml_ng::from_str(&saved)?; + let state = DnsOverrideState::new( + source.clone(), + restarted.enable_dns_settings.unwrap_or(false), + restarted.dns_override_confirmation.clone(), + ); + assert!(!state.enabled, "a previous session must not bypass startup protection"); + assert!(state.apply_to(&mut restarted)); + assert_eq!(restarted.enable_dns_settings, Some(false)); + } + Ok(()) + } + + #[test] + fn dns_override_applies_only_to_current_preferences() { + let mut verge = IVerge { + enable_dns_settings: Some(true), + ..IVerge::default() + }; + let state = DnsOverrideState::new(Some("provider-dns".into()), true, None); + assert!(state.apply_to(&mut verge)); + assert_eq!(verge.enable_dns_settings, Some(false)); + assert!(!state.apply_to(&mut verge)); + let disabled = DnsOverrideState::new(None, false, None); + assert!(!disabled.apply_to(&mut verge)); + assert!(!disabled.enabled); + + verge.enable_dns_settings = Some(true); + verge.dns_override_confirmation = Some("provider-dns".into()); + assert!(!state.apply_to(&mut verge)); + assert_eq!(verge.enable_dns_settings, Some(true)); + assert_eq!(verge.dns_override_confirmation.as_deref(), Some("provider-dns")); + let leaving = DnsOverrideState::new(None, true, verge.dns_override_confirmation.clone()); + assert!(leaving.apply_to(&mut verge)); + assert!(verge.dns_override_confirmation.is_none()); + } +} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index d537bb7c3..27d2e6d83 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -1,6 +1,7 @@ mod clash; #[allow(clippy::module_inception)] mod config; +pub(crate) mod dns; mod encrypt; mod mixed_port; mod port; diff --git a/src-tauri/src/config/runtime.rs b/src-tauri/src/config/runtime.rs index 7155989e2..105dc8b46 100644 --- a/src-tauri/src/config/runtime.rs +++ b/src-tauri/src/config/runtime.rs @@ -9,6 +9,7 @@ const PATCH_CONFIG_INNER: [&str; 5] = ["allow-lan", "ipv6", "log-level", "unifie #[derive(Default, Clone)] pub struct IRuntime { pub config: Option, + pub(crate) dns_override: Option, // Keys seen in the profile pipeline, including merge and script output. pub exists_keys: HashSet, // TODO 或许可以用 FixMap 来存储以提升效率 diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 92abd308b..849065709 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -80,6 +80,10 @@ pub struct IVerge { /// enable dns settings - this controls whether dns_config.yaml is applied pub enable_dns_settings: Option, + // Force-enable confirmation is valid only for the current app session. + #[serde(skip)] + pub dns_override_confirmation: Option, + pub use_default_bypass: Option, pub system_proxy_bypass: Option, diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index f6b1d2634..a775c56d7 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -138,6 +138,7 @@ impl CoreManager { Config::runtime().await.edit_draft(|d| { *d = IRuntime { config: Some(clash_config.to_owned()), + dns_override: None, exists_keys: HashSet::new(), chain_logs: Default::default(), } diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index 9d9eb3c22..2f85e22a3 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -13,6 +13,7 @@ use self::{ seq::{SeqMap, use_seq}, tun::use_tun, }; +use crate::config::dns::{DnsOverrideState, dns_override_source}; use crate::utils::dirs; use crate::{ config::{Config, IProfiles, IVerge, PrfItem}, @@ -36,6 +37,7 @@ struct ConfigValues { socks_enabled: bool, http_enabled: bool, enable_dns_settings: bool, + dns_override_confirmation: Option, enable_external_controller: bool, #[cfg(not(target_os = "windows"))] redir_enabled: bool, @@ -121,6 +123,7 @@ async fn get_config_values() -> ConfigValues { .. } = **verge_arc; let enable_external_controller = enable_external_controller.unwrap_or(false); + let dns_override_confirmation = verge_arc.dns_override_confirmation.clone(); let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = ( Some(verge_arc.get_valid_clash_core()), @@ -148,6 +151,7 @@ async fn get_config_values() -> ConfigValues { socks_enabled, http_enabled, enable_dns_settings, + dns_override_confirmation, enable_external_controller, #[cfg(not(target_os = "windows"))] redir_enabled, @@ -701,8 +705,10 @@ async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> M config } -/// Returns the enhanced profile, its original keys, and script logs. -pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, HashMap)> { +/// Returns the enhanced profile, its original keys, script logs, and DNS override decision. +pub async fn enhance( + profiles: &IProfiles, +) -> Result<(Mapping, HashSet, HashMap, DnsOverrideState)> { let cfg_vals = get_config_values().await; let ConfigValues { clash_config, @@ -712,6 +718,7 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, socks_enabled, http_enabled, enable_dns_settings, + dns_override_confirmation, enable_external_controller, #[cfg(not(target_os = "windows"))] redir_enabled, @@ -720,6 +727,12 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, } = cfg_vals; let profile = collect_profile_items(profiles).await?; + let dns_override = DnsOverrideState::new( + dns_override_source(profiles.current.as_deref().unwrap_or_default(), &profile.config)?, + enable_dns_settings, + dns_override_confirmation, + ); + let enable_dns_settings = dns_override.enabled; let config = profile.config; let merge_item = profile.merge_item; let script_item = profile.script_item; @@ -775,7 +788,7 @@ pub async fn enhance(profiles: &IProfiles) -> Result<(Mapping, HashSet, let mut exists_keys_set = HashSet::new(); exists_keys_set.extend(exists_keys); - Ok((config, exists_keys_set, result_map)) + Ok((config, exists_keys_set, result_map, dns_override)) } #[cfg(test)] diff --git a/src-tauri/src/feat/backup.rs b/src-tauri/src/feat/backup.rs index 414f530b9..32a6e1de6 100644 --- a/src-tauri/src/feat/backup.rs +++ b/src-tauri/src/feat/backup.rs @@ -9,7 +9,7 @@ use crate::{ }; use anyhow::{Result, anyhow}; use chrono::Utc; -use clash_verge_logging::{Type, logging}; +use clash_verge_logging::{Type, logging, logging_error}; use reqwest_dav::list_cmd::ListFile; use serde::Serialize; use smartstring::alias::String; @@ -85,6 +85,7 @@ async fn finalize_restored_verge_config( return Err(err); } } + logging_error!(Type::Config, Config::sync_dns_override().await); Ok(()) } diff --git a/src-tauri/src/feat/dns.rs b/src-tauri/src/feat/dns.rs new file mode 100644 index 000000000..d711511fb --- /dev/null +++ b/src-tauri/src/feat/dns.rs @@ -0,0 +1,63 @@ +use anyhow::Result; +use clash_verge_draft::DraftTransaction; +use serde::Serialize; +use smartstring::alias::String; + +use crate::{ + config::{Config, dns::dns_override_source, profiles::PROFILE_WRITE_LOCK}, + constants::files::DNS_CONFIG, + core::{CoreManager, handle::Handle}, + utils::dirs, +}; + +#[derive(Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum DnsOverrideOutcome { + Applied, + ConfirmationRequired { source: String }, +} + +pub async fn set_dns_override(enabled: bool, confirmation: Option) -> Result { + let _profile_write = PROFILE_WRITE_LOCK.lock().await; + let _config_write = Config::lock_config_write().await; + let profiles = Config::profiles().await.data_arc(); + let source = dns_override_source( + profiles.current.as_deref().unwrap_or_default(), + &profiles.current_mapping().await?, + )?; + if enabled && let Some(source) = source.as_ref().filter(|source| Some(*source) != confirmation.as_ref()) { + return Ok(DnsOverrideOutcome::ConfirmationRequired { source: source.clone() }); + } + + if enabled { + let content = tokio::fs::read_to_string(dirs::app_home_dir()?.join(DNS_CONFIG)).await?; + serde_yaml_ng::from_str::(&content)?; + } + + let verge = Config::verge().await; + let transaction = DraftTransaction::begin(vec![&verge])?; + verge.edit_draft(|draft| { + draft.enable_dns_settings = Some(enabled); + draft.dns_override_confirmation = if enabled { source } else { None }; + }); + CoreManager::global().update_config_checked().await?; + + let state = Config::runtime().await.data_arc().dns_override.clone(); + let mut outcome = DnsOverrideOutcome::Applied; + // A subscription update can replace the file while Mihomo is validating it. + if enabled && let Some(state) = state.filter(|state| !state.enabled) { + verge.edit_draft(|draft| { + draft.enable_dns_settings = Some(false); + draft.dns_override_confirmation = None; + }); + if let Some(source) = state.source { + outcome = DnsOverrideOutcome::ConfirmationRequired { source }; + } + } + + transaction.commit(); + Handle::refresh_verge(); + Handle::refresh_clash(); + verge.data_arc().save_file().await?; + Ok(outcome) +} diff --git a/src-tauri/src/feat/mod.rs b/src-tauri/src/feat/mod.rs index 17c3abc59..7b4fb4232 100644 --- a/src-tauri/src/feat/mod.rs +++ b/src-tauri/src/feat/mod.rs @@ -2,6 +2,7 @@ mod backup; mod clash; mod config; mod core_upgrade; +mod dns; mod icon; mod listener; mod profile; @@ -14,6 +15,7 @@ pub use backup::*; pub use clash::*; pub use config::*; pub use core_upgrade::*; +pub use dns::*; pub use icon::*; pub use listener::*; pub use profile::*; diff --git a/src-tauri/src/feat/profile.rs b/src-tauri/src/feat/profile.rs index ff9e3f7c3..aa4f206d8 100644 --- a/src-tauri/src/feat/profile.rs +++ b/src-tauri/src/feat/profile.rs @@ -5,7 +5,7 @@ use crate::{ utils::help::{mask_err, mask_url}, }; use anyhow::{Result, bail}; -use clash_verge_logging::{Type, logging}; +use clash_verge_logging::{Type, logging, logging_error}; use smartstring::alias::String; /// Toggle proxy profile @@ -200,6 +200,7 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual logging!(debug, Type::Config, "[订阅更新] 更新内核配置"); match CoreManager::global().update_config_with_force(is_mannual_trigger).await { Ok(outcome) if outcome.is_valid() => { + logging_error!(Type::Config, Config::sync_dns_override().await); handle::Handle::refresh_clash(); } Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => { @@ -228,5 +229,9 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual /// 增强配置 pub async fn enhance_profiles() -> Result { - CoreManager::global().update_config_forced().await + let outcome = CoreManager::global().update_config_forced().await?; + if outcome.is_valid() { + logging_error!(Type::Config, Config::sync_dns_override().await); + } + Ok(outcome) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3fbcc20ca..9d8086a17 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -160,6 +160,8 @@ mod app_init { cmd::forget_selected_node, cmd::save_dns_config, cmd::apply_dns_config, + cmd::set_dns_override, + cmd::take_dns_override_notice, cmd::get_dns_config_content, cmd::validate_dns_config, cmd::get_clash_logs, diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 4397a1518..4c29a437b 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -33,6 +33,7 @@ import { Switch, } from '@/components/base' import { useClash } from '@/hooks/use-clash' +import { useVerge } from '@/hooks/use-verge' import { showNotice } from '@/services/notice-service' import { useThemeMode } from '@/services/states' import type { MonacoEditorInstance } from '@/types/monaco' @@ -187,7 +188,8 @@ const DEFAULT_DNS_CONFIG = { export function DnsViewer({ ref }: { ref?: Ref }) { const { t } = useTranslation() - const { clash, mutateClash } = useClash() + const { mutateClash } = useClash() + const { verge } = useVerge() const themeMode = useThemeMode() const [open, setOpen] = useState(false) @@ -563,7 +565,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { return } - if (clash?.dns?.enable) { + if (verge?.enable_dns_settings) { await invoke('apply_dns_config', { apply: true }) mutateClash() } diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index 1ab467ce9..73296da76 100644 --- a/src/components/setting/setting-clash.tsx +++ b/src/components/setting/setting-clash.tsx @@ -1,17 +1,16 @@ import { LanRounded, SettingsRounded } from '@mui/icons-material' import { MenuItem, Select, TextField, Typography } from '@mui/material' -import { invoke } from '@tauri-apps/api/core' import { useLockFn } from 'ahooks' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { updateGeo, type LogLevel } from 'tauri-plugin-mihomo-api' -import { DialogRef, Switch, TooltipIcon } from '@/components/base' +import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' import { useClash } from '@/hooks/use-clash' import { useClashLog } from '@/hooks/use-clash-log' import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useVerge } from '@/hooks/use-verge' -import { invoke_uwp_tool } from '@/services/cmds' +import { invoke_uwp_tool, setDnsOverride } from '@/services/cmds' import { showNotice } from '@/services/notice-service' import getSystem from '@/utils/get-system' @@ -36,7 +35,7 @@ const SettingClash = ({ onError }: Props) => { const { t } = useTranslation() const { clash, version, mutateClash, patchClash } = useClash() - const { verge, patchVerge } = useVerge() + const { verge, mutateVerge } = useVerge() const displayedMixedPort = useDisplayedMixedPort() const [, setClashLog] = useClashLog() @@ -47,10 +46,8 @@ const SettingClash = ({ onError }: Props) => { 'unified-delay': unifiedDelay, } = clash ?? {} - // 独立跟踪DNS设置开关状态 - const [dnsSettingsEnabled, setDnsSettingsEnabled] = useState(() => { - return verge?.enable_dns_settings ?? false - }) + const [dnsConfirmation, setDnsConfirmation] = useState(null) + const [dnsUpdating, setDnsUpdating] = useState(false) const webRef = useRef(null) const portRef = useRef(null) @@ -74,22 +71,29 @@ const SettingClash = ({ onError }: Props) => { } } - // 实现DNS设置开关处理函数 - const handleDnsToggle = useLockFn(async (enable: boolean) => { - try { - setDnsSettingsEnabled(enable) - await patchVerge({ enable_dns_settings: enable }) - await invoke('apply_dns_config', { apply: enable }) - setTimeout(() => { + const handleDnsToggle = useLockFn( + async (enable: boolean, confirmation?: string) => { + setDnsUpdating(true) + try { + const outcome = await setDnsOverride(enable, confirmation) + if (outcome.status === 'confirmation_required') { + setDnsConfirmation(outcome.source) + } else { + setDnsConfirmation(null) + } + mutateVerge() mutateClash() - }, 500) - } catch (err: any) { - setDnsSettingsEnabled(!enable) - showNotice.error(err) - await patchVerge({ enable_dns_settings: !enable }).catch(() => {}) - throw err - } - }) + } catch (err: any) { + showNotice.error(err) + } finally { + setDnsUpdating(false) + } + }, + ) + + const closeDnsConfirmation = () => { + if (!dnsUpdating) setDnsConfirmation(null) + } return ( @@ -99,6 +103,24 @@ const SettingClash = ({ onError }: Props) => { + { + if (dnsConfirmation) void handleDnsToggle(true, dnsConfirmation) + }} + > + + {t('settings.modals.dns.protection.message')} + + { > handleDnsToggle(checked)} /> diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 5b1d5c81d..4aec89f19 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -510,6 +510,13 @@ "title": "DNS Overwrite", "warning": "If you are not familiar with these settings, please do not modify them and keep DNS Overwrite disabled" }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "DNS Settings", "fallbackFilter": "Fallback Filter Settings", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index db5b415f7..109f32725 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -510,6 +510,13 @@ "title": "DNS-Überschreibung", "warning": "Wenn Sie sich nicht mit diesen Einstellungen auskennen, ändern Sie sie nicht und lassen Sie die DNS-Überschreibung deaktiviert." }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "DNS-Einstellungen", "fallbackFilter": "Rückfallfilter-Einstellungen", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index baac334f6..fe0f9c94a 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -510,6 +510,13 @@ "title": "DNS Overwrite", "warning": "If you are not familiar with these settings, please do not modify them and keep DNS Overwrite disabled" }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "DNS Settings", "fallbackFilter": "Fallback Filter Settings", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 508d247b0..b8492bb2c 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -510,6 +510,13 @@ "title": "Sobrescritura de DNS", "warning": "Si no está seguro de cómo configurar esto, no realice cambios y mantenga deshabilitada la sobrescritura de DNS." }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "Configuración de DNS", "fallbackFilter": "Configuración de filtrado de respaldo", diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 8aa413e5e..f2b6fc10c 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -510,6 +510,13 @@ "title": "بازنویسی DNS", "warning": "اگر با این تنظیمات آشنا نیستید، لطفاً آنها را تغییر ندهید و DNS Overwrite را غیرفعال نگه دارید." }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "تنظیمات DNS", "fallbackFilter": "Fallback Filter Settings", diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 141222c6a..cf7245cb7 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -510,6 +510,13 @@ "title": "DNS Overwrite", "warning": "If you are not familiar with these settings, please do not modify them and keep DNS Overwrite disabled" }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "DNS Settings", "fallbackFilter": "Fallback Filter Settings", diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 1b278ee88..a7ec639f7 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -510,6 +510,13 @@ "title": "DNS上書き", "warning": "ここの設定がわからない場合は、変更しないでください。DNS上書きを無効にしたままにしてください。" }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "DNS設定", "fallbackFilter": "フォールバックフィルター設定", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index f0832df51..725607d0d 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -510,6 +510,13 @@ "title": "DNS 덮어쓰기", "warning": "이 설정에 익숙하지 않다면 수정하지 말고 DNS 덮어쓰기를 비활성화 상태로 유지하세요" }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "DNS 설정", "fallbackFilter": "폴백 필터 설정", diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 3ac7d9eed..f96d6f6a5 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -510,6 +510,13 @@ "title": "Переопределение настроек DNS", "warning": "Если вы не знакомы с этими настройками, пожалуйста, не изменяйте их и оставьте переопределение DNS отключённым" }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "Настройки DNS", "fallbackFilter": "Настройки фильтра fallback", diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index d2cb538fe..619b201ca 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -510,6 +510,13 @@ "title": "DNS Üzerine Yazma", "warning": "Bu ayarlarla ilgili bilginiz yoksa, lütfen bunları değiştirmeyin ve DNS Üzerine Yazma'yı devre dışı tutun" }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "DNS Ayarları", "fallbackFilter": "Yedek Filtre Ayarları", diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 7ca3c7a62..2aa3fb7ca 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -510,6 +510,13 @@ "title": "DNS Overwrite", "warning": "If you are not familiar with these settings, please do not modify them and keep DNS Overwrite disabled" }, + "protection": { + "title": "Enable DNS Overwrite?", + "message": "This subscription contains custom DNS settings. DNS Overwrite may replace them, preventing nodes from connecting or some websites from loading. Enable it anyway?", + "enableAnyway": "Enable anyway", + "keepDisabled": "Keep disabled", + "autoDisabled": "DNS Overwrite was disabled to preserve the custom DNS settings in the current subscription." + }, "sections": { "general": "DNS Settings", "fallbackFilter": "Fallback Filter Settings", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 7cc8a92b7..42ac1101f 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -510,6 +510,13 @@ "title": "DNS 覆写", "warning": "如果你不清楚这里的设置请不要修改,并保持 DNS 覆写关闭" }, + "protection": { + "title": "是否开启 DNS 覆写?", + "message": "当前订阅包含自定义 DNS 配置。开启 DNS 覆写可能覆盖这些配置,导致节点无法连接或部分网站无法访问。是否仍要开启?", + "enableAnyway": "仍然开启", + "keepDisabled": "保持关闭", + "autoDisabled": "检测到当前订阅包含自定义 DNS 配置,已自动关闭 DNS 覆写。" + }, "sections": { "general": "DNS 设置", "fallbackFilter": "回退过滤设置", diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index d7348d207..6997082ac 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -510,6 +510,13 @@ "title": "DNS 覆寫", "warning": "如果你不清楚這裡的設定請不要修改,並保持 DNS 覆寫關閉" }, + "protection": { + "title": "是否開啟 DNS 覆寫?", + "message": "目前訂閱包含自訂 DNS 設定。開啟 DNS 覆寫可能覆蓋這些設定,導致節點無法連線或部分網站無法存取。是否仍要開啟?", + "enableAnyway": "仍然開啟", + "keepDisabled": "保持關閉", + "autoDisabled": "偵測到目前訂閱包含自訂 DNS 設定,已自動關閉 DNS 覆寫。" + }, "sections": { "general": "DNS 設定", "fallbackFilter": "備援篩選設定", diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/pages/_layout/hooks/use-layout-events.test.ts new file mode 100644 index 000000000..59edc8b34 --- /dev/null +++ b/src/pages/_layout/hooks/use-layout-events.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +import { takeDnsOverrideNotice } from '@/services/cmds' +import { subscribeVergeEvents } from '@/services/events' +import { showNotice } from '@/services/notice-service' +import { revalidateQueries } from '@/services/query-client' + +import { handleNoticeMessage } from '../utils/notification-handlers' + +import { useLayoutEvents } from './use-layout-events' + +vi.mock('react', () => ({ useEffect: (effect: () => void) => effect() })) +vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() })) +vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] })) +vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() })) +vi.mock('@/services/cmds', () => ({ takeDnsOverrideNotice: vi.fn() })) +vi.mock('@/services/notice-service', () => ({ + showNotice: { info: vi.fn() }, +})) +vi.mock('@/services/query-client', () => ({ + revalidateQueries: vi.fn(), + setCacheData: vi.fn(), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +it('drains a DNS notice after listeners mount without duplicating its live event', async () => { + vi.mocked(takeDnsOverrideNotice) + .mockResolvedValueOnce(true) + .mockResolvedValue(false) + + useLayoutEvents(([status, message]) => { + handleNoticeMessage(status, message, (key) => key, vi.fn()) + }) + + expect(takeDnsOverrideNotice).not.toHaveBeenCalled() + const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0] + onSubscribed?.() + expect(takeDnsOverrideNotice).toHaveBeenCalledOnce() + handlers['verge://notice-message']?.(['dns_override::auto_disabled', '']) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(showNotice.info).toHaveBeenCalledExactlyOnceWith( + 'settings.modals.dns.protection.autoDisabled', + ) + expect(revalidateQueries).toHaveBeenCalledWith([ + ['getRuntimeState'], + ['getVergeConfig'], + ]) +}) diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index 24b2d1859..b1094cf0c 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -61,7 +61,10 @@ export const useLayoutEvents = ( 'verge://notice-message': handleNotice, }, // Re-read event-only state after subscribing to close the initial race window. - () => revalidateKeys(['getRuntimeState']), + () => { + revalidateKeys(['getRuntimeState', 'getVergeConfig']) + handleNotice(['dns_override::auto_disabled', '']) + }, ) }, [handleNotice]) } diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index 95b40831a..d866cd762 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -1,3 +1,4 @@ +import { takeDnsOverrideNotice } from '@/services/cmds' import { showNotice } from '@/services/notice-service' type NavigateFunction = (path: string, options?: any) => void @@ -24,6 +25,17 @@ export const handleNoticeMessage = ( showNotice.error(msg) }, 'set_config::error': () => showNotice.error(msg), + 'dns_override::auto_disabled': () => { + void takeDnsOverrideNotice() + .then((pending) => { + if (pending) { + showNotice.info('settings.modals.dns.protection.autoDisabled') + } + }) + .catch((error) => { + console.error('Failed to read the pending DNS override notice', error) + }) + }, 'tun_mode::auto_disabled': () => showNotice.info( 'settings.sections.system.notifications.tunMode.autoDisabled', diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 4aad73073..d26e48e62 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -172,6 +172,16 @@ export async function patchVergeConfig(payload: IVergeConfig) { return invoke('patch_verge_config', { payload }) } +export async function setDnsOverride(enabled: boolean, confirmation?: string) { + return invoke< + { status: 'applied' } | { status: 'confirmation_required'; source: string } + >('set_dns_override', { enabled, confirmation }) +} + +export async function takeDnsOverrideNotice() { + return invoke('take_dns_override_notice') +} + export async function getSystemProxy() { return invoke<{ enable: boolean diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 090f3428b..928bdcd67 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -697,6 +697,11 @@ export const translationKeys = [ 'settings.modals.tun.messages.routeExcludeAddressHint', 'settings.modals.dns.dialog.title', 'settings.modals.dns.dialog.warning', + 'settings.modals.dns.protection.title', + 'settings.modals.dns.protection.message', + 'settings.modals.dns.protection.enableAnyway', + 'settings.modals.dns.protection.keepDisabled', + 'settings.modals.dns.protection.autoDisabled', 'settings.modals.dns.sections.general', 'settings.modals.dns.sections.fallbackFilter', 'settings.modals.dns.sections.hosts', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 7026825f4..f92a33cd1 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -1037,6 +1037,13 @@ export interface TranslationResources { configError: string saved: string } + protection: { + autoDisabled: string + enableAnyway: string + keepDisabled: string + message: string + title: string + } sections: { fallbackFilter: string general: string