Files
clash-verge-rev/src-tauri/src/enhance/mod.rs
T
Tunglies b85b435e00 refactor: structure config validation outcomes
- Replace boolean/string validation results with structured ValidationOutcome values across config validation, profile switching, DNS validation, and script validation. Callers can now distinguish valid, invalid, skipped, and busy states, with invalid results carrying an error kind and message.

- Route core config updates through structured outcomes and checked forced updates. Generation and validation failures now discard runtime drafts and surface validation failures instead of relying on empty success messages.

- Preserve existing frontend ergonomics by keeping boolean service wrappers where callers only need success, while direct DNS and script validation consumers receive ValidationOutcome. Failed profile editor saves reload the backend-restored file content and synchronize Monaco models so reopened editors show the restored state.
2026-04-25 13:31:08 +08:00

819 lines
25 KiB
Rust

mod chain;
pub mod field;
mod merge;
mod script;
pub mod seq;
mod tun;
use self::{
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
field::{use_keys, use_lowercase, use_sort},
merge::use_merge,
script::use_script,
seq::{SeqMap, use_seq},
tun::use_tun,
};
use crate::utils::dirs;
use crate::{config::Config, utils::tmpl};
use crate::{config::IVerge, constants};
use anyhow::{Context as _, Result};
use clash_verge_logging::{Type, logging};
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
use tokio::fs;
type ResultLog = Vec<(String, String)>;
#[derive(Debug)]
struct ConfigValues {
clash_config: Mapping,
clash_core: Option<String>,
enable_tun: bool,
enable_builtin: bool,
socks_enabled: bool,
http_enabled: bool,
enable_dns_settings: bool,
#[cfg(not(target_os = "windows"))]
redir_enabled: bool,
#[cfg(target_os = "linux")]
tproxy_enabled: bool,
}
#[derive(Debug)]
struct ProfileItems {
config: Mapping,
merge_item: ChainItem,
script_item: ChainItem,
rules_item: ChainItem,
proxies_item: ChainItem,
groups_item: ChainItem,
global_merge: ChainItem,
global_script: ChainItem,
profile_name: String,
}
impl Default for ProfileItems {
fn default() -> Self {
Self {
config: Default::default(),
profile_name: Default::default(),
merge_item: ChainItem {
uid: "".into(),
data: ChainType::Merge(Mapping::new()),
},
script_item: ChainItem {
uid: "".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},
rules_item: ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},
proxies_item: ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},
groups_item: ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},
global_merge: ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},
global_script: ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},
}
}
}
async fn get_config_values() -> ConfigValues {
let clash = Config::clash().await;
let clash_arc = clash.latest_arc();
let clash_config = clash_arc.0.clone();
drop(clash_arc);
drop(clash);
let verge = Config::verge().await;
let verge_arc = verge.latest_arc();
let IVerge {
ref enable_tun_mode,
ref enable_builtin_enhanced,
ref verge_socks_enabled,
ref verge_http_enabled,
ref enable_dns_settings,
..
} = *verge_arc;
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
enable_tun_mode.unwrap_or(false),
enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false),
verge_http_enabled.unwrap_or(false),
enable_dns_settings.unwrap_or(false),
);
#[cfg(not(target_os = "windows"))]
let redir_enabled = verge_arc.verge_redir_enabled.unwrap_or(false);
#[cfg(target_os = "linux")]
let tproxy_enabled = verge_arc.verge_tproxy_enabled.unwrap_or(false);
drop(verge_arc);
drop(verge);
ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
http_enabled,
enable_dns_settings,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
}
}
#[allow(clippy::cognitive_complexity)]
async fn collect_profile_items() -> Result<ProfileItems> {
let profiles = Config::profiles().await;
let profiles_arc = profiles.latest_arc();
drop(profiles);
let current_profile_uid = match profiles_arc.get_current().cloned() {
Some(uid) => uid,
None => {
drop(profiles_arc);
return Ok(ProfileItems::default());
}
};
let current = profiles_arc
.current_mapping()
.await
.with_context(|| format!("failed to read current profile \"{current_profile_uid}\""))?;
let current_item = match profiles_arc.get_item(&current_profile_uid) {
Ok(item) => item,
Err(err) => {
return Err(err).with_context(|| format!("failed to get current profile \"{current_profile_uid}\""));
}
};
let merge_uid = current_item.current_merge().cloned().unwrap_or_else(|| "Merge".into());
let script_uid = current_item
.current_script()
.cloned()
.unwrap_or_else(|| "Script".into());
let rules_uid = current_item.current_rules().cloned().unwrap_or_else(|| "Rules".into());
let proxies_uid = current_item
.current_proxies()
.cloned()
.unwrap_or_else(|| "Proxies".into());
let groups_uid = current_item
.current_groups()
.cloned()
.unwrap_or_else(|| "Groups".into());
let name = current_item.name.clone().unwrap_or_default();
let merge_item = {
let item = profiles_arc.get_item(&merge_uid).ok().cloned();
if let Some(item) = item {
<Option<ChainItem>>::from_async(&item).await
} else {
None
}
}
.unwrap_or_else(|| ChainItem {
uid: "".into(),
data: ChainType::Merge(Mapping::new()),
});
let script_item = {
let item = profiles_arc.get_item(&script_uid).ok().cloned();
if let Some(item) = item {
<Option<ChainItem>>::from_async(&item).await
} else {
None
}
}
.unwrap_or_else(|| ChainItem {
uid: "".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
});
let rules_item = {
let item = profiles_arc.get_item(&rules_uid).ok().cloned();
if let Some(item) = item {
<Option<ChainItem>>::from_async(&item).await
} else {
None
}
}
.unwrap_or_else(|| ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
});
let proxies_item = {
let item = profiles_arc.get_item(&proxies_uid).ok().cloned();
if let Some(item) = item {
<Option<ChainItem>>::from_async(&item).await
} else {
None
}
}
.unwrap_or_else(|| ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
});
let groups_item = {
let item = profiles_arc.get_item(&groups_uid).ok().cloned();
if let Some(item) = item {
<Option<ChainItem>>::from_async(&item).await
} else {
None
}
}
.unwrap_or_else(|| ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
});
let global_merge = {
let item = profiles_arc.get_item("Merge").ok().cloned();
if let Some(item) = item {
<Option<ChainItem>>::from_async(&item).await
} else {
None
}
}
.unwrap_or_else(|| ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
});
let global_script = {
let item = profiles_arc.get_item("Script").ok().cloned();
if let Some(item) = item {
<Option<ChainItem>>::from_async(&item).await
} else {
None
}
}
.unwrap_or_else(|| ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
});
drop(profiles_arc);
Ok(ProfileItems {
config: current,
merge_item,
script_item,
rules_item,
proxies_item,
groups_item,
global_merge,
global_script,
profile_name: name,
})
}
async fn process_global_items(
mut config: Mapping,
global_merge: ChainItem,
global_script: ChainItem,
profile_name: &String,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
let mut result_map = HashMap::new();
let mut exists_keys = use_keys(&config).collect::<Vec<_>>();
if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge));
config = use_merge(&merge, config.to_owned());
}
if let ChainType::Script(script) = global_script.data {
let mut logs = vec![];
match use_script(script, config.clone(), profile_name.clone()).await {
Ok((res_config, res_logs)) => {
exists_keys.extend(use_keys(&res_config));
config = res_config;
logs.extend(res_logs);
}
Err(err) => logs.push(("exception".into(), err.to_string().into())),
}
result_map.insert(global_script.uid, logs);
}
(config, exists_keys, result_map)
}
#[allow(clippy::too_many_arguments)]
async fn process_profile_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
rules_item: ChainItem,
proxies_item: ChainItem,
groups_item: ChainItem,
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Rules(rules) = rules_item.data {
config = use_seq(rules, config.to_owned(), "rules");
}
if let ChainType::Proxies(proxies) = proxies_item.data {
config = use_seq(proxies, config.to_owned(), "proxies");
}
if let ChainType::Groups(groups) = groups_item.data {
config = use_seq(groups, config.to_owned(), "proxy-groups");
}
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
config = use_merge(&merge, config.to_owned());
}
if let ChainType::Script(script) = script_item.data {
let mut logs = vec![];
match use_script(script, config.clone(), profile_name.clone()).await {
Ok((res_config, res_logs)) => {
exists_keys.extend(use_keys(&res_config));
config = res_config;
logs.extend(res_logs);
}
Err(err) => logs.push(("exception".into(), err.to_string().into())),
}
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
async fn merge_default_config(
mut config: Mapping,
clash_config: Mapping,
socks_enabled: bool,
http_enabled: bool,
#[cfg(not(target_os = "windows"))] redir_enabled: bool,
#[cfg(target_os = "linux")] tproxy_enabled: bool,
) -> Mapping {
for (key, value) in clash_config.into_iter() {
if key.as_str() == Some("tun") {
let mut tun = config.get_mut("tun").map_or_else(Mapping::new, |val| {
val.as_mapping().cloned().unwrap_or_else(Mapping::new)
});
let patch_tun = value.as_mapping().cloned().unwrap_or_else(Mapping::new);
for (key, value) in patch_tun.into_iter() {
tun.insert(key, value);
}
config.insert("tun".into(), tun.into());
} else {
if key.as_str() == Some("socks-port") && !socks_enabled {
config.remove("socks-port");
continue;
}
if key.as_str() == Some("port") && !http_enabled {
config.remove("port");
continue;
}
#[cfg(target_os = "windows")]
{
if key.as_str() == Some("redir-port") {
continue;
}
}
#[cfg(not(target_os = "windows"))]
{
if key.as_str() == Some("redir-port") && !redir_enabled {
config.remove("redir-port");
continue;
}
}
#[cfg(target_os = "linux")]
{
if key.as_str() == Some("tproxy-port") && !tproxy_enabled {
config.remove("tproxy-port");
continue;
}
}
#[cfg(not(target_os = "linux"))]
{
if key.as_str() == Some("tproxy-port") {
config.remove("tproxy-port");
continue;
}
}
// 处理 external-controller 键的开关逻辑
if key.as_str() == Some("external-controller") {
let enable_external_controller = Config::verge()
.await
.latest_arc()
.enable_external_controller
.unwrap_or(false);
if enable_external_controller {
config.insert(key, value);
} else {
// 如果禁用了外部控制器,设置为空字符串
config.insert(key, "".into());
}
} else {
config.insert(key, value);
}
}
}
config
}
async fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping {
if enable_builtin {
let items: Vec<_> = ChainItem::builtin()
.into_iter()
.filter(|(s, _)| s.is_support(clash_core.as_ref()))
.map(|(_, c)| c)
.collect();
for item in items {
logging!(debug, Type::Core, "run builtin script {}", item.uid);
if let ChainType::Script(script) = item.data {
match use_script(script, config.clone(), String::from("")).await {
Ok((res_config, _)) => {
config = res_config;
}
Err(err) => {
logging!(error, Type::Core, "builtin script error `{err}`");
}
}
}
}
}
config
}
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
let proxy_names = config
.get("proxies")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| match item {
Value::Mapping(map) => map
.get("name")
.and_then(Value::as_str)
.map(|name| name.to_owned().into()),
Value::String(name) => Some(name.to_owned().into()),
_ => None,
})
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let group_names = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| {
item.as_mapping()
.and_then(|map| map.get("name"))
.and_then(Value::as_str)
.map(std::convert::Into::into)
})
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let provider_names = config
.get("proxy-providers")
.and_then(Value::as_mapping)
.map(|map| {
map.keys()
.filter_map(Value::as_str)
.map(std::convert::Into::into)
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let mut allowed_names = proxy_names;
allowed_names.extend(group_names);
allowed_names.extend(provider_names.iter().cloned());
allowed_names.extend(BUILTIN_POLICIES.iter().map(|p| (*p).into()));
if let Some(Value::Sequence(groups)) = config.get_mut("proxy-groups") {
for group in groups {
if let Some(group_map) = group.as_mapping_mut() {
let mut has_valid_provider = false;
if let Some(Value::Sequence(uses)) = group_map.get_mut("use") {
uses.retain(|provider| match provider {
Value::String(name) => {
let exists = provider_names.contains(name.as_str());
has_valid_provider = has_valid_provider || exists;
exists
}
_ => false,
});
}
if let Some(Value::Sequence(proxies)) = group_map.get_mut("proxies") {
proxies.retain(|proxy| match proxy {
Value::String(name) => allowed_names.contains(name.as_str()) || has_valid_provider,
_ => true,
});
}
}
}
}
config
}
async fn apply_dns_settings(mut config: Mapping, enable_dns_settings: bool) -> Mapping {
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)
{
if let Some(hosts_value) = dns_config.get("hosts")
&& hosts_value.is_mapping()
{
config.insert("hosts".into(), hosts_value.clone());
logging!(info, Type::Core, "apply hosts configuration");
}
if let Some(dns_value) = dns_config.get("dns") {
if let Some(dns_mapping) = dns_value.as_mapping() {
config.insert("dns".into(), dns_mapping.clone().into());
logging!(info, Type::Core, "apply dns_config.yaml (dns section)");
}
} else {
config.insert("dns".into(), dns_config.into());
logging!(info, Type::Core, "apply dns_config.yaml");
}
}
}
config
}
/// Enhance mode
/// 返回最终订阅、该订阅包含的键、和script执行的结果
pub async fn enhance() -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>)> {
// gather config values
let cfg_vals = get_config_values().await;
let ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
http_enabled,
enable_dns_settings,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
} = cfg_vals;
// collect profile items
let profile = collect_profile_items().await?;
let config = profile.config;
let merge_item = profile.merge_item;
let script_item = profile.script_item;
let rules_item = profile.rules_item;
let proxies_item = profile.proxies_item;
let groups_item = profile.groups_item;
let global_merge = profile.global_merge;
let global_script = profile.global_script;
let profile_name = profile.profile_name;
// process globals
let (config, exists_keys, result_map) =
process_global_items(config, global_merge, global_script, &profile_name).await;
// process profile-specific items
let (config, exists_keys, result_map) = process_profile_items(
config,
exists_keys,
result_map,
rules_item,
proxies_item,
groups_item,
merge_item,
script_item,
&profile_name,
)
.await;
// merge default clash config
let config = merge_default_config(
config,
clash_config,
socks_enabled,
http_enabled,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
)
.await;
// builtin scripts
let mut config = apply_builtin_scripts(config, clash_core, enable_builtin).await;
config = cleanup_proxy_groups(config);
config = use_tun(config, enable_tun);
config = use_sort(config);
// dns settings
config = apply_dns_settings(config, enable_dns_settings).await;
let mut exists_keys_set = HashSet::new();
exists_keys_set.extend(exists_keys);
Ok((config, exists_keys_set, result_map))
}
#[allow(clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::cleanup_proxy_groups;
#[test]
fn remove_missing_proxies_from_groups() {
let config_str = r#"
proxies:
- name: "alive-node"
type: ss
proxy-groups:
- name: "manual"
type: select
proxies:
- "alive-node"
- "missing-node"
- "DIRECT"
- name: "nested"
type: select
proxies:
- "manual"
- "ghost"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let manual_proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("manual proxies should be a sequence");
assert_eq!(manual_proxies.len(), 2);
assert!(manual_proxies.iter().any(|p| p.as_str() == Some("alive-node")));
assert!(manual_proxies.iter().any(|p| p.as_str() == Some("DIRECT")));
let nested_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("nested"))
.and_then(|group| group.as_mapping())
.expect("nested group should exist");
let nested_proxies = nested_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("nested proxies should be a sequence");
assert_eq!(nested_proxies.len(), 1);
assert_eq!(nested_proxies[0].as_str(), Some("manual"));
}
#[test]
fn keep_provider_backed_groups_intact() {
let config_str = r#"
proxy-providers:
providerA:
type: http
url: https://example.com
path: ./providerA.yaml
proxies: []
proxy-groups:
- name: "manual"
type: select
use:
- "providerA"
- "ghostProvider"
proxies:
- "dynamic-node"
- "DIRECT"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let uses = manual_group
.get("use")
.and_then(|v| v.as_sequence())
.expect("use should be a sequence");
assert_eq!(uses.len(), 1);
assert_eq!(uses[0].as_str(), Some("providerA"));
let proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("proxies should be a sequence");
assert_eq!(proxies.len(), 2);
assert!(proxies.iter().any(|p| p.as_str() == Some("dynamic-node")));
assert!(proxies.iter().any(|p| p.as_str() == Some("DIRECT")));
}
#[test]
fn prune_invalid_provider_and_proxies_without_provider() {
let config_str = r#"
proxy-groups:
- name: "manual"
type: select
use:
- "ghost-provider"
proxies:
- "ghost-node"
- "DIRECT"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let uses = manual_group
.get("use")
.and_then(|v| v.as_sequence())
.expect("use should be a sequence");
assert_eq!(uses.len(), 0);
let proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("proxies should be a sequence");
assert_eq!(proxies.len(), 1);
assert_eq!(proxies[0].as_str(), Some("DIRECT"));
}
}