diff --git a/src-tauri/src/config/config.rs b/src-tauri/src/config/config.rs index 3fa9686b6..59e6f921d 100644 --- a/src-tauri/src/config/config.rs +++ b/src-tauri/src/config/config.rs @@ -70,6 +70,12 @@ impl Config { CONFIG_WRITE_LOCK.lock().await } + pub(crate) fn try_lock_config_write() -> Result> { + CONFIG_WRITE_LOCK + .try_lock() + .map_err(|_| anyhow!("configuration update is already running")) + } + pub async fn init_config_before_window() -> Result<()> { Self::ensure_default_profile_items().await?; diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index f5bc4682c..619a29403 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -130,6 +130,30 @@ where } impl CoreManager { + pub(crate) fn claim_config_update( + &self, + _config_write: &tokio::sync::MutexGuard<'_, ()>, + ) -> Result> { + if !self.try_start_config_update() { + anyhow::bail!("configuration update is already running"); + } + Ok(ConfigUpdateGuard(self)) + } + + pub(crate) async fn update_config_in_patch(&self, _update: &ConfigUpdateGuard<'_>) -> Result<()> { + if handle::Handle::global().is_exiting() { + anyhow::bail!("application is exiting"); + } + self.set_last_update(Instant::now()); + Config::generate().await?; + let outcome = self.validate_and_apply_draft().await?; + if outcome.is_valid() { + Ok(()) + } else { + Err(anyhow!("{outcome}")) + } + } + pub async fn use_default_config(&self, status: NoticeStatus, message: &str) -> Result<()> { use crate::constants::files::RUNTIME_CONFIG; @@ -300,6 +324,14 @@ impl CoreManager { /// Validates and applies the caller's transaction, committing only on success. async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result { + let outcome = self.validate_and_apply_draft().await?; + if outcome.is_valid() { + transaction.commit(); + } + Ok(outcome) + } + + async fn validate_and_apply_draft(&self) -> Result { // One serialization feeds check and run files; the core never applies unvalidated bytes. let yaml = Config::runtime_config_yaml().await?; let outcome = CoreConfigValidator::global() @@ -311,7 +343,6 @@ impl CoreManager { let run_path = Config::write_runtime_file(&yaml).await?; self.apply_config(run_path).await?; - transaction.commit(); Ok(ValidationOutcome::Valid) } diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index ae5889e7d..b20338412 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -1,4 +1,5 @@ mod config; +pub(crate) use config::ConfigUpdateGuard; mod lifecycle; mod state; diff --git a/src-tauri/src/core/notify/mod.rs b/src-tauri/src/core/notify/mod.rs index ff67036ac..9a81dd861 100644 --- a/src-tauri/src/core/notify/mod.rs +++ b/src-tauri/src/core/notify/mod.rs @@ -109,7 +109,7 @@ mod tests { use super::NoticeStatus; #[test] - fn notice_status_wire_contract() { + fn notice_status_wire_contract() -> Result<(), serde_json::Error> { let statuses = [ NoticeStatus::Info, NoticeStatus::ImportSubUrlOk, @@ -199,8 +199,8 @@ mod tests { let actual: Vec<_> = statuses .into_iter() .map(serde_json::to_value) - .collect::>() - .unwrap(); + .collect::>()?; assert_eq!(actual, expected.map(serde_json::Value::from)); + Ok(()) } } diff --git a/src-tauri/src/core/notify/notification.rs b/src-tauri/src/core/notify/notification.rs index f42960ae9..3bc73f40d 100644 --- a/src-tauri/src/core/notify/notification.rs +++ b/src-tauri/src/core/notify/notification.rs @@ -322,7 +322,7 @@ mod wire_tests { use super::*; #[test] - fn frontend_event_wire_contract() { + fn frontend_event_wire_contract() -> Result<(), serde_json::Error> { let uid = String::from("profile"); let events = [ ( @@ -387,7 +387,8 @@ mod wire_tests { for (event, name, payload) in events { let (actual_name, actual_payload) = NotificationSystem::serialize_event(event); assert_eq!(actual_name, name); - assert_eq!(actual_payload.unwrap(), payload); + assert_eq!(actual_payload?, payload); } + Ok(()) } } diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 7bcb17a80..2a1fdbe6c 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -1,279 +1,17 @@ -use crate::{ - config::{Config, IVerge}, - core::{CoreManager, autostart, handle, hotkey, logger, proxy_control, tray}, - module::{auto_backup::AutoBackupManager, lightweight}, -}; +use crate::config::{Config, IVerge}; use anyhow::Result; -use bitflags::bitflags; -use clash_verge_draft::{DraftTransaction, SharedDraft}; -use clash_verge_logging::{Type, logging, logging_error}; +use clash_verge_draft::SharedDraft; use serde_yaml_ng::Mapping; use tokio::sync::MutexGuard; -/// Patch Clash configuration pub async fn patch_clash(patch: &Mapping) -> Result<()> { - Config::clash().await.edit_draft(|d| d.patch_config(patch)); - - let res = { - // 激活订阅 - if patch.get("secret").is_some() || patch.get("external-controller").is_some() { - Config::generate().await?; - CoreManager::global().restart_core().await?; - } else if patch.get("allow-lan").is_some() { - CoreManager::global().update_config_checked().await?; - } else { - if patch.get("mode").is_some() { - tray::Tray::global().update_menu_and_icon().await; - } - CoreManager::global().update_config_checked().await?; - } - handle::Handle::refresh_clash(); - >::Ok(()) - }; - match res { - Ok(()) => { - Config::clash().await.apply(); - // 分离数据获取和异步调用 - let clash_data = Config::clash().await.data_arc(); - clash_data.save_config().await?; - Ok(()) - } - Err(err) => { - Config::clash().await.discard(); - Err(err) - } - } -} - -// Define update flags as bitflags for better performance -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - struct UpdateFlags: u16 { - const RESTART_CORE = 1 << 0; - const CLASH_CONFIG = 1 << 1; - const VERGE_CONFIG = 1 << 2; - const LAUNCH = 1 << 3; - const SYS_PROXY = 1 << 4; - const SYSTRAY_ICON = 1 << 5; - const HOTKEY = 1 << 6; - const SYSTRAY_MENU = 1 << 7; - const SYSTRAY_TOOLTIP = 1 << 8; - const SYSTRAY_CLICK_BEHAVIOR = 1 << 9; - const LIGHT_WEIGHT = 1 << 10; - const LANGUAGE = 1 << 11; - const LOG_LEVEL = 1 << 12; - const LOG_FILE = 1 << 13; - - const GROUP_SYS_TRAY = Self::SYSTRAY_MENU.bits() - | Self::SYSTRAY_TOOLTIP.bits() - | Self::SYSTRAY_ICON.bits(); - } -} - -fn determine_update_flags(patch: &IVerge) -> UpdateFlags { - let tun_mode = patch.enable_tun_mode; - let auto_launch = patch.enable_auto_launch; - let system_proxy = patch.enable_system_proxy; - let pac = patch.proxy_auto_config; - let pac_content = &patch.pac_file_content; - let proxy_bypass = &patch.system_proxy_bypass; - let language = &patch.language; - let mixed_port = patch.verge_mixed_port; - #[cfg(target_os = "macos")] - let tray_icon = &patch.tray_icon; - #[cfg(not(target_os = "macos"))] - let tray_icon: Option = None; - let common_tray_icon = patch.common_tray_icon; - let sysproxy_tray_icon = patch.sysproxy_tray_icon; - let tun_tray_icon = patch.tun_tray_icon; - #[cfg(not(target_os = "windows"))] - let redir_enabled = patch.verge_redir_enabled; - #[cfg(not(target_os = "windows"))] - let redir_port = patch.verge_redir_port; - #[cfg(target_os = "linux")] - let tproxy_enabled = patch.verge_tproxy_enabled; - #[cfg(target_os = "linux")] - let tproxy_port = patch.verge_tproxy_port; - let socks_enabled = patch.verge_socks_enabled; - let socks_port = patch.verge_socks_port; - let http_enabled = patch.verge_http_enabled; - let http_port = patch.verge_port; - #[cfg(target_os = "macos")] - let enable_tray_speed = patch.enable_tray_speed; - #[cfg(not(target_os = "macos"))] - let enable_tray_speed: Option = None; - // let enable_tray_icon = patch.enable_tray_icon; - let enable_global_hotkey = patch.enable_global_hotkey; - let tray_event = &patch.tray_event; - let home_cards = patch.home_cards.as_ref(); - let enable_auto_light_weight = patch.enable_auto_light_weight_mode; - let enable_external_controller = patch.enable_external_controller; - let tray_proxy_groups_display_mode = &patch.tray_proxy_groups_display_mode; - let tray_inline_outbound_modes = patch.tray_inline_outbound_modes; - let enable_proxy_guard = patch.enable_proxy_guard; - let proxy_guard_duration = patch.proxy_guard_duration; - let log_level = &patch.app_log_level; - let log_max_size = patch.app_log_max_size; - let log_max_count = patch.app_log_max_count; - - #[cfg(target_os = "windows")] - let restart_core_needed = socks_enabled.is_some() - || http_enabled.is_some() - || socks_port.is_some() - || http_port.is_some() - || mixed_port.is_some() - || enable_external_controller.is_some(); - #[cfg(not(target_os = "windows"))] - let mut restart_core_needed = socks_enabled.is_some() - || http_enabled.is_some() - || socks_port.is_some() - || http_port.is_some() - || mixed_port.is_some() - || enable_external_controller.is_some(); - #[cfg(not(target_os = "windows"))] - { - restart_core_needed |= redir_enabled.is_some() || redir_port.is_some(); - } - #[cfg(target_os = "linux")] - { - restart_core_needed |= tproxy_enabled.is_some() || tproxy_port.is_some(); - restart_core_needed |= tun_mode == Some(true); - } - - let mut update_flags = UpdateFlags::empty(); - if restart_core_needed { - update_flags.insert(UpdateFlags::RESTART_CORE); - } - if tun_mode.is_some() { - update_flags.insert(UpdateFlags::CLASH_CONFIG | UpdateFlags::GROUP_SYS_TRAY); - } - if enable_global_hotkey.is_some() || home_cards.is_some() { - update_flags.insert(UpdateFlags::VERGE_CONFIG); - } - if auto_launch.is_some() { - update_flags.insert(UpdateFlags::LAUNCH); - } - if system_proxy.is_some() { - update_flags.insert(UpdateFlags::SYS_PROXY | UpdateFlags::GROUP_SYS_TRAY); - } - if proxy_bypass.is_some() - || pac_content.is_some() - || pac.is_some() - || enable_proxy_guard.is_some() - || proxy_guard_duration.is_some() - { - update_flags.insert(UpdateFlags::SYS_PROXY); - } - if language.is_some() { - update_flags.insert(UpdateFlags::LANGUAGE | UpdateFlags::SYSTRAY_MENU | UpdateFlags::SYSTRAY_TOOLTIP); - } - if common_tray_icon.is_some() - || sysproxy_tray_icon.is_some() - || tun_tray_icon.is_some() - || tray_icon.is_some() - || enable_tray_speed.is_some() - { - update_flags.insert(UpdateFlags::SYSTRAY_ICON); - } - if patch.hotkeys.is_some() { - update_flags.insert(UpdateFlags::HOTKEY | UpdateFlags::SYSTRAY_MENU); - } - if tray_event.is_some() { - update_flags.insert(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR); - } - if enable_auto_light_weight.is_some() { - update_flags.insert(UpdateFlags::LIGHT_WEIGHT); - } - if tray_proxy_groups_display_mode.is_some() { - update_flags.insert(UpdateFlags::SYSTRAY_MENU); - } - if log_level.is_some() { - update_flags.insert(UpdateFlags::LOG_LEVEL); - } - if log_max_size.is_some() || log_max_count.is_some() { - update_flags.insert(UpdateFlags::LOG_FILE); - } - if tray_inline_outbound_modes.is_some() { - update_flags.insert(UpdateFlags::SYSTRAY_MENU); - } - - update_flags -} - -#[allow(clippy::cognitive_complexity)] -async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) -> Result<()> { - // Process updates based on flags - if update_flags.contains(UpdateFlags::RESTART_CORE) { - Config::generate().await?; - CoreManager::global().restart_core().await?; - } - if update_flags.contains(UpdateFlags::CLASH_CONFIG) { - CoreManager::global().update_config_checked().await?; - handle::Handle::refresh_clash(); - } - if update_flags.contains(UpdateFlags::LAUNCH) { - autostart::update_launch().await?; - } - if update_flags.contains(UpdateFlags::LANGUAGE) - && let Some(language) = &patch.language - { - clash_verge_i18n::set_locale(language.as_str()); - } - if update_flags.contains(UpdateFlags::SYS_PROXY) { - let manager = CoreManager::global(); - let _lifecycle = manager.lifecycle_lock.lock().await; - // Turning it off only writes OS state, so it must stay available while the Core is down. - if Config::verge() - .await - .latest_arc() - .enable_system_proxy - .unwrap_or_default() - { - manager.apply_proxy_after_start().await?; - } else { - proxy_control::apply().await?; - proxy_control::refresh_guard().await?; - } - } - if update_flags.contains(UpdateFlags::HOTKEY) - && let Some(hotkeys) = &patch.hotkeys - { - hotkey::Hotkey::global().update(hotkeys.to_owned()).await?; - } - if update_flags.contains(UpdateFlags::SYSTRAY_MENU) { - tray::Tray::global().update_menu().await?; - } - if update_flags.contains(UpdateFlags::SYSTRAY_ICON) { - tray::Tray::global() - .update_icon(&Config::verge().await.latest_arc()) - .await?; - #[cfg(target_os = "macos")] - if patch.enable_tray_speed.is_some() { - tray::Tray::global().update_speed_task(patch.enable_tray_speed.unwrap_or(false)); - } - } - if update_flags.contains(UpdateFlags::SYSTRAY_TOOLTIP) { - tray::Tray::global().update_tooltip().await?; - } - if update_flags.contains(UpdateFlags::SYSTRAY_CLICK_BEHAVIOR) { - tray::Tray::global().update_click_behavior().await?; - } - if update_flags.contains(UpdateFlags::LIGHT_WEIGHT) { - if patch.enable_auto_light_weight_mode.unwrap_or(false) { - lightweight::enable_auto_light_weight_mode().await; - } else { - lightweight::disable_auto_light_weight_mode(); - } - } - if update_flags.contains(UpdateFlags::LOG_LEVEL) { - logger::Logger::global().update_log_level(patch.get_log_level())?; - } - if update_flags.contains(UpdateFlags::LOG_FILE) { - let log_max_size = patch.app_log_max_size.unwrap_or(128); - let log_max_count = patch.app_log_max_count.unwrap_or(8); - logger::update_log_config(log_max_size, log_max_count).await?; - } - Ok(()) + let config_write = Config::try_lock_config_write()?; + super::executor::apply( + &config_write, + super::executor::Patch::Clash(patch), + super::effects::clash_effects(patch), + ) + .await } /// Apply a patch, then reconcile TUN when its setting changes. @@ -289,7 +27,7 @@ pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> { /// Apply a patch without post-update reconciliation. pub(super) async fn apply_verge_patch(patch: &IVerge, not_save_file: bool) -> Result<()> { - let config_write = Config::lock_config_write().await; + let config_write = Config::try_lock_config_write()?; apply_verge_patch_locked(&config_write, patch, not_save_file).await } @@ -300,29 +38,15 @@ pub(super) async fn apply_verge_patch_locked( patch: &IVerge, not_save_file: bool, ) -> Result<()> { - let verge = Config::verge().await; - // Hold the claim across side effects so concurrent transactions cannot share this draft. - let transaction = DraftTransaction::begin(vec![&verge])?; - verge.edit_draft(|d| d.patch_config(patch)); - - let update_flags = determine_update_flags(patch); - logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags); - // A failed patch rolls back to what the user already had; it never invents a value for them. - process_terminated_flags(update_flags, patch).await?; - transaction.commit(); - announce_verge_change(); - - logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await); - if !not_save_file { - // 分离数据获取和异步调用 - let verge_data = verge.data_arc(); - verge_data.save_file().await?; - } - Ok(()) -} - -fn announce_verge_change() { - handle::Handle::refresh_verge(); + super::executor::apply( + _config_write, + super::executor::Patch::Verge { + patch, + persist: !not_save_file, + }, + super::effects::verge_effects(patch), + ) + .await } pub async fn fetch_verge_config() -> Result> { diff --git a/src-tauri/src/feat/effects.rs b/src-tauri/src/feat/effects.rs new file mode 100644 index 000000000..b45c437be --- /dev/null +++ b/src-tauri/src/feat/effects.rs @@ -0,0 +1,180 @@ +use crate::config::IVerge; +use std::collections::BTreeSet; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Effect { + RestartCore, + ClashConfig, + VergeConfig, + Autostart, + SystemProxy, + TrayIcon, + Hotkey, + TrayMenu, + TrayTooltip, + TrayClick, + Lightweight, + Language, + LogLevel, + LogFile, +} + +pub(super) type Effects = BTreeSet; + +macro_rules! select_field { + ($effects:ident, $field:ident, NoEffect) => { let _ = $field; }; + ($effects:ident, $field:ident, [$($effect:ident),*]) => { + if $field.is_some() { $effects.extend([$(Effect::$effect),*]); } + }; + ($effects:ident, $field:ident, Tun) => { + select_field!($effects, $field, [ClashConfig, TrayMenu, TrayTooltip, TrayIcon]); + #[cfg(target_os = "linux")] + if *$field == Some(true) { $effects.insert(Effect::RestartCore); } + }; +} + +macro_rules! verge_registry { + ($( $(#[$cfg:meta])* $field:ident => $rule:tt; )*) => { + pub(super) fn verge_effects(patch: &IVerge) -> Effects { + // No rest pattern: even fields omitted by serde must be classified at compile time. + let IVerge { $( $(#[$cfg])* $field, )* } = patch; + let mut effects = Effects::new(); + $( $(#[$cfg])* { select_field!(effects, $field, $rule); } )* + effects + } + + #[cfg(test)] + const VERGE_FIELDS: &[&str] = &[ $( $(#[$cfg])* stringify!($field), )* ]; + }; +} + +verge_registry! { + app_log_level => [LogLevel]; + app_log_max_size => [LogFile]; + app_log_max_count => [LogFile]; + language => [Language, TrayMenu, TrayTooltip]; + theme_mode => NoEffect; + tray_event => [TrayClick]; + env_type => NoEffect; + start_page => NoEffect; + startup_script => NoEffect; + traffic_graph => NoEffect; + enable_memory_usage => NoEffect; + enable_group_icon => NoEffect; + pause_render_traffic_stats_on_blur => NoEffect; + common_tray_icon => [TrayIcon]; + #[cfg(target_os = "macos")] + tray_icon => [TrayIcon]; + menu_icon => NoEffect; + menu_order => NoEffect; + proxy_group_tools_position => NoEffect; + notice_position => NoEffect; + collapse_navbar => NoEffect; + sysproxy_tray_icon => [TrayIcon]; + tun_tray_icon => [TrayIcon]; + enable_tun_mode => Tun; + enable_auto_launch => [Autostart]; + enable_silent_start => NoEffect; + enable_system_proxy => [SystemProxy, TrayMenu, TrayTooltip, TrayIcon]; + enable_proxy_guard => [SystemProxy]; + enable_bypass_check => NoEffect; + enable_dns_settings => NoEffect; + profile_dns_settings => NoEffect; + use_default_bypass => NoEffect; + system_proxy_bypass => [SystemProxy]; + proxy_guard_duration => [SystemProxy]; + proxy_auto_config => [SystemProxy]; + pac_file_content => [SystemProxy]; + proxy_host => NoEffect; + theme_setting => NoEffect; + web_ui_list => NoEffect; + clash_core => NoEffect; + hotkeys => [Hotkey, TrayMenu]; + enable_global_hotkey => [VergeConfig]; + home_cards => [VergeConfig]; + auto_close_connection => NoEffect; + auto_check_update => NoEffect; + default_latency_test => NoEffect; + default_latency_timeout => NoEffect; + enable_auto_delay_detection => NoEffect; + auto_delay_detection_interval_minutes => NoEffect; + enable_builtin_enhanced => NoEffect; + proxy_layout_column => NoEffect; + test_list => NoEffect; + auto_log_clean => NoEffect; + enable_auto_backup_schedule => NoEffect; + auto_backup_interval_hours => NoEffect; + auto_backup_on_change => NoEffect; + #[cfg(not(target_os = "windows"))] + verge_redir_port => [RestartCore]; + #[cfg(not(target_os = "windows"))] + verge_redir_enabled => [RestartCore]; + #[cfg(target_os = "linux")] + verge_tproxy_port => [RestartCore]; + #[cfg(target_os = "linux")] + verge_tproxy_enabled => [RestartCore]; + verge_mixed_port => [RestartCore]; + verge_socks_port => [RestartCore]; + verge_socks_enabled => [RestartCore]; + verge_port => [RestartCore]; + verge_http_enabled => [RestartCore]; + webdav_url => NoEffect; + webdav_username => NoEffect; + webdav_password => NoEffect; + #[cfg(target_os = "macos")] + enable_tray_speed => [TrayIcon]; + tray_proxy_groups_display_mode => [TrayMenu]; + tray_inline_outbound_modes => [TrayMenu]; + enable_auto_light_weight_mode => [Lightweight]; + auto_light_weight_minutes => NoEffect; + enable_hover_jump_navigator => NoEffect; + hover_jump_navigator_delay => NoEffect; + enable_external_controller => [RestartCore]; +} + +pub(super) fn clash_effects(patch: &serde_yaml_ng::Mapping) -> Effects { + use clash_verge_logging::{Type, logging}; + let mut effects = Effects::new(); + for key in patch.keys() { + effects.extend(match key.as_str() { + Some("secret" | "external-controller") => &[Effect::RestartCore][..], + Some("allow-lan") => &[Effect::ClashConfig][..], + Some("mode") => &[Effect::ClashConfig, Effect::TrayMenu, Effect::TrayIcon][..], + _ => { + logging!( + warn, + Type::Config, + "Unregistered Clash configuration key {key:?}; reloading configuration" + ); + &[Effect::ClashConfig][..] + } + }); + } + // These branches were exclusive: restart supersedes reload, and allow-lan precedes mode. + if effects.contains(&Effect::RestartCore) { + effects.retain(|effect| *effect == Effect::RestartCore); + } else if patch.contains_key("allow-lan") { + effects.retain(|effect| *effect == Effect::ClashConfig); + } + if patch.is_empty() { + effects.insert(Effect::ClashConfig); + } + effects +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_serialized_verge_field_is_registered() -> Result<(), serde_json::Error> { + let fields: std::collections::BTreeMap = + serde_json::from_value(serde_json::to_value(IVerge::default())?)?; + for field in fields.keys() { + assert!(VERGE_FIELDS.contains(&field.as_str()), "unregistered field: {field}"); + } + let unique: BTreeSet<_> = VERGE_FIELDS.iter().collect(); + assert_eq!(unique.len(), VERGE_FIELDS.len()); + Ok(()) + } +} diff --git a/src-tauri/src/feat/executor.rs b/src-tauri/src/feat/executor.rs new file mode 100644 index 000000000..430ac4ca3 --- /dev/null +++ b/src-tauri/src/feat/executor.rs @@ -0,0 +1,203 @@ +use super::effects::{Effect, Effects}; +use crate::{ + config::{ + Config, IVerge, + snapshot::{capture_config_files, restore_files}, + }, + core::{CoreManager, autostart, handle::Handle, hotkey, logger, manager::ConfigUpdateGuard, proxy_control, tray}, + module::{auto_backup::AutoBackupManager, lightweight}, +}; +use anyhow::Result; +use clash_verge_draft::DraftTransaction; +use clash_verge_logging::{Type, logging_error}; +use futures::future::BoxFuture; +use serde_yaml_ng::Mapping; +use tokio::sync::MutexGuard; + +pub(super) enum Patch<'a> { + Verge { patch: &'a IVerge, persist: bool }, + Clash(&'a Mapping), +} + +struct EffectContext<'a> { + patch: &'a IVerge, + manager: &'a CoreManager, + update: &'a ConfigUpdateGuard<'a>, +} + +type Ensure = for<'a> fn(&'a EffectContext<'_>) -> BoxFuture<'a, Result<()>>; + +const EFFECT_ORDER: &[(Effect, Ensure)] = &[ + (Effect::RestartCore, ensure_restart), + (Effect::ClashConfig, ensure_clash), + (Effect::VergeConfig, ensure_verge), + (Effect::Autostart, ensure_autostart), + (Effect::Language, ensure_language), + (Effect::SystemProxy, ensure_system_proxy), + (Effect::Hotkey, ensure_hotkey), + (Effect::TrayMenu, ensure_tray_menu), + (Effect::TrayIcon, ensure_tray_icon), + (Effect::TrayTooltip, ensure_tray_tooltip), + (Effect::TrayClick, ensure_tray_click), + (Effect::Lightweight, ensure_lightweight), + (Effect::LogLevel, ensure_log_level), + (Effect::LogFile, ensure_log_file), +]; + +macro_rules! ensure { + ($name:ident, $ctx:ident, $body:block) => { + fn $name<'a>($ctx: &'a EffectContext<'_>) -> BoxFuture<'a, Result<()>> { + Box::pin(async move $body) + } + }; +} + +ensure!(ensure_restart, ctx, { + Config::generate().await?; + ctx.manager.restart_core_during_config_update().await +}); +ensure!(ensure_clash, ctx, { + ctx.manager.update_config_in_patch(ctx.update).await?; + Handle::refresh_clash(); + Ok(()) +}); +ensure!(ensure_verge, _ctx, { Ok(()) }); +ensure!(ensure_autostart, _ctx, { autostart::update_launch().await }); +ensure!(ensure_language, ctx, { + if let Some(language) = &ctx.patch.language { + clash_verge_i18n::set_locale(language.as_str()); + } + Ok(()) +}); +ensure!(ensure_system_proxy, ctx, { + let _lifecycle = ctx.manager.lifecycle_lock.lock().await; + // Disabling only writes OS state and must remain available when the Core is stopped. + if Config::verge() + .await + .latest_arc() + .enable_system_proxy + .unwrap_or_default() + { + ctx.manager.apply_proxy_after_start().await + } else { + proxy_control::apply().await?; + proxy_control::refresh_guard().await + } +}); +ensure!(ensure_hotkey, ctx, { + if let Some(hotkeys) = &ctx.patch.hotkeys { + hotkey::Hotkey::global().update(hotkeys.to_owned()).await?; + } + Ok(()) +}); +ensure!(ensure_tray_menu, _ctx, { tray::Tray::global().update_menu().await }); +ensure!(ensure_tray_icon, ctx, { + tray::Tray::global() + .update_icon(&Config::verge().await.latest_arc()) + .await?; + #[cfg(target_os = "macos")] + if let Some(enabled) = ctx.patch.enable_tray_speed { + tray::Tray::global().update_speed_task(enabled); + } + #[cfg(not(target_os = "macos"))] + let _ = ctx; + Ok(()) +}); +ensure!(ensure_tray_tooltip, _ctx, { + tray::Tray::global().update_tooltip().await +}); +ensure!(ensure_tray_click, _ctx, { + tray::Tray::global().update_click_behavior().await +}); +ensure!(ensure_lightweight, ctx, { + if ctx.patch.enable_auto_light_weight_mode.unwrap_or(false) { + lightweight::enable_auto_light_weight_mode().await; + } else { + lightweight::disable_auto_light_weight_mode(); + } + Ok(()) +}); +ensure!(ensure_log_level, ctx, { + logger::Logger::global().update_log_level(ctx.patch.get_log_level()) +}); +ensure!(ensure_log_file, ctx, { + logger::update_log_config( + ctx.patch.app_log_max_size.unwrap_or(128), + ctx.patch.app_log_max_count.unwrap_or(8), + ) + .await +}); + +pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { + let manager = CoreManager::global(); + let update = manager.claim_config_update(config_write)?; + let clash = Config::clash().await; + let verge = Config::verge().await; + let runtime = Config::runtime().await; + let transaction = match patch { + Patch::Verge { .. } => DraftTransaction::begin(vec![&verge, &runtime])?, + Patch::Clash(_) => DraftTransaction::begin(vec![&clash, &runtime])?, + }; + let snapshots = capture_config_files().await?; + let empty = IVerge::default(); + let context = EffectContext { + patch: match &patch { + Patch::Verge { patch, .. } => patch, + Patch::Clash(_) => &empty, + }, + manager, + update: &update, + }; + match &patch { + Patch::Verge { patch, .. } => verge.edit_draft(|draft| draft.patch_config(patch)), + Patch::Clash(patch) => clash.edit_draft(|draft| draft.patch_config(patch)), + } + let result: Result<()> = async { + for (effect, ensure) in EFFECT_ORDER { + if effects.contains(effect) { + ensure(&context).await?; + } + } + match patch { + Patch::Verge { persist: true, .. } => verge.latest_arc().save_file().await?, + Patch::Verge { persist: false, .. } => {} + Patch::Clash(_) => clash.latest_arc().save_config().await?, + } + Ok(()) + } + .await; + if let Err(error) = result { + transaction.rollback(); + return match restore_files(&snapshots).await { + Ok(()) => Err(error), + Err(rollback_error) => Err(proxy_control::rollback_failure(error, rollback_error)), + }; + } + transaction.commit(); + match patch { + Patch::Verge { .. } => { + logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await); + Handle::refresh_verge(); + } + Patch::Clash(_) => Handle::refresh_clash(), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn config_claim_is_exclusive_and_released_on_drop() -> Result<()> { + let write_lock = tokio::sync::Mutex::new(()); + let write = write_lock.lock().await; + let manager = CoreManager::default(); + let first = manager.claim_config_update(&write)?; + assert!(manager.claim_config_update(&write).is_err()); + drop(first); + assert!(manager.claim_config_update(&write).is_ok()); + drop(write); + Ok(()) + } +} diff --git a/src-tauri/src/feat/mod.rs b/src-tauri/src/feat/mod.rs index 7b4fb4232..6cf5fcb8c 100644 --- a/src-tauri/src/feat/mod.rs +++ b/src-tauri/src/feat/mod.rs @@ -3,6 +3,8 @@ mod clash; mod config; mod core_upgrade; mod dns; +mod effects; +mod executor; mod icon; mod listener; mod profile; diff --git a/src-tauri/src/utils/retry.rs b/src-tauri/src/utils/retry.rs index 280e2ab57..502cb70f0 100644 --- a/src-tauri/src/utils/retry.rs +++ b/src-tauri/src/utils/retry.rs @@ -42,9 +42,9 @@ pub enum RetryError { Stop(E), } -pub async fn retry>>>( +pub async fn retry>> + Send>( policy: RetryPolicy, - mut operation: impl FnMut(usize) -> Fut, + mut operation: impl FnMut(usize) -> Fut + Send, ) -> Result { let mut result = operation(0).await; for (index, delay) in policy.delays().enumerate() { @@ -63,10 +63,16 @@ pub async fn retry>>>( }) } -pub async fn try_strategies>, const N: usize>( +pub async fn try_strategies< + S: Copy + Send, + T: Send, + E: Send, + Fut: Future> + Send, + const N: usize, +>( first: S, rest: [S; N], - mut operation: impl FnMut(S) -> Fut, + mut operation: impl FnMut(S) -> Fut + Send, ) -> Result<(S, T), E> { retry( RetryPolicy { @@ -74,7 +80,7 @@ pub async fn try_strategies>, c backoff: Backoff::Fixed(Duration::ZERO), jitter: false, }, - |attempt| { + move |attempt| { let strategy = if attempt == 0 { first } else { rest[attempt - 1] }; let future = operation(strategy); async move { future.await.map(|value| (strategy, value)).map_err(RetryError::Retry) } @@ -90,7 +96,7 @@ mod tests { #[tokio::test] async fn retries_stop_at_success_terminal_error_or_attempt_limit() { let policy = RetryPolicy { - attempts: NonZeroUsize::new(3).unwrap(), + attempts: NonZeroUsize::MIN.saturating_add(2), backoff: Backoff::Fixed(Duration::ZERO), jitter: false, }; @@ -138,7 +144,7 @@ mod tests { #[test] fn backoff_keeps_the_attempt_budget_and_exponential_cap() { let policy = RetryPolicy { - attempts: NonZeroUsize::new(5).unwrap(), + attempts: NonZeroUsize::MIN.saturating_add(4), backoff: Backoff::Exponential { base: Duration::from_millis(10), cap: Duration::from_millis(25),