diff --git a/src-tauri/src/config/dns.rs b/src-tauri/src/config/dns.rs index 5ca0623df..6227dad84 100644 --- a/src-tauri/src/config/dns.rs +++ b/src-tauri/src/config/dns.rs @@ -139,12 +139,13 @@ impl Config { } // The runtime is already committed; a disk failure cannot undo its effective settings. transaction.commit(); - Handle::refresh_verge(); if state.requested && !state.enabled { PENDING_DNS_OVERRIDE_NOTICE.store(true, Ordering::Relaxed); Handle::notice(NoticeStatus::DnsOverrideAutoDisabled, ""); } - verge.data_arc().save_file().await + verge.data_arc().save_file().await?; + Handle::refresh_verge(); + Ok(()) } } diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index 0be175e08..c6e84c90d 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -2,6 +2,7 @@ use super::{ PrfOption, prfitem::{PrfItem, PrfSelected, normalize_profile_home_url}, }; +use crate::core::notify::{Refresh, announce}; use crate::{ core::{handle, tray::Tray}, utils::{ @@ -691,51 +692,58 @@ async fn update_tray_after_activation(generation: u64) { } } +async fn persist_selected_update(supersede: bool, edit: F) -> Result<()> +where + F: FnOnce(IProfiles) -> Fut + Send, + Fut: std::future::Future> + Send, +{ + let changed = Config::profiles().await.with_data_modify(edit).await?; + if changed { + if supersede { + supersede_selected_activation(); + } + announce(Refresh::Profiles); + } + Ok(()) +} + /// Records a backend-made selection so the next core start restores it. pub(crate) async fn record_selected_node(group_name: &str, node: &str) -> Result<()> { let group_name = String::from(group_name); let node = String::from(node); - let recorded = Config::profiles() - .await - .with_data_modify(move |mut profiles| async move { - let Some(current) = profiles.current.clone() else { - return Ok((profiles, false)); - }; - let Some(item) = profiles - .items - .as_mut() - .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t))) - else { - return Ok((profiles, false)); - }; + persist_selected_update(true, move |mut profiles| async move { + let Some(current) = profiles.current.clone() else { + return Ok((profiles, false)); + }; + let Some(item) = profiles + .items + .as_mut() + .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t))) + else { + return Ok((profiles, false)); + }; - let mut selected = item.selected.clone().unwrap_or_default(); - match selected - .iter_mut() - .find(|entry| entry.name.as_ref() == Some(&group_name)) - { - Some(entry) => { - if entry.now.as_ref() == Some(&node) { - return Ok((profiles, false)); - } - entry.now = Some(node); + let mut selected = item.selected.clone().unwrap_or_default(); + match selected + .iter_mut() + .find(|entry| entry.name.as_ref() == Some(&group_name)) + { + Some(entry) => { + if entry.now.as_ref() == Some(&node) { + return Ok((profiles, false)); } - None => selected.push(PrfSelected { - name: Some(group_name), - now: Some(node), - }), + entry.now = Some(node); } - item.selected = Some(selected); - profiles.save_file().await?; - Ok((profiles, true)) - }) - .await?; - - if recorded { - supersede_selected_activation(); - handle::Handle::refresh_profiles(); - } - Ok(()) + None => selected.push(PrfSelected { + name: Some(group_name), + now: Some(node), + }), + } + item.selected = Some(selected); + profiles.save_file().await?; + Ok((profiles, true)) + }) + .await } fn remove_selected_node(selected: &mut Vec, group_name: &str) -> bool { @@ -745,36 +753,28 @@ fn remove_selected_node(selected: &mut Vec, group_name: &str) -> bo } pub(crate) async fn forget_selected_node(group_name: &str) -> Result<()> { - let cleared = Config::profiles() - .await - .with_data_modify(move |mut profiles| async move { - let Some(current) = profiles.current.clone() else { - return Ok((profiles, false)); - }; - let Some(item) = profiles - .items - .as_mut() - .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t))) - else { - return Ok((profiles, false)); - }; + persist_selected_update(true, move |mut profiles| async move { + let Some(current) = profiles.current.clone() else { + return Ok((profiles, false)); + }; + let Some(item) = profiles + .items + .as_mut() + .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(¤t))) + else { + return Ok((profiles, false)); + }; - let mut selected = item.selected.clone().unwrap_or_default(); - if !remove_selected_node(&mut selected, group_name) { - return Ok((profiles, false)); - } + let mut selected = item.selected.clone().unwrap_or_default(); + if !remove_selected_node(&mut selected, group_name) { + return Ok((profiles, false)); + } - item.selected = (!selected.is_empty()).then_some(selected); - profiles.save_file().await?; - Ok((profiles, true)) - }) - .await?; - - if cleared { - supersede_selected_activation(); - handle::Handle::refresh_profiles(); - } - Ok(()) + item.selected = (!selected.is_empty()).then_some(selected); + profiles.save_file().await?; + Ok((profiles, true)) + }) + .await } async fn persist_reconciled_selected( @@ -787,34 +787,27 @@ async fn persist_reconciled_selected( return Ok(()); } - let profiles = Config::profiles().await; let profile_uid = profile_uid.clone(); let original_selected = original_selected.to_vec(); - let updated = profiles - .with_data_modify(move |mut profiles| async move { - if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) { - return Ok((profiles, false)); - } + persist_selected_update(false, move |mut profiles| async move { + if !is_activation_current(generation) || profiles.current.as_ref() != Some(&profile_uid) { + return Ok((profiles, false)); + } - let profile = profiles - .items - .as_mut() - .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid))) - .with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?; - if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() { - return Ok((profiles, false)); - } + let profile = profiles + .items + .as_mut() + .and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&profile_uid))) + .with_context(|| format!("failed to find the profile item \"uid:{profile_uid}\""))?; + if profile.selected.as_deref().unwrap_or(&[]) != original_selected.as_slice() { + return Ok((profiles, false)); + } - profile.selected = (!selected.is_empty()).then_some(selected); - profiles.save_file().await?; - Ok((profiles, true)) - }) - .await?; - - if updated { - handle::Handle::refresh_profiles(); - } - Ok(()) + profile.selected = (!selected.is_empty()).then_some(selected); + profiles.save_file().await?; + Ok((profiles, true)) + }) + .await } /// Returns recorded selections whose groups have not reached the requested node. @@ -875,7 +868,7 @@ async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut Has return; } if is_activation_current(generation) { - handle::Handle::refresh_clash(); + announce(Refresh::Clash); } } } @@ -920,7 +913,7 @@ async fn activate_selected_nodes_worker( } if is_activation_current(generation) { - handle::Handle::refresh_clash(); + announce(Refresh::Clash); } let plan = if needs_confirmation { @@ -944,7 +937,7 @@ async fn activate_selected_nodes_worker( return Ok(()); }; if confirmed_activated_count > 0 && is_activation_current(generation) { - handle::Handle::refresh_clash(); + announce(Refresh::Clash); } confirmed_plan } else { @@ -1034,7 +1027,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot if selected.is_empty() { if is_activation_current(generation) { - handle::Handle::refresh_clash(); + announce(Refresh::Clash); } return Ok(()); } @@ -1046,7 +1039,7 @@ fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot if let Err(err) = result { logging!(error, Type::Config, "failed to activate selected nodes: {err:#}"); // The profile itself is already active even if node restoration failed. - handle::Handle::refresh_clash(); + announce(Refresh::Clash); } update_tray_after_activation(generation).await; logging!(debug, Type::Config, "activating selected nodes done!"); diff --git a/src-tauri/src/core/notify/announce.rs b/src-tauri/src/core/notify/announce.rs new file mode 100644 index 000000000..785b8b9c7 --- /dev/null +++ b/src-tauri/src/core/notify/announce.rs @@ -0,0 +1,77 @@ +use super::{handle::Handle, notification::FrontendEvent}; +use anyhow::Result; +use std::{cell::RefCell, future::Future}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refresh { + Clash, + Verge, + Profiles, + Proxies, +} + +tokio::task_local! { + static PENDING: RefCell>; +} + +pub fn announce(refresh: Refresh) { + if PENDING.try_with(|pending| pending.borrow_mut().push(refresh)).is_err() { + emit(refresh); + } +} + +fn emit(refresh: Refresh) { + Handle::send_event(match refresh { + Refresh::Clash => FrontendEvent::RefreshClash, + Refresh::Verge => FrontendEvent::RefreshVerge, + Refresh::Profiles => FrontendEvent::RefreshProfiles, + Refresh::Proxies => FrontendEvent::RefreshProxyConfig, + }); +} + +async fn collect_committed(operation: impl Future> + Send) -> Result<(T, Vec)> { + PENDING + .scope(RefCell::new(Vec::new()), async { + let value = operation.await?; + let refreshes = PENDING.with(|pending| pending.take()); + Ok((value, refreshes)) + }) + .await +} + +pub async fn after_commit(operation: impl Future> + Send) -> Result { + let (value, refreshes) = collect_committed(operation).await?; + // Nested transactions hand their notifications to the outer transaction. + for refresh in refreshes { + announce(refresh); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn only_successful_transactions_publish_refreshes() -> Result<()> { + let (_, refreshes) = collect_committed(async { + announce(Refresh::Clash); + after_commit(async { + announce(Refresh::Verge); + Ok(()) + }) + .await?; + let failed = after_commit(async { + announce(Refresh::Profiles); + Err::<(), _>(anyhow::anyhow!("persistence failed")) + }) + .await; + assert!(failed.is_err()); + announce(Refresh::Clash); + Ok(()) + }) + .await?; + assert_eq!(refreshes, [Refresh::Clash, Refresh::Verge, Refresh::Clash]); + Ok(()) + } +} diff --git a/src-tauri/src/core/notify/handle.rs b/src-tauri/src/core/notify/handle.rs index 2588dfb9d..0a0cbfe8b 100644 --- a/src-tauri/src/core/notify/handle.rs +++ b/src-tauri/src/core/notify/handle.rs @@ -40,19 +40,11 @@ impl Handle { } pub fn refresh_clash() { - Self::send_event(FrontendEvent::RefreshClash); + super::announce(super::Refresh::Clash); } pub fn refresh_verge() { - Self::send_event(FrontendEvent::RefreshVerge); - } - - pub fn refresh_profiles() { - Self::send_event(FrontendEvent::RefreshProfiles); - } - - pub fn refresh_proxy_config() { - Self::send_event(FrontendEvent::RefreshProxyConfig); + super::announce(super::Refresh::Verge); } /// Push a Run State snapshot to the frontend. @@ -103,7 +95,7 @@ impl Handle { self.is_exiting.load(Ordering::Acquire) } - fn send_event(event: FrontendEvent) { + pub(super) fn send_event(event: FrontendEvent) { let handle = Self::global(); if handle.is_exiting() { return; diff --git a/src-tauri/src/core/notify/mod.rs b/src-tauri/src/core/notify/mod.rs index 9a81dd861..cc59f0b36 100644 --- a/src-tauri/src/core/notify/mod.rs +++ b/src-tauri/src/core/notify/mod.rs @@ -1,3 +1,5 @@ +mod announce; +pub use announce::{Refresh, after_commit, announce}; pub mod desktop; pub mod handle; pub mod notification; diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 220c9858f..4efe74714 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -1,4 +1,5 @@ use crate::core::notify::NoticeStatus; +use crate::core::notify::{Refresh, announce}; use crate::{ config::{Config, MixedPort}, core::{CoreManager, handle, tray}, @@ -27,7 +28,7 @@ static TLS_CONFIG: Lazy> = Lazy::new(|| { pub async fn restart_clash_core() { match CoreManager::global().restart_core().await { Ok(_) => { - handle::Handle::refresh_clash(); + announce(Refresh::Clash); handle::Handle::notice(NoticeStatus::SetConfigOk, "ok"); } Err(err) => { @@ -96,7 +97,7 @@ pub async fn change_clash_mode(mode: String) -> Result<(), String> { let clash_data = clash.data_arc(); if clash_data.save_config().await.is_ok() { - handle::Handle::refresh_clash(); + announce(Refresh::Clash); tray::Tray::global().update_menu_and_icon().await; } diff --git a/src-tauri/src/feat/dns.rs b/src-tauri/src/feat/dns.rs index 92ea7041b..ef8227bb1 100644 --- a/src-tauri/src/feat/dns.rs +++ b/src-tauri/src/feat/dns.rs @@ -1,3 +1,4 @@ +use crate::core::notify::{Refresh, announce}; use anyhow::{Result, ensure}; use clash_verge_draft::DraftTransaction; use serde::Serialize; @@ -10,7 +11,7 @@ use crate::{ profiles::PROFILE_WRITE_LOCK, }, constants::files::DNS_CONFIG, - core::{CoreManager, handle::Handle}, + core::CoreManager, utils::dirs, }; @@ -25,6 +26,14 @@ 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; @@ -74,8 +83,8 @@ pub async fn set_dns_override( } transaction.commit(); - Handle::refresh_verge(); - Handle::refresh_clash(); verge.data_arc().save_file().await?; + announce(Refresh::Verge); + announce(Refresh::Clash); Ok(outcome) } diff --git a/src-tauri/src/feat/executor.rs b/src-tauri/src/feat/executor.rs index 430ac4ca3..0c26660db 100644 --- a/src-tauri/src/feat/executor.rs +++ b/src-tauri/src/feat/executor.rs @@ -1,10 +1,11 @@ use super::effects::{Effect, Effects}; +use crate::core::notify::{Refresh, announce}; use crate::{ config::{ Config, IVerge, snapshot::{capture_config_files, restore_files}, }, - core::{CoreManager, autostart, handle::Handle, hotkey, logger, manager::ConfigUpdateGuard, proxy_control, tray}, + core::{CoreManager, autostart, hotkey, logger, manager::ConfigUpdateGuard, proxy_control, tray}, module::{auto_backup::AutoBackupManager, lightweight}, }; use anyhow::Result; @@ -58,7 +59,7 @@ ensure!(ensure_restart, ctx, { }); ensure!(ensure_clash, ctx, { ctx.manager.update_config_in_patch(ctx.update).await?; - Handle::refresh_clash(); + announce(Refresh::Clash); Ok(()) }); ensure!(ensure_verge, _ctx, { Ok(()) }); @@ -128,7 +129,7 @@ ensure!(ensure_log_file, ctx, { .await }); -pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { +async fn apply_inner(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; @@ -177,13 +178,17 @@ pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, e match patch { Patch::Verge { .. } => { logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await); - Handle::refresh_verge(); + announce(Refresh::Verge); } - Patch::Clash(_) => Handle::refresh_clash(), + Patch::Clash(_) => announce(Refresh::Clash), } Ok(()) } +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 +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/feat/listener.rs b/src-tauri/src/feat/listener.rs index 982ba29af..ee608a290 100644 --- a/src-tauri/src/feat/listener.rs +++ b/src-tauri/src/feat/listener.rs @@ -1,3 +1,4 @@ +use crate::core::notify::{Refresh, announce}; use crate::{ config::{ Config, MixedPort, @@ -5,7 +6,6 @@ use crate::{ }, core::{ CoreManager, - handle::Handle, listener::{ ListenerProbe, ListenerProbeOutcome, ProxyPortSettings, SaveProxyPortsOutcome, probe_listener as probe_listener_sync, probe_proxy_port_change, @@ -27,8 +27,12 @@ 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 { settings.validate()?; let _config_write = Config::lock_config_write().await; let manager = CoreManager::global(); @@ -126,8 +130,8 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result { record_switched_node(group_name, proxy_name).await; - handle::Handle::refresh_proxy_config(); + announce(Refresh::Proxies); let _ = tray::Tray::global().update_menu().await; return; } @@ -218,7 +219,7 @@ pub async fn update_profile(uid: &String, option: Option<&PrfOption>, is_mannual match CoreManager::global().update_config_with_force(is_mannual_trigger).await { Ok(outcome) if outcome.is_valid() => { logging_error!(Type::Config, Config::sync_dns_override().await); - handle::Handle::refresh_clash(); + announce(Refresh::Clash); } Ok(outcome @ (ValidationOutcome::Skipped { .. } | ValidationOutcome::Busy)) if !is_mannual_trigger => { logging!(info, Type::Config, "[订阅更新] 本次配置刷新已跳过: {}", outcome); diff --git a/src-tauri/src/feat/proxy.rs b/src-tauri/src/feat/proxy.rs index ac118ac79..49eb4ae9f 100644 --- a/src-tauri/src/feat/proxy.rs +++ b/src-tauri/src/feat/proxy.rs @@ -1,3 +1,4 @@ +use crate::core::notify::{Refresh, announce}; use crate::{ config::{Config, IVerge, MixedPort}, core::{ @@ -83,7 +84,7 @@ pub async fn toggle_tun_mode(not_save_file: Option) -> bool { .await { Ok(_) => { - handle::Handle::refresh_verge(); + announce(Refresh::Verge); // Reconciliation may immediately disable unavailable TUN; report the resulting state. Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false) }