mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-26 14:12:10 +08:00
fix(dns): keep profile values the override leaves blank or off
Refs #7292 #7933
This commit is contained in:
@@ -80,6 +80,7 @@
|
||||
|
||||
- 优化首页节点菜单关闭时的刷新性能
|
||||
- 优化 TUN 配置优先级:界面设置优先,其他选项仍可通过订阅、覆写和脚本调整
|
||||
- 优化 DNS 覆写:仅覆盖已填写的字段和已开启的开关,留空或关闭的项保留订阅设置
|
||||
- 优化系统代理失败后的恢复流程,保持系统设置和界面状态一致
|
||||
- 优化错误提示,支持复制完整原因,托盘和快捷键操作失败时也会提醒
|
||||
- 优化代理列表刷新、排序和页面切换速度
|
||||
|
||||
@@ -210,7 +210,8 @@ pub async fn validate_dns_config() -> CmdResult<ValidationOutcome> {
|
||||
return Ok(ValidationOutcome::invalid_from_message("DNS config file not found"));
|
||||
}
|
||||
|
||||
CoreConfigValidator::validate_config_file_outcome(dns_path_str, None)
|
||||
// A fragment, not a runnable config; the merged result is validated on apply.
|
||||
CoreConfigValidator::validate_config_file_outcome(dns_path_str, Some(true))
|
||||
.await
|
||||
.stringify_err()
|
||||
}
|
||||
|
||||
@@ -284,8 +284,7 @@ fn process_seq_items(
|
||||
config
|
||||
}
|
||||
|
||||
/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。
|
||||
/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。
|
||||
/// App 权威的顶层控制面键;`dns.ipv6` 单独处理。
|
||||
const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||
"external-controller",
|
||||
#[cfg(unix)]
|
||||
@@ -308,25 +307,20 @@ const CONTROL_PLANE_KEYS: &[&str] = &[
|
||||
"unified-delay",
|
||||
];
|
||||
|
||||
/// The fields the app owns, held across the stages that apply the user's manual overrides.
|
||||
///
|
||||
/// Capturing and restoring are one value rather than two calls because the order is a
|
||||
/// correctness requirement, not a style: capture must happen after the app has finished
|
||||
/// deriving these fields and before any override runs, and restore must happen after every
|
||||
/// override. As four separate calls that contract lived only in comments.
|
||||
/// App-owned fields: captured after the app derives them, enforced after every override.
|
||||
struct AuthoritativeFields {
|
||||
control_plane: Mapping,
|
||||
tun: Mapping,
|
||||
/// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely.
|
||||
/// Only when the DNS page switched it on.
|
||||
dns_ipv6: Option<Value>,
|
||||
}
|
||||
|
||||
impl AuthoritativeFields {
|
||||
fn capture(config: &Mapping, gui_tun_keys: &[Value], enable_dns_settings: bool) -> Self {
|
||||
fn capture(config: &Mapping, gui_tun_keys: &[Value], owns_dns_ipv6: bool) -> Self {
|
||||
Self {
|
||||
control_plane: snapshot_control_plane(config),
|
||||
tun: snapshot_tun(config, gui_tun_keys),
|
||||
dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(),
|
||||
dns_ipv6: owns_dns_ipv6.then(|| snapshot_dns_ipv6(config)).flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +395,7 @@ fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
|
||||
config
|
||||
}
|
||||
|
||||
/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。
|
||||
/// 只在 DNS 页开启 IPv6 时快照。
|
||||
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
|
||||
config.get("dns")?.get("ipv6").cloned()
|
||||
}
|
||||
@@ -481,11 +475,7 @@ async fn process_profile_items(
|
||||
(config, exists_keys, result_map)
|
||||
}
|
||||
|
||||
/// Merge the Application Merge Config over the profile's own configuration.
|
||||
///
|
||||
/// Every switch it consults is a parameter: it used to read `enable_external_controller`
|
||||
/// out of the global config from inside its loop, which made a stage that looks pure depend
|
||||
/// on process state and left it untestable.
|
||||
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
|
||||
fn merge_default_config(
|
||||
mut config: Mapping,
|
||||
clash_config: Mapping,
|
||||
@@ -683,9 +673,7 @@ fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
|
||||
config
|
||||
}
|
||||
|
||||
/// 当 DNS 处于 fake-ip 模式且启用 IPv6 时,补充缺失的 `fake-ip-range6`,
|
||||
/// 否则 AAAA 查询无法获得 fake-ip,导致 IPv6 解析失败(见 issue #7373)。
|
||||
/// 兼容旧版本生成的、缺少该字段的 dns_config.yaml。
|
||||
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
|
||||
fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
||||
use serde_yaml_ng::Value;
|
||||
|
||||
@@ -708,38 +696,77 @@ fn ensure_fake_ip_range6(dns: &mut Mapping) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
|
||||
/// On, non-blank text, or non-empty list/map; anything else keeps the profile's value.
|
||||
fn is_set(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::Bool(on) => *on,
|
||||
Value::String(text) => !text.trim().is_empty(),
|
||||
Value::Sequence(items) => !items.is_empty(),
|
||||
Value::Mapping(map) => !map.is_empty(),
|
||||
Value::Number(_) | Value::Tagged(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay set fields onto base; maps merge per key, lists are leaves.
|
||||
fn merge_set_fields(mut base: Mapping, overlay: Mapping) -> Mapping {
|
||||
for (key, value) in overlay {
|
||||
let value = match (base.get_mut(&key), value) {
|
||||
(Some(Value::Mapping(existing)), Value::Mapping(patch)) => {
|
||||
*existing = merge_set_fields(std::mem::take(existing), patch);
|
||||
continue;
|
||||
}
|
||||
(_, Value::Mapping(patch)) => Value::Mapping(merge_set_fields(Mapping::new(), patch)),
|
||||
(_, value) => value,
|
||||
};
|
||||
if is_set(&value) {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
|
||||
config
|
||||
.get_mut(key)
|
||||
.and_then(Value::as_mapping_mut)
|
||||
.map(std::mem::take)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns the merged config and whether the page set `dns.ipv6`.
|
||||
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> (Mapping, bool) {
|
||||
let mut owns_ipv6 = false;
|
||||
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
|
||||
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
|
||||
|
||||
if dns_path.exists()
|
||||
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
|
||||
&& let Ok(dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
|
||||
&& let Ok(mut dns_config) = serde_yaml_ng::from_str::<serde_yaml_ng::Mapping>(&dns_yaml)
|
||||
{
|
||||
if let Some(hosts_value) = dns_config.get("hosts")
|
||||
&& hosts_value.is_mapping()
|
||||
{
|
||||
config.insert("hosts".into(), hosts_value.clone());
|
||||
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts") {
|
||||
let hosts = merge_set_fields(take_mapping(&mut config, "hosts"), hosts);
|
||||
config.insert("hosts".into(), hosts.into());
|
||||
logging!(debug, Type::Core, "apply hosts configuration");
|
||||
}
|
||||
|
||||
if let Some(dns_value) = dns_config.get("dns") {
|
||||
if let Some(dns_mapping) = dns_value.as_mapping() {
|
||||
let mut dns_mapping = dns_mapping.clone();
|
||||
ensure_fake_ip_range6(&mut dns_mapping);
|
||||
config.insert("dns".into(), dns_mapping.into());
|
||||
logging!(debug, Type::Core, "apply dns_config.yaml (dns section)");
|
||||
}
|
||||
} else {
|
||||
let mut dns_config = dns_config;
|
||||
ensure_fake_ip_range6(&mut dns_config);
|
||||
config.insert("dns".into(), dns_config.into());
|
||||
// Legacy layout: no `dns` root.
|
||||
let dns_override = match dns_config.remove("dns") {
|
||||
Some(Value::Mapping(dns)) => Some(dns),
|
||||
Some(_) => None,
|
||||
None => Some(dns_config),
|
||||
};
|
||||
if let Some(dns_override) = dns_override {
|
||||
owns_ipv6 = dns_override.get("ipv6").is_some_and(is_set);
|
||||
let mut dns = merge_set_fields(take_mapping(&mut config, "dns"), dns_override);
|
||||
ensure_fake_ip_range6(&mut dns);
|
||||
config.insert("dns".into(), dns.into());
|
||||
logging!(debug, Type::Core, "apply dns_config.yaml");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
(config, owns_ipv6)
|
||||
}
|
||||
|
||||
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
|
||||
@@ -800,9 +827,9 @@ pub async fn enhance(
|
||||
|
||||
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
|
||||
let config = use_tun(config, enable_tun);
|
||||
let config = apply_dns_settings(config, enable_dns_settings).await;
|
||||
let (config, owns_dns_ipv6) = apply_dns_settings(config, enable_dns_settings).await;
|
||||
|
||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, enable_dns_settings);
|
||||
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, owns_dns_ipv6);
|
||||
|
||||
let (config, exists_keys, result_map) = process_global_items(
|
||||
config,
|
||||
@@ -864,8 +891,7 @@ mod fake_ip_tests {
|
||||
|
||||
#[test]
|
||||
fn a_hand_edited_empty_range_counts_as_missing() {
|
||||
// The reason this is not just a `contains_key` check: YAML edited by hand often
|
||||
// leaves the key present with nothing after the colon.
|
||||
// Hand-edited YAML may leave the key present but empty.
|
||||
for blank in ["", " "] {
|
||||
let mut config = dns(&[
|
||||
("ipv6", Value::from(true)),
|
||||
@@ -1004,8 +1030,7 @@ mod use_tun_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn switching_tun_off_leaves_dns_untouched() {
|
||||
// Turning TUN off must not rewrite DNS the user or profile chose; only the system
|
||||
// resolver is restored, which is not part of the configuration.
|
||||
// TUN off must not rewrite the DNS the profile chose.
|
||||
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
|
||||
|
||||
let result = use_tun(config, false);
|
||||
@@ -1038,8 +1063,7 @@ mod use_sort_tests {
|
||||
|
||||
#[test]
|
||||
fn the_bulky_list_fields_are_written_last() {
|
||||
// These are the fields that make a config file unreadable from the top, so they are
|
||||
// pushed below the settings a human actually scans for.
|
||||
// Bulky list fields go last so the top of the file stays readable.
|
||||
let mut config = Mapping::new();
|
||||
config.insert(Value::from("rules"), Value::from("rules"));
|
||||
config.insert(Value::from("mode"), Value::from("rule"));
|
||||
@@ -1129,8 +1153,7 @@ mod merge_default_config_tests {
|
||||
|
||||
#[test]
|
||||
fn a_disabled_listener_is_removed_rather_than_merged() {
|
||||
// Removed, not just skipped: the profile may have asked for the port itself, and a
|
||||
// listener the user switched off must not be opened by the profile's own value.
|
||||
// Removed, not skipped: the profile's own value must not reopen a disabled listener.
|
||||
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
|
||||
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
|
||||
|
||||
@@ -1152,8 +1175,7 @@ mod merge_default_config_tests {
|
||||
|
||||
#[test]
|
||||
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
|
||||
// Blanked, not removed: an absent key lets mihomo fall back to its own default and
|
||||
// listen anyway, which is the opposite of what switching it off means.
|
||||
// Blanked, not removed: an absent key makes mihomo listen on its default.
|
||||
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
|
||||
|
||||
let merged = merge(Mapping::new(), clash_config, false);
|
||||
|
||||
@@ -228,27 +228,21 @@ async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Raise existing sub-floor update intervals, once per install.
|
||||
///
|
||||
/// One-off repair, not a standing rule — the UI warns about shorter intervals but still saves
|
||||
/// them, so the marker is written even when nothing needed raising.
|
||||
/// Once per install; the marker is written even when no interval needed raising.
|
||||
pub async fn migrate_short_update_intervals() -> Result<()> {
|
||||
let marker = dirs::update_interval_migrated_path()?;
|
||||
if fs::try_exists(&marker).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Same order as every other writer holding it (lock, then draft permit): save_file runs
|
||||
// inside the closure, ahead of the optimistic check, so a racing restore can't be undone.
|
||||
// Same lock order as other writers; saving inside the closure keeps a racing restore intact.
|
||||
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
|
||||
|
||||
let min = constants::profile::MIN_UPDATE_INTERVAL;
|
||||
let raised = Config::profiles()
|
||||
.await
|
||||
.with_data_modify(|mut profiles: IProfiles| async move {
|
||||
// `IProfiles::new` normalises items to Some when it read the file, and returns a
|
||||
// bare default() when it failed. A failed load also raises zero — without this the
|
||||
// marker would burn the one shot on profiles nobody managed to load.
|
||||
// A failed profiles load (bare default) must not burn the one-shot marker.
|
||||
if profiles.items.is_none() {
|
||||
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
|
||||
}
|
||||
@@ -370,14 +364,7 @@ pub(super) async fn init_dns_config() -> Result<()> {
|
||||
"nameserver-policy".into(),
|
||||
Value::Mapping(serde_yaml_ng::Mapping::new()),
|
||||
),
|
||||
(
|
||||
"proxy-server-nameserver".into(),
|
||||
Value::Sequence(vec![
|
||||
Value::String("https://doh.pub/dns-query".into()),
|
||||
Value::String("https://dns.alidns.com/dns-query".into()),
|
||||
Value::String("tls://223.5.5.5".into()),
|
||||
]),
|
||||
),
|
||||
("proxy-server-nameserver".into(), Value::Sequence(vec![])),
|
||||
("direct-nameserver".into(), Value::Sequence(vec![])),
|
||||
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
|
||||
(
|
||||
|
||||
@@ -171,11 +171,7 @@ const DEFAULT_DNS_CONFIG = {
|
||||
],
|
||||
fallback: [],
|
||||
'nameserver-policy': {},
|
||||
'proxy-server-nameserver': [
|
||||
'https://doh.pub/dns-query',
|
||||
'https://dns.alidns.com/dns-query',
|
||||
'tls://223.5.5.5',
|
||||
],
|
||||
'proxy-server-nameserver': [],
|
||||
'direct-nameserver': [],
|
||||
'direct-nameserver-follow-policy': false,
|
||||
'fallback-filter': {
|
||||
@@ -340,8 +336,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
listen: values.listen,
|
||||
'enhanced-mode': values.enhancedMode,
|
||||
'fake-ip-range': values.fakeIpRange,
|
||||
'fake-ip-range6':
|
||||
values.fakeIpRange6 || DEFAULT_DNS_CONFIG['fake-ip-range6'],
|
||||
'fake-ip-range6': values.fakeIpRange6,
|
||||
'fake-ip-filter-mode': values.fakeIpFilterMode,
|
||||
'prefer-h3': values.preferH3,
|
||||
'respect-rules': values.respectRules,
|
||||
|
||||
Reference in New Issue
Block a user