mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
586 lines
18 KiB
Rust
586 lines
18 KiB
Rust
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 once_cell::sync::OnceCell;
|
||
use regex::Regex;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_yaml_ng::Mapping;
|
||
use smartstring::alias::String;
|
||
use std::collections::{HashMap, HashSet};
|
||
use tokio::fs;
|
||
|
||
// static PROFILE_FILE_RE: OnceCell<Regex> = OnceCell::new();
|
||
|
||
/// Define the `profiles.yaml` schema
|
||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||
pub struct IProfiles {
|
||
/// same as PrfConfig.current
|
||
pub current: Option<String>,
|
||
|
||
/// profile list
|
||
pub items: Option<Vec<PrfItem>>,
|
||
}
|
||
|
||
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: usize,
|
||
pub failed_deletions: usize,
|
||
}
|
||
|
||
macro_rules! patch {
|
||
($lv: expr, $rv: expr, $key: tt) => {
|
||
if let Some(ref val) = $rv.$key {
|
||
if Some(val) != $lv.$key.as_ref() {
|
||
$lv.$key = Some(val.to_owned());
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
impl IProfiles {
|
||
pub async fn new() -> Self {
|
||
let path = match dirs::profiles_path() {
|
||
Ok(p) => p,
|
||
Err(err) => {
|
||
logging!(error, Type::Config, "{err}");
|
||
return Self::default();
|
||
}
|
||
};
|
||
|
||
let mut profiles = match help::read_yaml::<Self>(&path).await {
|
||
Ok(profiles) => profiles,
|
||
Err(err) => {
|
||
logging!(error, Type::Config, "{err}");
|
||
return Self::default();
|
||
}
|
||
};
|
||
|
||
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
|
||
}
|
||
|
||
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<PrfItem>> {
|
||
self.items.as_ref()
|
||
}
|
||
|
||
/// find the item by the uid
|
||
pub fn get_item(&self, uid: impl AsRef<str>) -> Result<&PrfItem> {
|
||
let uid_str = uid.as_ref();
|
||
|
||
self.items
|
||
.as_ref()
|
||
.ok_or_else(|| anyhow::anyhow!("no profile items found"))?
|
||
.iter()
|
||
.find(|each| each.uid.as_ref().is_some_and(|uid_val| uid_val.as_str() == uid_str))
|
||
.ok_or_else(|| anyhow::anyhow!("failed to get the profile item \"uid:{}\"", uid_str))
|
||
}
|
||
|
||
/// 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<()> {
|
||
anyhow::ensure!(item.uid.is_some(), "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() {
|
||
anyhow::ensure!(item.file.is_some(), "the file should not be null");
|
||
|
||
let file = item
|
||
.file
|
||
.as_ref()
|
||
.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()
|
||
&& let Some(t) = item.itype.as_deref()
|
||
&& (t == "remote" || t == "local")
|
||
{
|
||
self.current = item.uid.to_owned();
|
||
}
|
||
|
||
self.items.get_or_insert_default().push(std::mem::take(item));
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// reorder items
|
||
pub async fn reorder(&mut self, active_id: &str, over_id: &str) -> Result<()> {
|
||
if active_id == over_id {
|
||
return Ok(());
|
||
}
|
||
|
||
let Some(items) = self.items.as_mut() else {
|
||
return Ok(());
|
||
};
|
||
|
||
let mut old_idx = None;
|
||
let mut new_idx = None;
|
||
|
||
for (i, item) in items.iter().enumerate() {
|
||
if let Some(uid) = item.uid.as_ref() {
|
||
if uid == active_id {
|
||
old_idx = Some(i);
|
||
}
|
||
if uid == over_id {
|
||
new_idx = Some(i);
|
||
}
|
||
}
|
||
if old_idx.is_some() && new_idx.is_some() {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if let (Some(old), Some(new)) = (old_idx, new_idx) {
|
||
if old < new {
|
||
items[old..=new].rotate_left(1);
|
||
} else {
|
||
items[new..=old].rotate_right(1);
|
||
}
|
||
|
||
return self.save_file().await;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// update the item value
|
||
pub async fn patch_item(&mut self, uid: &String, item: &PrfItem) -> Result<()> {
|
||
let items = self
|
||
.items
|
||
.as_mut()
|
||
.ok_or_else(|| anyhow::anyhow!("no profile items found"))?;
|
||
|
||
let target = items.iter_mut().find(|each| each.uid.as_ref() == Some(uid));
|
||
|
||
if let Some(each) = target {
|
||
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);
|
||
|
||
return self.save_file().await;
|
||
}
|
||
|
||
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<()> {
|
||
let target = self
|
||
.items
|
||
.get_or_insert_default()
|
||
.iter_mut()
|
||
.find(|each| each.uid.as_ref() == Some(uid))
|
||
.ok_or_else(|| anyhow::anyhow!("Item not found"))?;
|
||
|
||
target.extra = item.extra;
|
||
target.updated = item.updated;
|
||
target.home = std::mem::take(&mut item.home);
|
||
target.option = PrfOption::merge(target.option.as_ref(), item.option.as_ref());
|
||
|
||
let Some(file_data) = item.file_data.take() else {
|
||
return self.save_file().await;
|
||
};
|
||
|
||
let file = target
|
||
.file
|
||
.take()
|
||
.or_else(|| item.file.take())
|
||
.unwrap_or_else(|| format!("{}.yaml", uid).into());
|
||
|
||
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}\""))?;
|
||
|
||
target.file = Some(file);
|
||
|
||
self.save_file().await
|
||
}
|
||
|
||
/// delete item
|
||
/// if delete the current then return true
|
||
pub async fn delete_item(&mut self, uid: &String) -> Result<bool> {
|
||
let uids_to_remove: HashSet<String> = {
|
||
let item = self.get_item(uid)?;
|
||
let mut set = HashSet::new();
|
||
set.insert(uid.clone());
|
||
|
||
if let Some(opt) = &item.option {
|
||
if let Some(u) = &opt.merge {
|
||
set.insert(u.clone());
|
||
}
|
||
if let Some(u) = &opt.script {
|
||
set.insert(u.clone());
|
||
}
|
||
if let Some(u) = &opt.rules {
|
||
set.insert(u.clone());
|
||
}
|
||
if let Some(u) = &opt.proxies {
|
||
set.insert(u.clone());
|
||
}
|
||
if let Some(u) = &opt.groups {
|
||
set.insert(u.clone());
|
||
}
|
||
}
|
||
set
|
||
};
|
||
|
||
let mut items = self.items.take().unwrap_or_default();
|
||
let mut deleted_files = Vec::new();
|
||
|
||
items.retain_mut(|item| {
|
||
if let Some(item_uid) = item.uid.as_ref()
|
||
&& uids_to_remove.contains(item_uid)
|
||
{
|
||
if let Some(file) = item.file.take() {
|
||
deleted_files.push(file);
|
||
}
|
||
return false;
|
||
}
|
||
true
|
||
});
|
||
|
||
let is_deleting_current = self.current.as_ref() == Some(uid);
|
||
if is_deleting_current {
|
||
self.current = items
|
||
.iter()
|
||
.find(|i| i.itype.as_deref() == Some("remote") || i.itype.as_deref() == Some("local"))
|
||
.and_then(|i| i.uid.clone());
|
||
}
|
||
|
||
self.items = Some(items);
|
||
|
||
if let Ok(profile_dir) = dirs::app_profiles_dir() {
|
||
for file in deleted_files {
|
||
let _ = profile_dir.join(file.as_str()).remove_if_exists().await;
|
||
}
|
||
}
|
||
|
||
self.save_file().await?;
|
||
Ok(is_deleting_current)
|
||
}
|
||
|
||
/// 获取current指向的订阅内容
|
||
pub async fn current_mapping(&self) -> Result<Mapping> {
|
||
let (Some(current), Some(items)) = (self.current.as_ref(), self.items.as_ref()) else {
|
||
return Ok(Mapping::new());
|
||
};
|
||
|
||
let Some(target) = items.iter().find(|e| e.uid.as_ref() == Some(current)) else {
|
||
bail!("failed to find the current profile \"uid:{current}\"");
|
||
};
|
||
|
||
let file = target
|
||
.file
|
||
.as_ref()
|
||
.ok_or_else(|| anyhow::anyhow!("failed to get the file field"))?;
|
||
let file_path = dirs::app_profiles_dir()?.join(file.as_str());
|
||
help::read_mapping(&file_path).await
|
||
}
|
||
|
||
/// 判断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<Vec<IProfilePreview<'_>>> {
|
||
let items = self.items.as_ref()?;
|
||
let current_uid = self.current.as_ref();
|
||
|
||
let previews = items
|
||
.iter()
|
||
.filter_map(|e| {
|
||
let uid = e.uid.as_ref()?;
|
||
let name = e.name.as_ref()?;
|
||
Some(IProfilePreview {
|
||
uid,
|
||
name,
|
||
is_current: current_uid == Some(uid),
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
Some(previews)
|
||
}
|
||
|
||
/// 通过 uid 获取名称
|
||
pub fn get_name_by_uid(&self, uid: &str) -> Option<&String> {
|
||
self.items
|
||
.as_ref()?
|
||
.iter()
|
||
.find(|item| item.uid.as_deref() == Some(uid))
|
||
.and_then(|item| item.name.as_ref())
|
||
}
|
||
|
||
/// 以 app 中的 profile 列表为准,删除不再需要的文件
|
||
pub async fn cleanup_orphaned_files(&self) -> Result<()> {
|
||
let profiles_dir = dirs::app_profiles_dir()?;
|
||
|
||
if !profiles_dir.exists() {
|
||
return Ok(());
|
||
}
|
||
|
||
// 获取所有 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 = 0;
|
||
let mut failed_deletions = 0;
|
||
|
||
let mut dir_entries = tokio::fs::read_dir(&profiles_dir).await?;
|
||
while let Some(entry) = dir_entries.next_entry().await? {
|
||
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 += 1;
|
||
logging!(debug, Type::Config, "已清理冗余文件: {file_name}");
|
||
}
|
||
Err(e) => {
|
||
failed_deletions += 1;
|
||
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,
|
||
result.failed_deletions
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 不删除全局扩展配置
|
||
fn get_protected_global_files(&self) -> HashSet<String> {
|
||
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::new();
|
||
let items = match &self.items {
|
||
Some(i) => i,
|
||
None => return active_files,
|
||
};
|
||
|
||
let item_map: HashMap<Option<&str>, &PrfItem> = items.iter().map(|i| (i.uid.as_deref(), i)).collect();
|
||
|
||
for item in items {
|
||
if let Some(f) = &item.file {
|
||
active_files.insert(f.as_str());
|
||
}
|
||
|
||
let Some(opt) = &item.option else {
|
||
continue;
|
||
};
|
||
|
||
let related = [
|
||
opt.merge.as_deref(),
|
||
opt.script.as_deref(),
|
||
opt.rules.as_deref(),
|
||
opt.proxies.as_deref(),
|
||
opt.groups.as_deref(),
|
||
];
|
||
|
||
for r_uid in related.into_iter().flatten() {
|
||
if let Some(r_item) = item_map.get(&Some(r_uid))
|
||
&& let Some(f) = &r_item.file
|
||
{
|
||
active_files.insert(f.as_str());
|
||
}
|
||
}
|
||
}
|
||
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<String>) -> 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<bool> {
|
||
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
|
||
}
|