diff --git a/src-tauri/AGENTS.md b/src-tauri/AGENTS.md index 0198c5bd1..5314339b8 100644 --- a/src-tauri/AGENTS.md +++ b/src-tauri/AGENTS.md @@ -6,10 +6,13 @@ These rules supplement the repository's root `AGENTS.md`. its draft and persistence APIs. Patch orchestration must use `feat/effects.rs` and `feat/executor.rs`; refresh notifications must use `core::notify::announce` after successful persistence and commit. This keeps - failed transactions from publishing state. Independent transactions retain - their boundaries and announce via `after_commit`; runtime-only profile - activation announces after successful application. Cross-layer operations - must preserve these ownership and transaction boundaries. + rolled-back transactions from publishing state. If runtime and memory state + are already committed and cannot be rolled back, announce that state even + when persistence fails, while preserving the persistence error. Independent + transactions retain their boundaries and announce their own committed + results; runtime-only profile activation announces after successful + application. Cross-layer operations must preserve these ownership and + transaction boundaries. 2. **Register every new `IVerge` field.** Register the field in the exhaustive effect registry, including an explicit `NoEffect` where appropriate, and preserve its platform attributes. Manual field-to-flag diff --git a/src-tauri/src/config/dns.rs b/src-tauri/src/config/dns.rs index 6227dad84..7108b70c3 100644 --- a/src-tauri/src/config/dns.rs +++ b/src-tauri/src/config/dns.rs @@ -143,9 +143,9 @@ impl Config { PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed); Handle::notice(NoticeStatus::DnsOverrideAutoDisabled, ""); } - verge.data_arc().save_file().await?; + let result = verge.data_arc().save_file().await; Handle::refresh_verge(); - Ok(()) + result } } diff --git a/src-tauri/src/config/snapshot.rs b/src-tauri/src/config/snapshot.rs index 4b1d8cc9f..9d0a084cb 100644 --- a/src-tauri/src/config/snapshot.rs +++ b/src-tauri/src/config/snapshot.rs @@ -3,7 +3,7 @@ use anyhow::{Context as _, Result, bail}; use std::{io::ErrorKind, path::PathBuf}; pub(crate) struct FileSnapshot { - path: PathBuf, + pub(crate) path: PathBuf, content: Option>, } diff --git a/src-tauri/src/core/app_effects.rs b/src-tauri/src/core/app_effects.rs deleted file mode 100644 index ffddae685..000000000 --- a/src-tauri/src/core/app_effects.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::{ - config::{Config, IVerge}, - core::{autostart, hotkey, lightweight, tray}, -}; -use anyhow::Result; -use futures::future::BoxFuture; - -pub(crate) fn ensure_autostart(_patch: &IVerge) -> BoxFuture<'_, Result<()>> { - Box::pin(async move { autostart::update_launch().await }) -} - -pub(crate) fn ensure_hotkey(patch: &IVerge) -> BoxFuture<'_, Result<()>> { - Box::pin(async move { - if let Some(hotkeys) = &patch.hotkeys { - hotkey::Hotkey::global().update(hotkeys.to_owned()).await?; - } - Ok(()) - }) -} - -pub(crate) fn ensure_tray_menu(_patch: &IVerge) -> BoxFuture<'_, Result<()>> { - Box::pin(async move { tray::Tray::global().update_menu().await }) -} - -pub(crate) fn ensure_tray_icon(patch: &IVerge) -> BoxFuture<'_, Result<()>> { - Box::pin(async move { - tray::Tray::global() - .update_icon(&Config::verge().await.latest_arc()) - .await?; - #[cfg(target_os = "macos")] - if let Some(enabled) = patch.enable_tray_speed { - tray::Tray::global().update_speed_task(enabled); - } - #[cfg(not(target_os = "macos"))] - let _ = patch; - Ok(()) - }) -} - -pub(crate) fn ensure_tray_tooltip(_patch: &IVerge) -> BoxFuture<'_, Result<()>> { - Box::pin(async move { tray::Tray::global().update_tooltip().await }) -} - -pub(crate) fn ensure_tray_click(_patch: &IVerge) -> BoxFuture<'_, Result<()>> { - Box::pin(async move { tray::Tray::global().update_click_behavior().await }) -} - -pub(crate) fn ensure_lightweight(patch: &IVerge) -> BoxFuture<'_, Result<()>> { - Box::pin(async move { - if patch.enable_auto_light_weight_mode.unwrap_or(false) { - lightweight::enable_auto_light_weight_mode().await; - } else { - lightweight::disable_auto_light_weight_mode(); - } - Ok(()) - }) -} diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index 9ed82eefa..997a20f47 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -238,7 +238,7 @@ impl CoreManager { } } - fn current_core_readiness_generation(&self) -> Option { + pub(crate) fn current_core_readiness_generation(&self) -> Option { active_core_readiness_generation(self.core_readiness_state.load(Ordering::Acquire)) } diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index fefd3d059..bad0022a7 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod app_effects; pub mod auto_backup; pub mod autostart; pub mod backup; diff --git a/src-tauri/src/feat/dns.rs b/src-tauri/src/feat/dns.rs index ef8227bb1..9092676e1 100644 --- a/src-tauri/src/feat/dns.rs +++ b/src-tauri/src/feat/dns.rs @@ -26,14 +26,6 @@ pub async fn set_dns_override( profile_uid: String, enabled: bool, confirmation: Option, -) -> Result { - crate::core::notify::after_commit(Box::pin(set_dns_override_inner(profile_uid, enabled, confirmation))).await -} - -async fn set_dns_override_inner( - profile_uid: String, - enabled: bool, - confirmation: Option, ) -> Result { let _profile_write = PROFILE_WRITE_LOCK.lock().await; let _config_write = Config::lock_config_write().await; @@ -83,8 +75,10 @@ async fn set_dns_override_inner( } transaction.commit(); - verge.data_arc().save_file().await?; + // Core and memory state remain applied even when persistence fails. + let result = verge.data_arc().save_file().await; announce(Refresh::Verge); announce(Refresh::Clash); + result?; Ok(outcome) } diff --git a/src-tauri/src/feat/effects.rs b/src-tauri/src/feat/effects.rs index 0a46df068..93c6a0b26 100644 --- a/src-tauri/src/feat/effects.rs +++ b/src-tauri/src/feat/effects.rs @@ -5,7 +5,6 @@ use std::collections::BTreeSet; pub(super) enum Effect { RestartCore, ClashConfig, - VergeConfig, Autostart, SystemProxy, TrayIcon, @@ -90,8 +89,8 @@ verge_registry! { web_ui_list => NoEffect; clash_core => NoEffect; hotkeys => [Hotkey, TrayMenu]; - enable_global_hotkey => [VergeConfig]; - home_cards => [VergeConfig]; + enable_global_hotkey => NoEffect; + home_cards => NoEffect; auto_close_connection => NoEffect; auto_check_update => NoEffect; default_latency_test => NoEffect; diff --git a/src-tauri/src/feat/executor.rs b/src-tauri/src/feat/executor.rs index fd095e8ef..471f265b7 100644 --- a/src-tauri/src/feat/executor.rs +++ b/src-tauri/src/feat/executor.rs @@ -6,13 +6,13 @@ use crate::{ snapshot::{capture_config_files, restore_files}, }, core::auto_backup::AutoBackupManager, - core::{CoreManager, logger, manager::ConfigUpdateGuard, proxy_control}, + core::{CoreManager, autostart, hotkey, lightweight, logger, manager::ConfigUpdateGuard, proxy_control, tray}, }; 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 std::sync::Arc; use tokio::sync::MutexGuard; pub(super) enum Patch<'a> { @@ -20,101 +20,108 @@ pub(super) enum Patch<'a> { 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), +const EFFECT_ORDER: &[Effect] = &[ + Effect::RestartCore, + Effect::ClashConfig, + Effect::Autostart, + Effect::Language, + Effect::SystemProxy, + Effect::Hotkey, + Effect::TrayMenu, + Effect::TrayIcon, + Effect::TrayTooltip, + Effect::TrayClick, + Effect::Lightweight, + Effect::LogLevel, + Effect::LogFile, ]; -macro_rules! ensure { - ($name:ident, $ctx:ident, $body:block) => { - fn $name<'a>($ctx: &'a EffectContext<'_>) -> BoxFuture<'a, Result<()>> { - Box::pin(async move $body) - } - }; +async fn ensure_restart(manager: &CoreManager) -> Result<()> { + Config::generate().await?; + let previous = manager.current_core_readiness_generation(); + let result = manager.restart_core_during_config_update().await; + if result.is_ok() + || manager + .current_core_readiness_generation() + .is_some_and(|current| Some(current) != previous) + { + Config::runtime().await.apply(); + } + result?; + Ok(()) } -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?; - announce(Refresh::Clash); - Ok(()) -}); -ensure!(ensure_verge, _ctx, { Ok(()) }); -ensure!(ensure_autostart, ctx, { - crate::core::app_effects::ensure_autostart(ctx.patch).await -}); -ensure!(ensure_language, ctx, { - if let Some(language) = &ctx.patch.language { - clash_verge_i18n::set_locale(language.as_str()); +async fn ensure_effect( + effect: Effect, + patch: &IVerge, + manager: &CoreManager, + update: &ConfigUpdateGuard<'_>, +) -> Result<()> { + match effect { + Effect::RestartCore => { + ensure_restart(manager).await?; + } + Effect::ClashConfig => { + manager.update_config_in_patch(update).await?; + Config::runtime().await.apply(); + announce(Refresh::Clash); + } + Effect::Autostart => autostart::update_launch().await?, + Effect::Language => { + if let Some(language) = &patch.language { + clash_verge_i18n::set_locale(language.as_str()); + } + } + Effect::SystemProxy => { + let _lifecycle = 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() + { + manager.apply_proxy_after_start().await?; + } else { + proxy_control::apply().await?; + proxy_control::refresh_guard().await?; + } + } + Effect::Hotkey => { + if let Some(hotkeys) = &patch.hotkeys { + hotkey::Hotkey::global().update(hotkeys.to_owned()).await?; + } + } + Effect::TrayMenu => tray::Tray::global().update_menu().await?, + Effect::TrayIcon => { + tray::Tray::global() + .update_icon(&Config::verge().await.latest_arc()) + .await?; + #[cfg(target_os = "macos")] + if let Some(enabled) = patch.enable_tray_speed { + tray::Tray::global().update_speed_task(enabled); + } + } + Effect::TrayTooltip => tray::Tray::global().update_tooltip().await?, + Effect::TrayClick => tray::Tray::global().update_click_behavior().await?, + Effect::Lightweight => { + if patch.enable_auto_light_weight_mode.unwrap_or(false) { + lightweight::enable_auto_light_weight_mode().await; + } else { + lightweight::disable_auto_light_weight_mode(); + } + } + Effect::LogLevel => logger::Logger::global().update_log_level(patch.get_log_level())?, + Effect::LogFile => { + logger::update_log_config( + patch.app_log_max_size.unwrap_or(128), + patch.app_log_max_count.unwrap_or(8), + ) + .await?; + } } 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, { - crate::core::app_effects::ensure_hotkey(ctx.patch).await -}); -ensure!(ensure_tray_menu, ctx, { - crate::core::app_effects::ensure_tray_menu(ctx.patch).await -}); -ensure!(ensure_tray_icon, ctx, { - crate::core::app_effects::ensure_tray_icon(ctx.patch).await -}); -ensure!(ensure_tray_tooltip, ctx, { - crate::core::app_effects::ensure_tray_tooltip(ctx.patch).await -}); -ensure!(ensure_tray_click, ctx, { - crate::core::app_effects::ensure_tray_click(ctx.patch).await -}); -ensure!(ensure_lightweight, ctx, { - crate::core::app_effects::ensure_lightweight(ctx.patch).await -}); -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 -}); +} async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { let manager = CoreManager::global(); @@ -126,24 +133,27 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect Patch::Verge { .. } => DraftTransaction::begin(vec![&verge, &runtime])?, Patch::Clash(_) => DraftTransaction::begin(vec![&clash, &runtime])?, }; - let snapshots = capture_config_files().await?; + let original_runtime = runtime.data_arc(); + let mut 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, + let verge_patch = match &patch { + Patch::Verge { patch, .. } => patch, + Patch::Clash(_) => &empty, }; 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?; + for &effect in EFFECT_ORDER { + if effects.contains(&effect) { + let result = Box::pin(ensure_effect(effect, verge_patch, manager, &update)).await; + if matches!(patch, Patch::Clash(_)) && matches!(effect, Effect::TrayMenu | Effect::TrayIcon) { + // Tray failures must not reject an applied Clash mode change. + logging_error!(Type::Tray, result); + } else { + result?; + } } } match patch { @@ -156,6 +166,11 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect .await; if let Err(error) = result { transaction.rollback(); + if !Arc::ptr_eq(&original_runtime, &runtime.data_arc()) { + // The Core already loaded this runtime; restoring its file would only hide that state. + let runtime_path = crate::utils::dirs::app_home_dir()?.join(crate::constants::files::RUNTIME_CONFIG); + snapshots.retain(|snapshot| snapshot.path != runtime_path); + } return match restore_files(&snapshots).await { Ok(()) => Err(error), Err(rollback_error) => Err(proxy_control::rollback_failure(error, rollback_error)), @@ -173,7 +188,13 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect } pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { - crate::core::notify::after_commit(apply_inner(config_write, patch, effects)).await + let runtime = Config::runtime().await; + let original = runtime.data_arc(); + let result = crate::core::notify::after_commit(apply_inner(config_write, patch, effects)).await; + if result.is_err() && !Arc::ptr_eq(&original, &runtime.data_arc()) { + announce(Refresh::Clash); + } + result } #[cfg(test)] diff --git a/src-tauri/src/feat/listener.rs b/src-tauri/src/feat/listener.rs index ee608a290..2e23d82a7 100644 --- a/src-tauri/src/feat/listener.rs +++ b/src-tauri/src/feat/listener.rs @@ -27,12 +27,8 @@ pub async fn probe_listener(request: ListenerProbe) -> Result Result { - crate::core::notify::after_commit(Box::pin(save_proxy_ports_inner(settings))).await -} - #[allow(clippy::cognitive_complexity)] -async fn save_proxy_ports_inner(settings: ProxyPortSettings) -> Result { +pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result { settings.validate()?; let _config_write = Config::lock_config_write().await; let manager = CoreManager::global();