use super::{PrfOption, prfitem::PrfItem}; use crate::utils::{ dirs::{self, PathBufExec as _}, help, }; use anyhow::{Context as _, Result, bail}; use clash_verge_logging::{Type, logging}; use serde::{Deserialize, Serialize}; use serde_yaml_ng::Mapping; use smartstring::alias::String; use std::{collections::HashSet, sync::Arc}; use tokio::fs; /// Define the `profiles.yaml` schema #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct IProfiles { /// same as PrfConfig.current pub current: Option, /// profile list pub items: Option>, } pub struct IProfilePreview<'a> { pub uid: &'a String, pub name: &'a String, pub is_current: bool, } /// 清理结果 #[derive(Debug, Clone)] pub struct CleanupResult { pub total_files: usize, pub deleted_files: Vec, pub failed_deletions: Vec, } macro_rules! patch { ($lv: expr, $rv: expr, $key: tt) => { if ($rv.$key).is_some() { $lv.$key = $rv.$key.to_owned(); } }; } impl IProfiles { // Helper to find and remove an item by uid from the items vec, returning its file name (if any). fn take_item_file_by_uid( items: &mut Vec, target_uid: Option, ) -> Option { for (i, _) in items.iter().enumerate() { if items[i].uid == target_uid { return items.remove(i).file; } } None } pub async fn new() -> Self { let path = match dirs::profiles_path() { Ok(p) => p, Err(err) => { logging!(error, Type::Config, "{err}"); return Self::default(); } }; match help::read_yaml::(&path).await { Ok(mut profiles) => { let items = profiles.items.get_or_insert_with(Vec::new); for item in items.iter_mut() { if item.uid.is_none() { item.uid = Some(help::get_uid("d").into()); } } profiles } Err(err) => { logging!(error, Type::Config, "{err}"); Self::default() } } } pub async fn save_file(&self) -> Result<()> { help::save_yaml( &dirs::profiles_path()?, self, Some("# Profiles Config for Clash Verge"), ) .await } /// 只修改current,valid和chain pub fn patch_config(&mut self, patch: &Self) { if self.items.is_none() { self.items = Some(vec![]); } if let Some(current) = &patch.current && let Some(items) = self.items.as_ref() { let some_uid = Some(current); if items.iter().any(|e| e.uid.as_ref() == some_uid) { self.current = some_uid.cloned(); } } } pub const fn get_current(&self) -> Option<&String> { self.current.as_ref() } /// get items ref pub const fn get_items(&self) -> Option<&Vec> { self.items.as_ref() } /// find the item by the uid pub fn get_item(&self, uid: impl AsRef) -> Result<&PrfItem> { let uid_str = uid.as_ref(); if let Some(items) = self.items.as_ref() { for each in items.iter() { if let Some(uid_val) = &each.uid && uid_val.as_str() == uid_str { return Ok(each); } } } bail!("failed to get the profile item \"uid:{}\"", uid_str); } pub fn get_item_arc(&self, uid: &str) -> Option> { self.items.as_ref().and_then(|items| { items .iter() .find(|it| it.uid.as_deref() == Some(uid)) .map(|it| Arc::new(it.clone())) }) } /// append new item /// if the file_data is some /// then should save the data to file pub async fn append_item(&mut self, item: &mut PrfItem) -> Result<()> { let uid = &item.uid; if uid.is_none() { bail!("the uid should not be null"); } // save the file data // move the field value after save if let Some(file_data) = item.file_data.take() { if item.file.is_none() { bail!("the file should not be null"); } let file = item.file.clone().ok_or_else(|| { anyhow::anyhow!("file field is required when file_data is provided") })?; let path = dirs::app_profiles_dir()?.join(file.as_str()); fs::write(&path, file_data.as_bytes()) .await .with_context(|| format!("failed to write to file \"{file}\""))?; } if self.current.is_none() && (item.itype == Some("remote".into()) || item.itype == Some("local".into())) { self.current = uid.to_owned(); } if self.items.is_none() { self.items = Some(vec![]); } if let Some(items) = self.items.as_mut() { items.push(item.to_owned()); } Ok(()) } /// reorder items pub async fn reorder(&mut self, active_id: &String, over_id: &String) -> Result<()> { let mut items = self.items.take().unwrap_or_default(); let mut old_index = None; let mut new_index = None; for (i, _) in items.iter().enumerate() { if items[i].uid.as_ref() == Some(active_id) { old_index = Some(i); } if items[i].uid.as_ref() == Some(over_id) { new_index = Some(i); } } let (old_idx, new_idx) = match (old_index, new_index) { (Some(old), Some(new)) => (old, new), _ => return Ok(()), }; let item = items.remove(old_idx); items.insert(new_idx, item); self.items = Some(items); self.save_file().await } /// update the item value pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> { let mut items = self.items.take().unwrap_or_default(); for each in items.iter_mut() { if each.uid.as_ref() == Some(uid) { patch!(each, item, itype); patch!(each, item, name); patch!(each, item, desc); patch!(each, item, file); patch!(each, item, url); patch!(each, item, selected); patch!(each, item, extra); patch!(each, item, updated); patch!(each, item, option); self.items = Some(items); return self.save_file().await; } } self.items = Some(items); bail!("failed to find the profile item \"uid:{uid}\"") } /// be used to update the remote item /// only patch `updated` `extra` `file_data` pub async fn update_item(&mut self, uid: &String, item: &mut PrfItem) -> Result<()> { if self.items.is_none() { self.items = Some(vec![]); } // find the item let _ = self.get_item(uid)?; if let Some(items) = self.items.as_mut() { let some_uid = Some(uid.clone()); for each in items.iter_mut() { if each.uid == some_uid { each.extra = item.extra; each.updated = item.updated; each.home = item.home.to_owned(); each.option = PrfOption::merge(each.option.as_ref(), item.option.as_ref()); // save the file data // move the field value after save if let Some(file_data) = item.file_data.take() { let file = each.file.take(); let file = file.unwrap_or_else(|| { item.file .take() .unwrap_or_else(|| format!("{}.yaml", &uid).into()) }); // the file must exists each.file = Some(file.clone()); let path = dirs::app_profiles_dir()?.join(file.as_str()); fs::write(&path, file_data.as_bytes()) .await .with_context(|| format!("failed to write to file \"{file}\""))?; } break; } } } self.save_file().await } /// delete item /// if delete the current then return true pub async fn delete_item(&mut self, uid: &String) -> Result { let current = self.current.as_ref().unwrap_or(uid); let current = current.clone(); let item = self.get_item(uid)?; let merge_uid = item.option.as_ref().and_then(|e| e.merge.clone()); let script_uid = item.option.as_ref().and_then(|e| e.script.clone()); let rules_uid = item.option.as_ref().and_then(|e| e.rules.clone()); let proxies_uid = item.option.as_ref().and_then(|e| e.proxies.clone()); let groups_uid = item.option.as_ref().and_then(|e| e.groups.clone()); let mut items = self.items.take().unwrap_or_default(); // remove the main item (if exists) and delete its file if let Some(file) = Self::take_item_file_by_uid(&mut items, Some(uid.clone())) { let _ = dirs::app_profiles_dir()? .join(file.as_str()) .remove_if_exists() .await; } // remove related extension items (merge, script, rules, proxies, groups) if let Some(file) = Self::take_item_file_by_uid(&mut items, merge_uid.clone()) { let _ = dirs::app_profiles_dir()? .join(file.as_str()) .remove_if_exists() .await; } if let Some(file) = Self::take_item_file_by_uid(&mut items, script_uid.clone()) { let _ = dirs::app_profiles_dir()? .join(file.as_str()) .remove_if_exists() .await; } if let Some(file) = Self::take_item_file_by_uid(&mut items, rules_uid.clone()) { let _ = dirs::app_profiles_dir()? .join(file.as_str()) .remove_if_exists() .await; } if let Some(file) = Self::take_item_file_by_uid(&mut items, proxies_uid.clone()) { let _ = dirs::app_profiles_dir()? .join(file.as_str()) .remove_if_exists() .await; } if let Some(file) = Self::take_item_file_by_uid(&mut items, groups_uid.clone()) { let _ = dirs::app_profiles_dir()? .join(file.as_str()) .remove_if_exists() .await; } // delete the original uid if current == *uid { self.current = None; for item in items.iter() { if item.itype == Some("remote".into()) || item.itype == Some("local".into()) { self.current = item.uid.clone(); break; } } } self.items = Some(items); self.save_file().await?; Ok(current == *uid) } /// 获取current指向的订阅内容 pub async fn current_mapping(&self) -> Result { match (self.current.as_ref(), self.items.as_ref()) { (Some(current), Some(items)) => { if let Some(item) = items.iter().find(|e| e.uid.as_ref() == Some(current)) { let file_path = match item.file.as_ref() { Some(file) => dirs::app_profiles_dir()?.join(file.as_str()), None => bail!("failed to get the file field"), }; return help::read_mapping(&file_path).await; } bail!("failed to find the current profile \"uid:{current}\""); } _ => Ok(Mapping::new()), } } /// 判断profile是否是current指向的 pub fn is_current_profile_index(&self, index: &String) -> bool { self.current.as_ref() == Some(index) } /// 获取所有的profiles(uid,名称, 是否为 current) pub fn profiles_preview(&self) -> Option>> { self.items.as_ref().map(|items| { items .iter() .filter_map(|e| { if let (Some(uid), Some(name)) = (e.uid.as_ref(), e.name.as_ref()) { let is_current = self.is_current_profile_index(uid); let preview = IProfilePreview { uid, name, is_current, }; Some(preview) } else { None } }) .collect() }) } /// 通过 uid 获取名称 pub fn get_name_by_uid(&self, uid: &String) -> Option<&String> { if let Some(items) = &self.items { for item in items { if item.uid.as_ref() == Some(uid) { return item.name.as_ref(); } } } None } /// 以 app 中的 profile 列表为准,删除不再需要的文件 pub async fn cleanup_orphaned_files(&self) -> Result { let profiles_dir = dirs::app_profiles_dir()?; if !profiles_dir.exists() { return Ok(CleanupResult { total_files: 0, deleted_files: vec![], failed_deletions: vec![], }); } // 获取所有 active profile 的文件名集合 let active_files = self.get_all_active_files(); // 添加全局扩展配置文件到保护列表 let protected_files = self.get_protected_global_files(); // 扫描 profiles 目录下的所有文件 let mut total_files = 0; let mut deleted_files = vec![]; let mut failed_deletions = vec![]; for entry in std::fs::read_dir(&profiles_dir)? { let entry = entry?; let path = entry.path(); if !path.is_file() { continue; } total_files += 1; if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) && Self::is_profile_file(file_name) { // 检查是否为全局扩展文件 if protected_files.contains(file_name) { logging!(debug, Type::Config, "保护全局扩展配置文件: {file_name}"); continue; } // 检查是否为活跃文件 if !active_files.contains(file_name) { match path.to_path_buf().remove_if_exists().await { Ok(_) => { deleted_files.push(file_name.into()); logging!(info, Type::Config, "已清理冗余文件: {file_name}"); } Err(e) => { failed_deletions.push(format!("{file_name}: {e}").into()); logging!( warn, Type::Config, "Warning: 清理文件失败: {file_name} - {e}" ); } } } } } let result = CleanupResult { total_files, deleted_files, failed_deletions, }; logging!( info, Type::Config, "Profile 文件清理完成: 总文件数={}, 删除文件数={}, 失败数={}", result.total_files, result.deleted_files.len(), result.failed_deletions.len() ); Ok(result) } /// 不删除全局扩展配置 fn get_protected_global_files(&self) -> HashSet { let mut protected_files = HashSet::new(); protected_files.insert("Merge.yaml".into()); protected_files.insert("Script.js".into()); protected_files } /// 获取所有 active profile 关联的文件名 fn get_all_active_files(&self) -> HashSet<&str> { let mut active_files: HashSet<&str> = HashSet::new(); if let Some(items) = &self.items { for item in items { // 收集所有类型 profile 的文件 if let Some(file) = &item.file { active_files.insert(file); } // 对于主 profile 类型(remote/local),还需要收集其关联的扩展文件 if let Some(itype) = &item.itype && (itype == "remote" || itype == "local") && let Some(option) = &item.option { // 收集关联的扩展文件 if let Some(merge_uid) = &option.merge && let Ok(merge_item) = self.get_item(merge_uid) && let Some(file) = &merge_item.file { active_files.insert(file); } if let Some(script_uid) = &option.script && let Ok(script_item) = self.get_item(script_uid) && let Some(file) = &script_item.file { active_files.insert(file); } if let Some(rules_uid) = &option.rules && let Ok(rules_item) = self.get_item(rules_uid) && let Some(file) = &rules_item.file { active_files.insert(file); } if let Some(proxies_uid) = &option.proxies && let Ok(proxies_item) = self.get_item(proxies_uid) && let Some(file) = &proxies_item.file { active_files.insert(file); } if let Some(groups_uid) = &option.groups && let Ok(groups_item) = self.get_item(groups_uid) && let Some(file) = &groups_item.file { active_files.insert(file); } } } } active_files } /// 检查文件名是否符合 profile 文件的命名规则 fn is_profile_file(filename: &str) -> bool { // 匹配各种 profile 文件格式 // R12345678.yaml (remote) // L12345678.yaml (local) // m12345678.yaml (merge) // s12345678.js (script) // r12345678.yaml (rules) // p12345678.yaml (proxies) // g12345678.yaml (groups) let patterns = [ r"^[RL][a-zA-Z0-9]+\.yaml$", // Remote/Local profiles r"^m[a-zA-Z0-9]+\.yaml$", // Merge files r"^s[a-zA-Z0-9]+\.js$", // Script files r"^[rpg][a-zA-Z0-9]+\.yaml$", // Rules/Proxies/Groups files ]; patterns.iter().any(|pattern| { regex::Regex::new(pattern) .map(|re| re.is_match(filename)) .unwrap_or(false) }) } } // 特殊的Send-safe helper函数,完全避免跨await持有guard use crate::config::Config; pub async fn profiles_append_item_with_filedata_safe( item: &PrfItem, file_data: Option, ) -> Result<()> { let item = &mut PrfItem::from(item, file_data).await?; profiles_append_item_safe(item).await } pub async fn profiles_append_item_safe(item: &mut PrfItem) -> Result<()> { Config::profiles() .await .with_data_modify(|mut profiles| async move { profiles.append_item(item).await?; Ok((profiles, ())) }) .await } pub async fn profiles_patch_item_safe(index: &String, item: &PrfItem) -> Result<()> { Config::profiles() .await .with_data_modify(|mut profiles| async move { profiles.patch_item(index, item).await?; Ok((profiles, ())) }) .await } pub async fn profiles_delete_item_safe(index: &String) -> Result { Config::profiles() .await .with_data_modify(|mut profiles| async move { let deleted = profiles.delete_item(index).await?; Ok((profiles, deleted)) }) .await } pub async fn profiles_reorder_safe(active_id: &String, over_id: &String) -> Result<()> { Config::profiles() .await .with_data_modify(|mut profiles| async move { profiles.reorder(active_id, over_id).await?; Ok((profiles, ())) }) .await } pub async fn profiles_save_file_safe() -> Result<()> { Config::profiles() .await .with_data_modify(|profiles| async move { profiles.save_file().await?; Ok((profiles, ())) }) .await } pub async fn profiles_draft_update_item_safe(index: &String, item: &mut PrfItem) -> Result<()> { Config::profiles() .await .with_data_modify(|mut profiles| async move { profiles.update_item(index, item).await?; Ok((profiles, ())) }) .await }