mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-26 05:42:07 +08:00
* refactor(config): unify YAML persistence * refactor(config): remove redundant config APIs * refactor(config): remove unused config APIs * refactor(config): narrow profile APIs * fix(config): preserve profiles on invalid reorder * refactor(startup): narrow resolve helpers * refactor(config): unify mixed port defaults * refactor(network): simplify response APIs * refactor(config): make profile builders infallible * refactor(config): make profile activation infallible * refactor(api): simplify internal runtime APIs * refactor: consolidate internal config and window APIs
557 lines
19 KiB
Rust
557 lines
19 KiB
Rust
use super::{CmdResult, StringifyErr as _, WithErrorCode as _, coded_error};
|
|
use crate::cmd::validate::{ValidationNoticeTarget, handle_validation_notice};
|
|
use crate::config::profiles;
|
|
use crate::utils::window_manager::WindowManager;
|
|
use crate::{
|
|
config::{
|
|
Config, IProfiles, PrfItem, PrfOption,
|
|
profiles::{
|
|
PROFILE_WRITE_LOCK, profiles_append_item_with_filedata_safe, profiles_patch_item_safe,
|
|
profiles_reorder_safe, profiles_save_file_safe,
|
|
},
|
|
profiles_append_item_safe,
|
|
},
|
|
core::{CoreManager, handle, timer::Timer, tray::Tray, validate::ValidationOutcome},
|
|
feat,
|
|
utils::{dirs, help},
|
|
};
|
|
use clash_verge_draft::{Draft, SharedDraft};
|
|
use clash_verge_logging::{Type, logging, logging_error};
|
|
use scopeguard::defer;
|
|
use smartstring::alias::String;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
static CURRENT_SWITCHING_PROFILE: AtomicBool = AtomicBool::new(false);
|
|
|
|
fn profile_import_error(err: &anyhow::Error) -> std::string::String {
|
|
if let Some(cause) = err.chain().find(|cause| cause.to_string().contains("TLS 1.0/1.1")) {
|
|
return cause.to_string();
|
|
}
|
|
|
|
format!("导入订阅失败: {err:#}")
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_profiles() -> CmdResult<SharedDraft<IProfiles>> {
|
|
logging!(debug, Type::Cmd, "获取配置文件列表");
|
|
let draft = Config::profiles().await;
|
|
let data = draft.data_arc();
|
|
Ok(data)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn enhance_profiles() -> CmdResult<ValidationOutcome> {
|
|
match feat::enhance_profiles().await {
|
|
Ok(outcome) if outcome.is_valid() => {
|
|
handle::Handle::refresh_clash();
|
|
Ok(outcome)
|
|
}
|
|
Ok(outcome) => {
|
|
logging!(
|
|
warn,
|
|
Type::Cmd,
|
|
"Reactivate profiles command failed validation: {}",
|
|
outcome
|
|
);
|
|
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
|
|
Ok(outcome)
|
|
}
|
|
Err(e) => {
|
|
logging!(error, Type::Cmd, "{}", e);
|
|
Err(coded_error("PROFILE_ENHANCE_FAILED", e))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn import_profile(url: std::string::String, option: Option<PrfOption>) -> CmdResult {
|
|
logging!(info, Type::Cmd, "[导入订阅] 开始导入: {}", help::mask_url(&url));
|
|
|
|
let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await {
|
|
Ok(it) => {
|
|
logging!(info, Type::Cmd, "[导入订阅] 下载完成,开始保存配置");
|
|
it
|
|
}
|
|
Err(e) => {
|
|
logging!(error, Type::Cmd, "[导入订阅] 下载失败: {}", e);
|
|
return Err(coded_error("PROFILE_IMPORT_FAILED", profile_import_error(&e)));
|
|
}
|
|
};
|
|
|
|
if let Err(e) = profiles_append_item_safe(item).await {
|
|
logging!(error, Type::Cmd, "[导入订阅] 保存配置失败: {}", e);
|
|
return Err(coded_error("PROFILE_IMPORT_FAILED", e));
|
|
}
|
|
|
|
if let Err(e) = profiles_save_file_safe().await {
|
|
logging!(error, Type::Cmd, "[导入订阅] 保存配置文件失败: {}", e);
|
|
return Err(coded_error("PROFILE_IMPORT_FAILED", e));
|
|
}
|
|
logging!(info, Type::Cmd, "[导入订阅] 配置文件保存成功");
|
|
logging_error!(Type::Timer, Timer::global().refresh().await);
|
|
|
|
if let Some(uid) = &item.uid {
|
|
logging!(info, Type::Cmd, "[导入订阅] 发送配置变更通知: {}", uid);
|
|
handle::Handle::notify_profile_changed(uid);
|
|
}
|
|
|
|
logging!(info, Type::Cmd, "[导入订阅] 导入完成: {}", help::mask_url(&url));
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult {
|
|
match profiles_reorder_safe(&active_id, &over_id).await {
|
|
Ok(_) => {
|
|
logging!(info, Type::Cmd, "重新排序配置文件");
|
|
Ok(())
|
|
}
|
|
Err(err) => {
|
|
logging!(error, Type::Cmd, "重新排序配置文件失败: {}", err);
|
|
Err(coded_error("PROFILE_REORDER_FAILED", err))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_profile(item: PrfItem, file_data: Option<String>) -> CmdResult {
|
|
match profiles_append_item_with_filedata_safe(&item, file_data).await {
|
|
Ok(_) => {
|
|
profiles_save_file_safe()
|
|
.await
|
|
.with_error_code("PROFILE_CREATE_FAILED")?;
|
|
logging_error!(Type::Timer, Timer::global().refresh().await);
|
|
if let Some(uid) = &item.uid {
|
|
logging!(info, Type::Cmd, "[创建订阅] 发送配置变更通知: {}", uid);
|
|
handle::Handle::notify_profile_changed(uid);
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_profile(index: String, option: Option<PrfOption>) -> CmdResult {
|
|
match feat::update_profile(&index, option.as_ref(), true, true, true).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => {
|
|
logging!(error, Type::Cmd, "{}", e);
|
|
Err(coded_error("PROFILE_UPDATE_FAILED", e))
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_profile(index: String) -> CmdResult {
|
|
let profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
|
|
|
|
let profiles = Config::profiles().await;
|
|
let result = profiles
|
|
.with_data_modify(|mut candidate| async move {
|
|
let original = candidate.clone();
|
|
let (should_update, plan) = candidate.plan_delete_item(&index)?;
|
|
let guard = if should_update {
|
|
match CoreManager::global()
|
|
.update_config_forced_with_profiles(&candidate, &original)
|
|
.await?
|
|
{
|
|
Ok(guard) => Some(guard),
|
|
Err(outcome) => return Ok((original, Err(outcome))),
|
|
}
|
|
} else {
|
|
candidate.save_file().await?;
|
|
None
|
|
};
|
|
let current = candidate.current.clone();
|
|
plan.cleanup().await;
|
|
Ok((candidate, Ok((should_update, current, guard))))
|
|
})
|
|
.await
|
|
.with_error_code("PROFILE_DELETE_FAILED")?;
|
|
let (should_update, current, config_update_guard) = match result {
|
|
Ok(result) => result,
|
|
Err(outcome) => {
|
|
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
|
|
return Err(coded_error("PROFILE_DELETE_FAILED", outcome));
|
|
}
|
|
};
|
|
if should_update {
|
|
profiles::activate_selected_nodes();
|
|
}
|
|
drop(config_update_guard);
|
|
drop(profile_write_guard);
|
|
|
|
if let Err(e) = Tray::global().update_tooltip().await {
|
|
logging!(warn, Type::Cmd, "Warning: 异步更新托盘提示失败: {e}");
|
|
}
|
|
|
|
if let Err(e) = Tray::global().update_menu().await {
|
|
logging!(warn, Type::Cmd, "Warning: 异步更新托盘菜单失败: {e}");
|
|
}
|
|
if should_update {
|
|
handle::Handle::refresh_clash();
|
|
if let Some(current) = current.as_ref() {
|
|
logging!(info, Type::Cmd, "[删除订阅] 发送配置变更通知: {}", current);
|
|
handle::Handle::notify_profile_changed(current);
|
|
}
|
|
}
|
|
logging_error!(Type::Timer, Timer::global().refresh().await);
|
|
Ok(())
|
|
}
|
|
|
|
async fn restore_previous_profile(prev_profile: &String) -> CmdResult<()> {
|
|
logging!(info, Type::Cmd, "尝试恢复到之前的配置: {}", prev_profile);
|
|
let restore_profiles = IProfiles {
|
|
current: Some(prev_profile.to_owned()),
|
|
items: None,
|
|
};
|
|
Config::profiles()
|
|
.await
|
|
.edit_draft(|d| d.patch_config(&restore_profiles));
|
|
Config::profiles().await.apply();
|
|
crate::process::AsyncHandler::spawn(|| async move {
|
|
if let Err(e) = profiles_save_file_safe().await {
|
|
logging!(warn, Type::Cmd, "Warning: 异步保存恢复配置文件失败: {e}");
|
|
}
|
|
});
|
|
logging!(info, Type::Cmd, "成功恢复到之前的配置");
|
|
Ok(())
|
|
}
|
|
|
|
async fn commit_current_profile(profiles: &Draft<IProfiles>, current: Option<String>) -> anyhow::Result<()> {
|
|
profiles.discard();
|
|
let Some(current) = current else {
|
|
return Ok(());
|
|
};
|
|
|
|
profiles
|
|
.with_data_modify(|mut committed| async move {
|
|
committed.patch_config(&IProfiles {
|
|
current: Some(current),
|
|
items: None,
|
|
});
|
|
Ok((committed, ()))
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn handle_success(current_value: Option<&String>) -> CmdResult<ValidationOutcome> {
|
|
commit_current_profile(&Config::profiles().await, current_value.cloned())
|
|
.await
|
|
.stringify_err()?;
|
|
// Runtime refresh and tray rebuilding happen after saved node selections are restored.
|
|
profiles::activate_selected_nodes();
|
|
|
|
if let Err(e) = profiles_save_file_safe().await {
|
|
logging!(warn, Type::Cmd, "Warning: 异步保存配置文件失败: {e}");
|
|
}
|
|
|
|
if let Some(current) = current_value
|
|
&& WindowManager::get_main_window().is_some()
|
|
{
|
|
logging!(info, Type::Cmd, "向前端发送配置变更事件: {}", current);
|
|
handle::Handle::notify_profile_changed(current);
|
|
}
|
|
|
|
Ok(ValidationOutcome::Valid)
|
|
}
|
|
|
|
async fn discard_and_restore(current_profile: Option<&String>) -> CmdResult<()> {
|
|
Config::profiles().await.discard();
|
|
if let Some(prev_profile) = current_profile {
|
|
restore_previous_profile(prev_profile).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_validation_failure(
|
|
outcome: ValidationOutcome,
|
|
current_profile: Option<&String>,
|
|
) -> CmdResult<ValidationOutcome> {
|
|
logging!(warn, Type::Cmd, "配置验证失败: {}", outcome);
|
|
discard_and_restore(current_profile).await?;
|
|
handle_validation_notice(&outcome, ValidationNoticeTarget::Runtime, "运行时配置");
|
|
Ok(outcome)
|
|
}
|
|
|
|
async fn handle_update_error<E: std::fmt::Display>(
|
|
e: E,
|
|
current_profile: Option<&String>,
|
|
) -> CmdResult<ValidationOutcome> {
|
|
logging!(warn, Type::Cmd, "更新过程发生错误: {}", e,);
|
|
discard_and_restore(current_profile).await?;
|
|
let message: String = e.to_string().into();
|
|
handle::Handle::notice_message("config_validate::boot_error", message.clone());
|
|
Ok(ValidationOutcome::invalid_from_message(message))
|
|
}
|
|
|
|
async fn run_profile_config_update_transition<Update, UpdateFuture>(
|
|
update_config: Update,
|
|
) -> anyhow::Result<ValidationOutcome>
|
|
where
|
|
Update: FnOnce() -> UpdateFuture,
|
|
UpdateFuture: std::future::Future<Output = anyhow::Result<ValidationOutcome>>,
|
|
{
|
|
update_config().await
|
|
}
|
|
|
|
async fn perform_config_update(
|
|
current_value: Option<&String>,
|
|
current_profile: Option<&String>,
|
|
) -> CmdResult<ValidationOutcome> {
|
|
defer! {
|
|
CURRENT_SWITCHING_PROFILE.store(false, Ordering::Release);
|
|
}
|
|
let update_result = run_profile_config_update_transition(|| CoreManager::global().update_config_forced()).await;
|
|
|
|
match update_result {
|
|
Ok(outcome) if outcome.is_valid() => handle_success(current_value).await,
|
|
Ok(outcome) => handle_validation_failure(outcome, current_profile).await,
|
|
Err(e) => handle_update_error(e, current_profile).await,
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult<ValidationOutcome> {
|
|
if CURRENT_SWITCHING_PROFILE
|
|
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
|
.is_err()
|
|
{
|
|
logging!(info, Type::Cmd, "当前正在切换配置,放弃请求");
|
|
return Ok(ValidationOutcome::Busy);
|
|
}
|
|
let _profile_write_guard = PROFILE_WRITE_LOCK.lock().await;
|
|
|
|
let target_profile = profiles.current.as_ref();
|
|
|
|
logging!(info, Type::Cmd, "开始修改配置文件,目标profile: {:?}", target_profile);
|
|
|
|
let previous_profile = Config::profiles().await.data_arc().current.clone();
|
|
logging!(info, Type::Cmd, "当前配置: {:?}", previous_profile);
|
|
|
|
Config::profiles().await.edit_draft(|d| d.patch_config(&profiles));
|
|
|
|
perform_config_update(target_profile, previous_profile.as_ref())
|
|
.await
|
|
.map_err(|error| coded_error("PROFILE_SWITCH_FAILED", error))
|
|
}
|
|
|
|
pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult<ValidationOutcome> {
|
|
logging!(info, Type::Cmd, "切换配置到: {}", profile_index);
|
|
|
|
let profiles = IProfiles {
|
|
current: Some(profile_index),
|
|
items: None,
|
|
};
|
|
patch_profiles_config(profiles).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
|
|
let profiles = Config::profiles().await;
|
|
let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index)
|
|
&& let Some(new_option) = profile.option.as_ref()
|
|
{
|
|
let old_interval = old_profile.option.as_ref().and_then(|o| o.update_interval);
|
|
let new_interval = new_option.update_interval;
|
|
let old_allow_auto_update = old_profile.option.as_ref().and_then(|o| o.allow_auto_update);
|
|
let new_allow_auto_update = new_option.allow_auto_update;
|
|
(old_interval != new_interval) || (old_allow_auto_update != new_allow_auto_update)
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Prevent an in-flight restore from overwriting a newer UI or chain selection.
|
|
let records_a_selection = profile.selected.is_some();
|
|
|
|
profiles_patch_item_safe(&index, &profile)
|
|
.await
|
|
.with_error_code("PROFILE_UPDATE_FAILED")?;
|
|
|
|
if records_a_selection {
|
|
profiles::supersede_selected_activation();
|
|
}
|
|
|
|
if should_refresh_timer {
|
|
crate::process::AsyncHandler::spawn(move || async move {
|
|
logging!(info, Type::Timer, "Timer update settings changed, refreshing timer...");
|
|
if let Err(e) = crate::core::Timer::global().refresh().await {
|
|
logging!(error, Type::Timer, "Failed to refresh timer: {}", e);
|
|
} else {
|
|
crate::core::handle::Handle::notify_timer_updated(&index);
|
|
}
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn view_profile(index: String) -> CmdResult {
|
|
let profiles = Config::profiles().await;
|
|
let profiles_ref = profiles.latest_arc();
|
|
let file = profiles_ref
|
|
.get_item(&index)
|
|
.with_error_code("PROFILE_OPEN_FAILED")?
|
|
.file
|
|
.as_ref()
|
|
.ok_or_else(|| coded_error("PROFILE_OPEN_FAILED", "the file field is null"))?;
|
|
|
|
let path = dirs::app_profiles_dir()
|
|
.with_error_code("PROFILE_OPEN_FAILED")?
|
|
.join(file.as_str());
|
|
if !path.exists() {
|
|
return CmdResult::Err(coded_error(
|
|
"PROFILE_OPEN_FAILED",
|
|
format!("file not found \"{}\"", path.display()),
|
|
));
|
|
}
|
|
|
|
help::open_file(path).with_error_code("PROFILE_OPEN_FAILED")
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn read_profile_file(index: String) -> CmdResult<String> {
|
|
let item = {
|
|
let profiles = Config::profiles().await;
|
|
let profiles_ref = profiles.latest_arc();
|
|
PrfItem {
|
|
file: profiles_ref
|
|
.get_item(&index)
|
|
.with_error_code("PROFILE_READ_FAILED")?
|
|
.file
|
|
.to_owned(),
|
|
..Default::default()
|
|
}
|
|
};
|
|
|
|
if let Some(file) = item.file.as_ref() {
|
|
let path = dirs::app_profiles_dir()
|
|
.with_error_code("PROFILE_READ_FAILED")?
|
|
.join(file.as_str());
|
|
match tokio::fs::try_exists(&path).await {
|
|
Ok(true) => {}
|
|
Ok(false) => return Ok(String::new()),
|
|
Err(err) => {
|
|
return Err(coded_error(
|
|
"PROFILE_READ_FAILED",
|
|
format!("failed to check profile file \"{}\": {err}", path.display()),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?;
|
|
Ok(data)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_next_update_time(uid: String) -> CmdResult<Option<i64>> {
|
|
let timer = Timer::global();
|
|
let next_time = timer.get_next_update_time(&uid).await;
|
|
Ok(next_time)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{commit_current_profile, run_profile_config_update_transition};
|
|
use crate::config::{IProfiles, PrfItem};
|
|
use crate::core::validate::ValidationOutcome;
|
|
use clash_verge_draft::Draft;
|
|
use std::{
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
task::Poll,
|
|
time::Duration,
|
|
};
|
|
use tokio::sync::Barrier;
|
|
|
|
struct CancellationProbe {
|
|
cancelled: Arc<AtomicBool>,
|
|
completed: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl Drop for CancellationProbe {
|
|
fn drop(&mut self) {
|
|
if !self.completed.load(Ordering::Acquire) {
|
|
self.cancelled.store(true, Ordering::Release);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn profile(uid: &str) -> PrfItem {
|
|
PrfItem {
|
|
uid: Some(uid.into()),
|
|
..PrfItem::default()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn committing_profile_switch_preserves_profiles_added_after_draft_creation() -> anyhow::Result<()> {
|
|
let profiles = Draft::new(IProfiles {
|
|
current: Some("a".into()),
|
|
items: Some(vec![profile("a"), profile("b")]),
|
|
});
|
|
profiles.edit_draft(|draft| {
|
|
draft.patch_config(&IProfiles {
|
|
current: Some("b".into()),
|
|
items: None,
|
|
});
|
|
});
|
|
profiles
|
|
.with_data_modify(|mut committed| async move {
|
|
committed.items.get_or_insert_with(Vec::new).push(profile("new"));
|
|
Ok((committed, ()))
|
|
})
|
|
.await?;
|
|
|
|
commit_current_profile(&profiles, Some("b".into())).await?;
|
|
|
|
let committed = profiles.data_arc();
|
|
assert_eq!(committed.current.as_deref(), Some("b"));
|
|
assert!(committed.get_item("new").is_ok());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn profile_config_update_runs_past_former_deadline_without_cancellation() -> anyhow::Result<()> {
|
|
let update_started = Arc::new(Barrier::new(2));
|
|
let release_update = Arc::new(Barrier::new(2));
|
|
let update_cancelled = Arc::new(AtomicBool::new(false));
|
|
let update_completed = Arc::new(AtomicBool::new(false));
|
|
|
|
let mut update = Box::pin(run_profile_config_update_transition({
|
|
let update_started = Arc::clone(&update_started);
|
|
let release_update = Arc::clone(&release_update);
|
|
let update_cancelled = Arc::clone(&update_cancelled);
|
|
let update_completed = Arc::clone(&update_completed);
|
|
move || async move {
|
|
let _probe = CancellationProbe {
|
|
cancelled: update_cancelled,
|
|
completed: Arc::clone(&update_completed),
|
|
};
|
|
update_started.wait().await;
|
|
release_update.wait().await;
|
|
update_completed.store(true, Ordering::Release);
|
|
Ok(ValidationOutcome::Valid)
|
|
}
|
|
}));
|
|
|
|
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
|
update_started.wait().await;
|
|
tokio::time::advance(Duration::from_secs(31)).await;
|
|
|
|
assert!(matches!(futures::poll!(update.as_mut()), Poll::Pending));
|
|
assert!(!update_cancelled.load(Ordering::Acquire));
|
|
|
|
release_update.wait().await;
|
|
assert!(update.await?.is_valid());
|
|
assert!(update_completed.load(Ordering::Acquire));
|
|
assert!(!update_cancelled.load(Ordering::Acquire));
|
|
Ok(())
|
|
}
|
|
}
|