From 7bf8e1dcf5e831406ecfa44cf3ef66939087be7f Mon Sep 17 00:00:00 2001 From: wonfen Date: Mon, 21 Sep 2026 21:22:28 +0800 Subject: [PATCH] feat(enhance): warn when extensions write app-owned keys --- Changelog.md | 3 +- src-tauri/src/cmd/runtime.rs | 5 + src-tauri/src/enhance/mod.rs | 113 +++++++++++++++++- src-tauri/src/lib.rs | 1 + src/locales/ar/profiles.json | 1 + src/locales/de/profiles.json | 1 + src/locales/en/profiles.json | 1 + src/locales/es/profiles.json | 1 + src/locales/fa/profiles.json | 1 + src/locales/id/profiles.json | 1 + src/locales/jp/profiles.json | 1 + src/locales/ko/profiles.json | 1 + src/locales/ru/profiles.json | 1 + src/locales/tr/profiles.json | 1 + src/locales/tt/profiles.json | 1 + src/locales/zh/profiles.json | 1 + src/locales/zhtw/profiles.json | 1 + .../_layout/hooks/use-layout-events.test.ts | 1 + src/pages/_layout/hooks/use-layout-events.ts | 1 + .../_layout/utils/notification-handlers.ts | 17 +++ src/services/cmds.ts | 4 + src/types/generated/i18n-keys.ts | 1 + src/types/generated/i18n-resources.ts | 1 + 23 files changed, 153 insertions(+), 7 deletions(-) diff --git a/Changelog.md b/Changelog.md index 16ce0b9c1..0061878c1 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,11 +3,9 @@
🐞 修复问题 -- 回退 2.5.4 引入的 DNS 覆写保护,修复重启后开关自动关闭 - 优化文件错误日志,显示具体文件路径和失败原因 - 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失 - 修复首次以管理员身份启动后,系统服务因数据目录所有权不符而拒绝启动内核的问题 -- 修复 DNS 覆写:空内容报错、初始值覆盖已有设置、已填写字段残留原配置、空字段在高级编辑器中缺失
@@ -16,5 +14,6 @@ - 优化系统服务版本检查:版本与当前应用要求不一致时,提示卸载后重新安装 - 优化预构建资源,补充 ASN 数据库文件 +- 扩展配置或脚本写入由应用设置接管的字段时,弹出提示并在扩展日志中记录该值已被丢弃 diff --git a/src-tauri/src/cmd/runtime.rs b/src-tauri/src/cmd/runtime.rs index d96168fb7..13e0e7c5f 100644 --- a/src-tauri/src/cmd/runtime.rs +++ b/src-tauri/src/cmd/runtime.rs @@ -32,6 +32,11 @@ pub async fn get_runtime_logs() -> CmdResult Option { + crate::enhance::take_discarded_keys_notice() +} + #[tauri::command] pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult { let runtime = Config::runtime().await; diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index c62aaf4d8..db2b6eca2 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -14,6 +14,7 @@ use self::{ tun::use_tun, }; use crate::config::dns::{DnsOverrideState, dns_override_source}; +use crate::core::handle::Handle; use crate::utils::dirs; use crate::{ config::{Config, IProfiles, IVerge, PrfItem}, @@ -22,6 +23,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use clash_verge_logging::{Type, logging}; +use parking_lot::Mutex; use serde_yaml_ng::{Mapping, Value}; use smartstring::alias::String; use std::collections::{HashMap, HashSet}; @@ -247,16 +249,33 @@ async fn process_global_items( global_merge: ChainItem, global_script: ChainItem, profile_name: &String, + authoritative: &AuthoritativeFields, ) -> (Mapping, Vec, HashMap) { if let ChainType::Merge(merge) = global_merge.data { exists_keys.extend(use_keys(&merge)); + let before = authoritative.current(&config); config = use_merge(&merge, config); + let notes: ResultLog = authoritative + .overridden(&before, &authoritative.current(&config)) + .into_iter() + .map(discarded_note) + .collect(); + if !notes.is_empty() { + result_map.entry(global_merge.uid).or_default().extend(notes); + } } if let ChainType::Script(script) = global_script.data { - let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await; + let before = authoritative.current(&config); + let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await; exists_keys.extend(changed_keys); config = res_config; + logs.extend( + authoritative + .overridden(&before, &authoritative.current(&config)) + .into_iter() + .map(discarded_note), + ); result_map.insert(global_script.uid, logs); } @@ -329,6 +348,60 @@ impl AuthoritativeFields { let config = enforce_tun(config, self.tun); enforce_dns_ipv6(config, self.dns_ipv6) } + + /// The owned values as `config` holds them now, for diffing one override. + fn current(&self, config: &Mapping) -> Self { + let tun_keys: Vec = self.tun.keys().cloned().collect(); + Self::capture(config, &tun_keys, self.dns_ipv6.is_some()) + } + + /// Owned keys an override moved off the app value; `enforce` discards those writes. + fn overridden(&self, before: &Self, after: &Self) -> Vec { + let mut keys = Vec::new(); + for &key in CONTROL_PLANE_KEYS { + if before.control_plane.get(key) != after.control_plane.get(key) + && after.control_plane.get(key) != self.control_plane.get(key) + { + keys.push(key.into()); + } + } + for key in self.tun.keys() { + if before.tun.get(key) != after.tun.get(key) && after.tun.get(key) != self.tun.get(key) { + keys.push(format!("tun.{}", key.as_str().unwrap_or_default()).into()); + } + } + if self.dns_ipv6.is_some() && before.dns_ipv6 != after.dns_ipv6 && after.dns_ipv6 != self.dns_ipv6 { + keys.push("dns.ipv6".into()); + } + keys + } +} + +fn discarded_note(key: String) -> (String, String) { + ( + "warn".into(), + format!("`{key}` is managed by Settings; the value written here was discarded").into(), + ) +} + +static PENDING_DISCARDED_KEYS: Mutex> = Mutex::new(None); +static LAST_DISCARDED_KEYS: Mutex> = Mutex::new(Vec::new()); + +pub(crate) fn take_discarded_keys_notice() -> Option { + PENDING_DISCARDED_KEYS.lock().take() +} + +/// One notice per distinct set, so regenerations with unchanged extensions stay quiet. +fn notify_discarded_keys(keys: Vec) { + let mut last = LAST_DISCARDED_KEYS.lock(); + if *last == keys { + return; + } + if !keys.is_empty() { + *PENDING_DISCARDED_KEYS.lock() = Some(keys.join(", ").into()); + Handle::notice_message("enhance::discarded_keys", ""); + } + *last = keys; } /// 手动 merge/script 前保存 app 最终控制面值,只记录当前存在的键。 @@ -459,16 +532,33 @@ async fn process_profile_items( merge_item: ChainItem, script_item: ChainItem, profile_name: &String, + authoritative: &AuthoritativeFields, ) -> (Mapping, Vec, HashMap) { if let ChainType::Merge(merge) = merge_item.data { exists_keys.extend(use_keys(&merge)); + let before = authoritative.current(&config); config = use_merge(&merge, config); + let notes: ResultLog = authoritative + .overridden(&before, &authoritative.current(&config)) + .into_iter() + .map(discarded_note) + .collect(); + if !notes.is_empty() { + result_map.entry(merge_item.uid).or_default().extend(notes); + } } if let ChainType::Script(script) = script_item.data { - let (res_config, changed_keys, logs) = use_script(script, config, profile_name.clone()).await; + let before = authoritative.current(&config); + let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await; exists_keys.extend(changed_keys); config = res_config; + logs.extend( + authoritative + .overridden(&before, &authoritative.current(&config)) + .into_iter() + .map(discarded_note), + ); result_map.insert(script_item.uid, logs); } @@ -836,12 +926,22 @@ pub async fn enhance( global_merge, global_script, &profile_name, + &authoritative, ) .await; - let (config, exists_keys, result_map) = - process_profile_items(config, exists_keys, result_map, merge_item, script_item, &profile_name).await; + let (config, exists_keys, result_map) = process_profile_items( + config, + exists_keys, + result_map, + merge_item, + script_item, + &profile_name, + &authoritative, + ) + .await; + notify_discarded_keys(authoritative.overridden(&authoritative, &authoritative.current(&config))); let config = authoritative.enforce(config); let config = ensure_lan_bind_address(config); @@ -1312,7 +1412,7 @@ mod authoritative_field_tests { #[cfg(test)] mod tests { use super::{ - ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items, + AuthoritativeFields, ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items, process_profile_items, use_keys, }; use std::collections::HashMap; @@ -1365,6 +1465,7 @@ mod tests { ); let profile_name = "test-profile".into(); + let authoritative = AuthoritativeFields::capture(&config, &[], false); let (config, exists_keys, result_map) = process_global_items( config, exists_keys, @@ -1372,6 +1473,7 @@ mod tests { global_merge, global_script, &profile_name, + &authoritative, ) .await; let (config, exists_keys, _) = process_profile_items( @@ -1381,6 +1483,7 @@ mod tests { profile_merge, profile_script, &profile_name, + &authoritative, ) .await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dee3df6e0..04fcaf303 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -171,6 +171,7 @@ mod app_init { cmd::set_dns_override, cmd::take_dns_override_notice, cmd::take_service_fallback_notice, + cmd::take_discarded_keys_notice, cmd::get_dns_config_content, cmd::validate_dns_config, cmd::get_clash_logs, diff --git a/src/locales/ar/profiles.json b/src/locales/ar/profiles.json index c1cf694f0..31601d4e5 100644 --- a/src/locales/ar/profiles.json +++ b/src/locales/ar/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "كتبت الإضافات الحقول {{keys}} التي تديرها الإعدادات، لذلك تم تجاهل هذه القيم", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/locales/de/profiles.json b/src/locales/de/profiles.json index f19a70ac3..4506512c6 100644 --- a/src/locales/de/profiles.json +++ b/src/locales/de/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "Erweiterungen haben {{keys}} gesetzt, die von den Einstellungen verwaltet werden, daher wurden diese Werte verworfen", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/locales/en/profiles.json b/src/locales/en/profiles.json index f1385946e..41eb21260 100644 --- a/src/locales/en/profiles.json +++ b/src/locales/en/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "Extensions wrote {{keys}}, which Settings manages, so those values were discarded", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/locales/es/profiles.json b/src/locales/es/profiles.json index aaf0fbdd2..0444116f5 100644 --- a/src/locales/es/profiles.json +++ b/src/locales/es/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "Las extensiones escribieron {{keys}}, campos gestionados por los ajustes, por lo que esos valores se descartaron", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/locales/fa/profiles.json b/src/locales/fa/profiles.json index 880439a80..9d930d336 100644 --- a/src/locales/fa/profiles.json +++ b/src/locales/fa/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "افزونه‌ها فیلدهای {{keys}} را نوشتند که توسط تنظیمات مدیریت می‌شوند، بنابراین این مقادیر نادیده گرفته شدند", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/locales/id/profiles.json b/src/locales/id/profiles.json index 0831a9228..1253e04aa 100644 --- a/src/locales/id/profiles.json +++ b/src/locales/id/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "Ekstensi menulis {{keys}} yang dikelola oleh Pengaturan, sehingga nilai tersebut diabaikan", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/locales/jp/profiles.json b/src/locales/jp/profiles.json index e8c29d40b..9bbce95d8 100644 --- a/src/locales/jp/profiles.json +++ b/src/locales/jp/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "拡張設定またはスクリプトが、アプリ設定で管理される {{keys}} を書き込んだため、その値は破棄されました", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/locales/ko/profiles.json b/src/locales/ko/profiles.json index 5abeecb58..044da8335 100644 --- a/src/locales/ko/profiles.json +++ b/src/locales/ko/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "강제 새로고침 완료", + "discardedKeys": "확장 설정 또는 스크립트가 앱 설정이 관리하는 {{keys}} 필드를 작성하여 해당 값은 무시되었습니다", "emergencyRefreshFailed": "긴급 새로고침 실패: {{message}}" }, "tooltips": { diff --git a/src/locales/ru/profiles.json b/src/locales/ru/profiles.json index 37fff545c..fb7efb435 100644 --- a/src/locales/ru/profiles.json +++ b/src/locales/ru/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Принудительное обновление завершено", + "discardedKeys": "Расширения записали поля {{keys}}, которыми управляют настройки приложения, поэтому эти значения отброшены", "emergencyRefreshFailed": "Экстренное обновление не удалось: {{message}}" }, "tooltips": { diff --git a/src/locales/tr/profiles.json b/src/locales/tr/profiles.json index 2797b340b..fc65cf9c8 100644 --- a/src/locales/tr/profiles.json +++ b/src/locales/tr/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Zorunlu yenileme tamamlandı", + "discardedKeys": "Uzantılar, Ayarlar tarafından yönetilen {{keys}} alanlarını yazdığı için bu değerler yok sayıldı", "emergencyRefreshFailed": "Acil yenileme başarısız oldu: {{message}}" }, "tooltips": { diff --git a/src/locales/tt/profiles.json b/src/locales/tt/profiles.json index 9e02a1a98..37e7f3a67 100644 --- a/src/locales/tt/profiles.json +++ b/src/locales/tt/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "Киңәйтмәләр Көйләүләр белән идарә ителә торган {{keys}} кырларын язды, шуңа күрә бу кыйммәтләр кире кагылды", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/locales/zh/profiles.json b/src/locales/zh/profiles.json index 1ef0413b5..6d61b179b 100644 --- a/src/locales/zh/profiles.json +++ b/src/locales/zh/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "数据已强制刷新", + "discardedKeys": "扩展配置或脚本写入了由应用设置接管的字段 {{keys}},这些值已被丢弃", "emergencyRefreshFailed": "紧急刷新失败: {{message}}" }, "tooltips": { diff --git a/src/locales/zhtw/profiles.json b/src/locales/zhtw/profiles.json index c86349fec..81efac3be 100644 --- a/src/locales/zhtw/profiles.json +++ b/src/locales/zhtw/profiles.json @@ -51,6 +51,7 @@ }, "notices": { "forceRefreshCompleted": "Force refresh completed", + "discardedKeys": "擴充設定或腳本寫入了由應用程式設定接管的欄位 {{keys}},這些值已被捨棄", "emergencyRefreshFailed": "Emergency refresh failed: {{message}}" }, "tooltips": { diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/pages/_layout/hooks/use-layout-events.test.ts index 0a3f50f4d..8dceda771 100644 --- a/src/pages/_layout/hooks/use-layout-events.test.ts +++ b/src/pages/_layout/hooks/use-layout-events.test.ts @@ -14,6 +14,7 @@ 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', () => ({ + takeDiscardedKeysNotice: vi.fn().mockResolvedValue(null), takeDnsOverrideNotice: vi.fn(), takeServiceFallbackNotice: vi.fn().mockResolvedValue(false), })) diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index eddf2318f..0ec1d042e 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -64,6 +64,7 @@ export const useLayoutEvents = ( () => { revalidateKeys(['getRuntimeState', 'getVergeConfig']) handleNotice(['dns_override::auto_disabled', '']) + handleNotice(['enhance::discarded_keys', '']) handleNotice(['service_core::sidecar_fallback', '']) }, ) diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index cff425e8e..1671fa0b0 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -1,4 +1,5 @@ import { + takeDiscardedKeysNotice, takeDnsOverrideNotice, takeServiceFallbackNotice, } from '@/services/cmds' @@ -55,6 +56,22 @@ export const handleNoticeMessage = ( console.error('Failed to read the pending DNS override notice', error) }) }, + 'enhance::discarded_keys': () => { + void takeDiscardedKeysNotice() + .then((keys) => { + if (keys) { + showNotice.warning('profiles.page.feedback.notices.discardedKeys', { + keys, + }) + } + }) + .catch((error) => { + console.error( + 'Failed to read the pending discarded keys 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 034d77efa..4b4ea10d5 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -186,6 +186,10 @@ export async function takeServiceFallbackNotice() { return invoke('take_service_fallback_notice') } +export async function takeDiscardedKeysNotice() { + return invoke('take_discarded_keys_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 972d28812..397e19cf4 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -186,6 +186,7 @@ export const translationKeys = [ 'profiles.page.feedback.notifications.switchBusy', 'profiles.page.feedback.notifications.batchDeleted', 'profiles.page.feedback.notices.forceRefreshCompleted', + 'profiles.page.feedback.notices.discardedKeys', 'profiles.page.feedback.notices.emergencyRefreshFailed', 'profiles.page.feedback.tooltips.forceRefreshStaleData', 'profiles.page.title', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index ad39b14ff..5f2739288 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -446,6 +446,7 @@ export interface TranslationResources { updateFailed: string } notices: { + discardedKeys: string emergencyRefreshFailed: string forceRefreshCompleted: string }