diff --git a/Cargo.lock b/Cargo.lock index 949107e21..33599018c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1113,6 +1113,7 @@ dependencies = [ "getrandom 0.4.3", "libc", "log", + "minisign-verify", "nanoid", "network-interface", "objc2", diff --git a/Changelog.md b/Changelog.md index a7add527d..6078a6885 100644 --- a/Changelog.md +++ b/Changelog.md @@ -6,6 +6,8 @@ - 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题 - 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题 - 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题 +- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题 +- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题 **🖥️ Windows** diff --git a/src-tauri/AGENTS.md b/src-tauri/AGENTS.md index 5314339b8..a9356e9fc 100644 --- a/src-tauri/AGENTS.md +++ b/src-tauri/AGENTS.md @@ -2,39 +2,23 @@ These rules supplement the repository's root `AGENTS.md`. -1. **Keep configuration ownership in `config`.** Configuration writes must use - 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 - 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 - chains hide missing effects and are forbidden. -3. **Use `utils::retry` for bounded retries.** Do not introduce handwritten retry - loops, including `loop` plus `sleep`; preserve attempt budgets, delays and - terminal errors. Permanent watchers are the exception and must document - their exit conditions. Traversal of distinct data or port candidates is not - a retry policy. -4. **Keep notifications typed.** Send notice statuses through `NoticeStatus` and - use `Arc` for notice message payloads. Preserve the event names and - status strings as a shared wire contract. Contract changes must keep - frontend consumers and backend producers aligned. -5. **Preserve lock order.** The order is `config_write` before - `config_update_in_progress` before `lifecycle_lock`. Patch callers must use - the executor's guarded claim instead of independently combining these locks; - this preserves Busy rejection and prevents inverted acquisition. Existing - lifecycle and independent port transactions keep their established locking - boundaries. A new lock requires registering its position in this rule before - introducing acquisition sites. -6. **Prefer reconciliation.** Express new effects as idempotent `ensure` - operations in `EFFECT_ORDER` so repeated application converges on the same - state. A trigger chain is an exception only with a code comment explaining - why the operation cannot be idempotent. Configuration rollback does not - authorize reverse OS effects. +- **Configuration ownership.** `config` owns source configuration, drafts and + persistence. The effects layer owns patch orchestration. Rollbackable + transactions publish state after persistence and commit; already applied + runtime and independently committed state remain authoritative even when a + later step fails. Preserve that state, its notifications and the error. +- **Effect ownership.** The declarative registry classifies every Verge patch field, + including fields without effects and platform-specific fields. Effects + reconcile toward the requested state with explicit dependencies and ordering. + Configuration rollback does not imply reversal of OS or lifecycle effects. +- **Shared wire contract.** Notifications are typed. Event names, status values + and payload shapes stay compatible with frontend producers and consumers. +- **Synchronization ownership.** Configuration-write serialization precedes + configuration-update ownership, which precedes Core lifecycle ownership. + When both are held, the lifecycle lock precedes the DNS lock. Independent + transactions retain their ownership boundaries; asynchronous work does not + hold synchronous data guards across suspension. +- **Retry ownership.** Bounded retries share backend policy while preserving + attempts, delays and terminal errors. Background monitors own their lifetime + and cancellation separately; waiting for a condition or traversing distinct + candidates is not a retry policy. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 432543670..5a82d69e8 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -85,6 +85,7 @@ flate2 = "1.1.10" reqwest_dav = "0.3.3" aes-gcm = { version = "0.11.1" } base64 = "0.23.1" +minisign-verify = "0.2.5" sha2 = "0.11" getrandom = "0.4.3" futures = "0.3.34" diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 635c8f959..425d31f7b 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -1,7 +1,12 @@ use super::CmdResult; -use crate::{cmd::StringifyErr as _, feat, utils::dirs}; +use crate::{ + cmd::StringifyErr as _, + core::{SilentUpdater, updater::DownloadEvent}, + feat, + utils::dirs, +}; use smartstring::alias::String; -use tauri::{AppHandle, Manager as _}; +use tauri::{AppHandle, Manager as _, ipc::Channel}; #[tauri::command] pub async fn open_app_dir() -> CmdResult<()> { @@ -44,6 +49,25 @@ pub async fn restart_app() -> CmdResult<()> { Ok(()) } +#[tauri::command] +pub async fn install_update( + app_handle: AppHandle, + version: String, + on_event: Channel, +) -> CmdResult { + SilentUpdater::global() + .install_update(&app_handle, &version, |event| { + let _ = on_event.send(event); + }) + .await + .stringify_err() +} + +#[tauri::command] +pub fn cancel_update_download() { + SilentUpdater::global().cancel_download(); +} + #[tauri::command] pub fn get_app_dir() -> CmdResult { let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into(); diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index 437b6daf1..56e725d61 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -322,7 +322,7 @@ impl CoreManager { self.validate_and_apply(transaction).await } - /// Validates and applies the caller's transaction, committing only on success. + /// Commits the applied runtime even if restoring the system proxy subsequently fails. async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result { let outcome = self.validate_and_apply_draft().await?; if outcome.is_valid() { @@ -342,7 +342,10 @@ impl CoreManager { } let run_path = Config::write_runtime_file(&yaml).await?; - self.apply_config(run_path).await?; + let previous = self.current_core_readiness_generation(); + let result = self.apply_config(run_path).await; + self.retain_applied_runtime(&Config::runtime().await, previous, &result); + result?; // Under the lifecycle lock, so a stop for exit waits for this write instead of outrunning it. #[cfg(target_os = "macos")] { @@ -352,6 +355,21 @@ impl CoreManager { Ok(ValidationOutcome::Valid) } + fn retain_applied_runtime( + &self, + runtime: &clash_verge_draft::Draft, + previous: Option, + result: &Result<()>, + ) { + if result.is_ok() + || self + .current_core_readiness_generation() + .is_some_and(|current| Some(current) != previous) + { + runtime.apply(); + } + } + /// Applies a generated configuration through the active core owner. async fn apply_config(&self, path: PathBuf) -> Result<()> { if matches!(*self.get_running_mode(), RunningMode::Service) { @@ -443,6 +461,31 @@ mod tests { use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome}; use std::{cell::Cell, time::Duration}; + #[test] + fn a_ready_replacement_keeps_the_applied_runtime_when_proxy_restore_fails() -> anyhow::Result<()> { + use crate::{config::runtime::IRuntime, core::CoreManager}; + use clash_verge_draft::{Draft, DraftTransaction}; + + for replaced in [false, true] { + let manager = CoreManager::default(); + manager.mark_core_ready(); + let previous = manager.current_core_readiness_generation(); + let runtime = Draft::new(IRuntime::default()); + let transaction = DraftTransaction::begin(vec![&runtime])?; + runtime.edit_draft(|draft| { + draft.exists_keys.insert("replacement".into()); + }); + if replaced { + manager.mark_core_ready(); + } + let result = Err(anyhow::anyhow!("system proxy restore failed")); + manager.retain_applied_runtime(&runtime, previous, &result); + transaction.rollback(); + assert_eq!(runtime.data_arc().exists_keys.contains("replacement"), replaced); + } + Ok(()) + } + const CONFIRM_WITHIN: Duration = Duration::from_secs(5); fn staged() -> StageAttempt { diff --git a/src-tauri/src/core/notify/announce.rs b/src-tauri/src/core/notify/announce.rs index b6fb11234..9b727e681 100644 --- a/src-tauri/src/core/notify/announce.rs +++ b/src-tauri/src/core/notify/announce.rs @@ -1,7 +1,6 @@ use super::{handle::Handle, notification::FrontendEvent}; -use anyhow::Result; use parking_lot::Mutex; -use std::{cell::RefCell, future::Future, time::Duration}; +use std::time::Duration; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Refresh { @@ -11,10 +10,6 @@ pub enum Refresh { Proxies, } -tokio::task_local! { - static PENDING: RefCell>; -} - const REFRESH_WINDOW: Duration = Duration::from_millis(20); static BATCH: RefreshBatch = RefreshBatch(Mutex::new(Vec::new())); @@ -42,7 +37,7 @@ impl RefreshBatch { } pub fn announce(refresh: Refresh) { - if PENDING.try_with(|pending| pending.borrow_mut().push(refresh)).is_err() && BATCH.enqueue(refresh) { + if BATCH.enqueue(refresh) { crate::AsyncHandler::spawn(|| BATCH.flush(emit)); } } @@ -56,54 +51,12 @@ fn emit(refresh: Refresh) { }); } -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(()) - } - - #[tokio::test] - async fn bursts_keep_each_refresh_kind_and_rearm_during_delivery() -> Result<()> { + async fn bursts_keep_each_refresh_kind_and_rearm_during_delivery() -> anyhow::Result<()> { let batch = RefreshBatch::default(); let delivered = Mutex::new(Vec::new()); assert!(batch.enqueue(Refresh::Clash)); diff --git a/src-tauri/src/core/notify/mod.rs b/src-tauri/src/core/notify/mod.rs index 00d5c7880..1387dbc81 100644 --- a/src-tauri/src/core/notify/mod.rs +++ b/src-tauri/src/core/notify/mod.rs @@ -1,5 +1,5 @@ mod announce; -pub use announce::{Refresh, after_commit, announce}; +pub use announce::{Refresh, announce}; pub mod desktop; pub mod handle; pub mod notification; diff --git a/src-tauri/src/core/updater.rs b/src-tauri/src/core/updater.rs index f9fca74d7..ab8e5a69c 100644 --- a/src-tauri/src/core/updater.rs +++ b/src-tauri/src/core/updater.rs @@ -2,15 +2,19 @@ use crate::{config::Config, singleton, utils::dirs}; use anyhow::{Context as _, Result}; use chrono::Utc; use clash_verge_logging::{Type, logging}; +use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use std::{ path::PathBuf, - sync::atomic::{AtomicBool, Ordering}, + sync::atomic::{AtomicBool, AtomicU64, Ordering}, + time::Duration, }; -use tauri_plugin_updater::UpdaterExt as _; +use tauri_plugin_updater::{Update, UpdaterExt as _}; +use tokio_util::sync::CancellationToken; pub struct SilentUpdater { update_ready: AtomicBool, + manual_cancel: Mutex>, } singleton!(SilentUpdater, SILENT_UPDATER); @@ -19,6 +23,7 @@ impl SilentUpdater { const fn new() -> Self { Self { update_ready: AtomicBool::new(false), + manual_cancel: Mutex::new(None), } } @@ -92,6 +97,72 @@ impl SilentUpdater { } } } + + /// Returns the cached installer for `update`, deleting a cache that does not match it. + fn verified_cache(app_handle: &tauri::AppHandle, update: &Update) -> Option> { + let meta = Self::read_cache_meta().ok()?; + let bytes = if meta.version == update.version { + Self::read_cache_bytes().and_then(|bytes| { + verify_signature(app_handle, &bytes, &update.signature, &update.version)?; + Ok(bytes) + }) + } else { + Err(anyhow::anyhow!("server offers v{}", update.version)) + }; + match bytes { + Ok(bytes) => Some(bytes), + Err(e) => { + logging!( + info, + Type::System, + "Update cache v{} is unusable: {e:#}, cleaning up", + meta.version + ); + Self::delete_cache(); + None + } + } + } +} + +/// The cache directory is user-writable and feeds an elevated installer, so it is re-verified +/// with the same rules `Update::download` applies. +fn verify_signature(app_handle: &tauri::AppHandle, bytes: &[u8], signature: &str, version: &str) -> Result<()> { + use base64::Engine as _; + + let decode = |value: &str| -> Result { + Ok(String::from_utf8( + base64::engine::general_purpose::STANDARD.decode(value)?, + )?) + }; + let config: tauri_plugin_updater::Config = serde_json::from_value( + app_handle + .config() + .plugins + .0 + .get("updater") + .cloned() + .context("updater is not configured")?, + )?; + + let public_key = minisign_verify::PublicKey::decode(&decode(&config.pubkey)?)?; + let signature = minisign_verify::Signature::decode(&decode(signature)?)?; + public_key + .verify(bytes, &signature, true) + .context("signature mismatch")?; + + match signature + .trusted_comment() + .split('\t') + .find_map(|field| field.strip_prefix("version:")) + { + Some(signed) => anyhow::ensure!( + signed.trim_start_matches('v') == version, + "signed for v{signed}, not v{version}" + ), + None => anyhow::ensure!(!config.require_signed_version, "signature carries no version"), + } + Ok(()) } pub fn is_build_to_stable(current: &str, remote: &str) -> bool { @@ -140,7 +211,121 @@ fn nsis_language_id(app_language: &str) -> &'static str { } } +/// Same wire shape as the updater plugin's JS `DownloadEvent`. +#[derive(Clone, Serialize)] +#[serde(tag = "event", content = "data")] +pub enum DownloadEvent { + #[serde(rename_all = "camelCase")] + Started { + content_length: Option, + }, + #[serde(rename_all = "camelCase")] + Progress { + chunk_length: usize, + }, + Finished, +} + +const MIRRORS: [&str; 2] = ["https://update.hwdns.net/", "https://gh-proxy.org/"]; +/// A source delivering less than this per window is dropped for the next one. +const MIN_PROGRESS: u64 = 1024 * 1024; +const PROGRESS_WINDOW: Duration = Duration::from_secs(30); + +/// The manifest's URL, then the other mirror, then GitHub itself. +fn download_sources(url: &tauri::Url) -> Vec { + let origin = MIRRORS + .iter() + .find_map(|mirror| url.as_str().strip_prefix(mirror)) + .unwrap_or(url.as_str()); + let mut sources = vec![url.clone()]; + if origin.starts_with("https://github.com/") { + let candidates = MIRRORS.iter().map(|mirror| format!("{mirror}{origin}")); + for candidate in candidates.chain([origin.to_owned()]) { + if let Ok(candidate) = tauri::Url::parse(&candidate) + && !sources.contains(&candidate) + { + sources.push(candidate); + } + } + } + sources +} + +/// Tries every source with a throughput floor, then again stopping only on a stall, so a link +/// that is slow everywhere still finishes. +async fn download(update: &Update, on_event: impl Fn(DownloadEvent) + Sync) -> Result> { + let sources = download_sources(&update.download_url); + let mut last_error = None; + for floor in [MIN_PROGRESS, 1] { + for url in &sources { + logging!(info, Type::System, "Downloading update v{} from {url}", update.version); + match download_from(update, url, floor, &on_event).await { + Ok(bytes) => { + on_event(DownloadEvent::Finished); + return Ok(bytes); + } + Err(e) => { + logging!(warn, Type::System, "Update download from {url} failed: {e:#}"); + last_error = Some(e); + } + } + } + } + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("no update download source"))) +} + +async fn download_from( + update: &Update, + url: &tauri::Url, + floor: u64, + on_event: &(impl Fn(DownloadEvent) + Sync), +) -> Result> { + let mut source = update.clone(); + source.download_url = url.clone(); + let received = AtomicU64::new(0); + let mut started = false; + let fetch = source.download( + |chunk_length, content_length| { + if !std::mem::replace(&mut started, true) { + on_event(DownloadEvent::Started { content_length }); + } + received.fetch_add(chunk_length as u64, Ordering::Relaxed); + on_event(DownloadEvent::Progress { chunk_length }); + }, + || {}, + ); + tokio::pin!(fetch); + let mut seen = 0; + loop { + tokio::select! { + result = &mut fetch => return Ok(result?), + () = tokio::time::sleep(PROGRESS_WINDOW) => { + let total = received.load(Ordering::Relaxed); + anyhow::ensure!(total - seen >= floor, "only {} bytes in {PROGRESS_WINDOW:?}", total - seen); + seen = total; + } + } + } +} + impl SilentUpdater { + /// Checks the server; Windows receives `/LANG` to suppress the NSIS language dialog, + /// see `packages/windows/installer.nsi`. + async fn check(app_handle: &tauri::AppHandle) -> Result> { + let updater_builder = app_handle.updater_builder(); + #[cfg(target_os = "windows")] + let updater_builder = { + let verge_lang = Config::verge().await.latest_arc().language.clone(); + let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref())); + updater_builder.installer_arg(format!("/LANG={lang_id}")) + }; + Ok(updater_builder.build()?.check().await?) + } + + async fn install(update: Update, bytes: Vec) -> Result<()> { + Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??) + } + /// Installs a newer cached update before normal startup, if the user confirms. pub async fn try_install_on_startup(&self, app_handle: &tauri::AppHandle) -> bool { let current_version = env!("CARGO_PKG_VERSION"); @@ -178,71 +363,30 @@ impl SilentUpdater { return false; } - let bytes = match Self::read_cache_bytes() { - Ok(b) => b, - Err(e) => { + let update = match Self::check(app_handle).await { + Ok(Some(u)) => u, + Ok(None) => { logging!( - warn, + info, Type::System, - "Failed to read cached update bytes: {e:#}, cleaning up" + "No update available from server, cache may be stale, cleaning up" ); Self::delete_cache(); return false; } - }; - - // Refresh metadata without re-downloading. Windows receives `/LANG` to suppress the - // NSIS language dialog; see `packages/windows/installer.nsi`. - let updater_builder = app_handle.updater_builder(); - #[cfg(target_os = "windows")] - let updater_builder = { - let verge_lang = Config::verge().await.latest_arc().language.clone(); - let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref())); - updater_builder.installer_arg(format!("/LANG={lang_id}")) - }; - let update = match updater_builder.build() { - Ok(updater) => match updater.check().await { - Ok(Some(u)) => u, - Ok(None) => { - logging!( - info, - Type::System, - "No update available from server, cache may be stale, cleaning up" - ); - Self::delete_cache(); - return false; - } - Err(e) => { - logging!( - warn, - Type::System, - "Failed to check for update at startup: {e}, will retry next launch" - ); - return false; // Keep cache for next attempt - } - }, Err(e) => { logging!( warn, Type::System, - "Failed to create updater: {e}, will retry next launch" + "Failed to check for update at startup: {e:#}, will retry next launch" ); - return false; + return false; // Keep cache for next attempt } }; - // A changed server version invalidates the cached bytes. - if update.version != *cached_version { - logging!( - info, - Type::System, - "Server version ({}) != cached version ({}), cache is stale, cleaning up", - update.version, - cached_version - ); - Self::delete_cache(); + let Some(bytes) = Self::verified_cache(app_handle, &update) else { return false; - } + }; let version = update.version.clone(); logging!(info, Type::System, "Installing cached update v{version} at startup..."); @@ -250,49 +394,77 @@ impl SilentUpdater { Self::show_update_splash(app_handle, &version); // `install()` may hang (#2558); on Windows NSIS can take over without returning. - let install_result = tokio::task::spawn_blocking({ - let bytes = bytes.clone(); - let update = update.clone(); - move || update.install(&bytes) - }); - - let success = match tokio::time::timeout(std::time::Duration::from_secs(30), install_result).await { - Ok(Ok(Ok(()))) => { + let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes)); + match install + .await + .unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)"))) + { + Ok(()) => { logging!(info, Type::System, "Update v{version} install triggered at startup"); Self::delete_cache(); true } - Ok(Ok(Err(e))) => { + Err(e) => { logging!( warn, Type::System, - "Startup install failed: {e}, will retry next launch" - ); - false - } - Ok(Err(e)) => { - logging!( - warn, - Type::System, - "Startup install task panicked: {e}, will retry next launch" - ); - false - } - Err(_) => { - logging!( - warn, - Type::System, - "Startup install timed out (30s), will retry next launch" + "Startup install failed: {e:#}, will retry next launch" ); + Self::close_update_splash(app_handle); false } + } + } + + /// Installs `version` for the update dialog, reusing the cache when it holds that version; + /// `false` when cancelled before the installer starts. + pub async fn install_update( + &self, + app_handle: &tauri::AppHandle, + version: &str, + on_event: impl Fn(DownloadEvent) + Sync, + ) -> Result { + let cancel = CancellationToken::new(); + *self.manual_cancel.lock() = Some(cancel.clone()); + let prepared = tokio::select! { + prepared = Self::prepare_install(app_handle, version, on_event) => Some(prepared), + () = cancel.cancelled() => None, + }; + // `cancel_download` cancels under the lock, so once the token is taken back its state is + // final; a cancel during the last synchronous poll of `prepare_install` still wins. + self.manual_cancel.lock().take(); + let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else { + logging!(info, Type::System, "Update v{version} cancelled"); + return Ok(false); }; - if !success { - Self::close_update_splash(app_handle); - } + logging!(info, Type::System, "Installing update v{version}..."); + Self::install(update, bytes).await?; + Self::delete_cache(); + Ok(true) + } - success + async fn prepare_install( + app_handle: &tauri::AppHandle, + version: &str, + on_event: impl Fn(DownloadEvent) + Sync, + ) -> Result<(Update, Vec)> { + let update = Self::check(app_handle) + .await? + .filter(|update| update.version == version) + .with_context(|| format!("v{version} is no longer offered"))?; + let bytes = match Self::verified_cache(app_handle, &update) { + Some(bytes) => bytes, + None => download(&update, on_event).await?, + }; + Ok((update, bytes)) + } + + pub fn cancel_download(&self) { + let mut slot = self.manual_cancel.lock(); + if let Some(cancel) = slot.take() { + cancel.cancel(); + } } } @@ -449,24 +621,13 @@ impl SilentUpdater { return Ok(()); } - logging!(info, Type::System, "Silent updater: downloading v{version}..."); - let bytes = update - .download( - |chunk_len, content_len| { - logging!( - debug, - Type::System, - "Silent updater download progress: chunk={chunk_len}, total={content_len:?}" - ); - }, - || { - logging!(info, Type::System, "Silent updater: download complete"); - }, - ) - .await?; - - if let Err(e) = Self::write_cache(&bytes, &version) { - logging!(warn, Type::System, "Silent updater: failed to write cache: {e:#}"); + if Self::verified_cache(app_handle, &update).is_some() { + logging!(info, Type::System, "Silent updater: v{version} already cached"); + } else { + logging!(info, Type::System, "Silent updater: downloading v{version}..."); + let bytes = download(&update, |_| {}).await?; + logging!(info, Type::System, "Silent updater: download complete"); + Self::write_cache(&bytes, &version)?; } self.update_ready.store(true, Ordering::Release); diff --git a/src-tauri/src/feat/executor.rs b/src-tauri/src/feat/executor.rs index 471f265b7..c301a6975 100644 --- a/src-tauri/src/feat/executor.rs +++ b/src-tauri/src/feat/executor.rs @@ -63,8 +63,6 @@ async fn ensure_effect( } Effect::ClashConfig => { manager.update_config_in_patch(update).await?; - Config::runtime().await.apply(); - announce(Refresh::Clash); } Effect::Autostart => autostart::update_launch().await?, Effect::Language => { @@ -123,7 +121,7 @@ async fn ensure_effect( Ok(()) } -async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { +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; @@ -167,6 +165,7 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect if let Err(error) = result { transaction.rollback(); if !Arc::ptr_eq(&original_runtime, &runtime.data_arc()) { + announce(Refresh::Clash); // 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); @@ -179,6 +178,9 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect transaction.commit(); match patch { Patch::Verge { .. } => { + if effects.contains(&Effect::ClashConfig) { + announce(Refresh::Clash); + } logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await); announce(Refresh::Verge); } @@ -187,16 +189,6 @@ async fn apply_inner(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effect Ok(()) } -pub(super) async fn apply(config_write: &MutexGuard<'_, ()>, patch: Patch<'_>, effects: Effects) -> Result<()> { - 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)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5612543a6..3426765b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -142,6 +142,8 @@ mod app_init { cmd::get_network_interfaces, cmd::get_system_hostname, cmd::restart_app, + cmd::install_update, + cmd::cancel_update_download, cmd::restart_core, cmd::upgrade_clash_core, cmd::get_runtime_state, diff --git a/src-tauri/src/utils/retry.rs b/src-tauri/src/utils/retry.rs index 5becccac1..72c79017b 100644 --- a/src-tauri/src/utils/retry.rs +++ b/src-tauri/src/utils/retry.rs @@ -1,46 +1,18 @@ -use backon::{BackoffBuilder as _, ExponentialBackoff, ExponentialBuilder}; use std::{future::Future, num::NonZeroUsize, time::Duration}; -#[derive(Clone, Copy, Debug)] -pub enum Backoff { - Fixed(Duration), - #[allow(dead_code)] - Exponential { - base: Duration, - cap: Duration, - }, -} - #[derive(Clone, Copy, Debug)] pub struct RetryPolicy { pub attempts: NonZeroUsize, - pub backoff: Backoff, - pub jitter: bool, + delay: Duration, } impl RetryPolicy { pub const fn fixed(attempts: NonZeroUsize, delay: Duration) -> Self { - Self { - attempts, - backoff: Backoff::Fixed(delay), - jitter: false, - } + Self { attempts, delay } } - fn delays(self) -> ExponentialBackoff { - let (base, cap, factor) = match self.backoff { - Backoff::Fixed(delay) => (delay, delay, 1.0), - Backoff::Exponential { base, cap } => (base, cap, 2.0), - }; - let mut builder = ExponentialBuilder::default() - .with_min_delay(base) - .with_max_delay(cap) - .with_factor(factor) - .with_max_times(self.attempts.get() - 1); - if self.jitter { - builder = builder.with_jitter(); - } - builder.build() + fn delays(self) -> impl Iterator { + std::iter::repeat_n(self.delay, self.attempts.get() - 1) } } @@ -82,11 +54,7 @@ pub async fn try_strategies< mut operation: impl FnMut(S) -> Fut + Send, ) -> Result<(S, T), E> { retry( - RetryPolicy { - attempts: NonZeroUsize::MIN.saturating_add(N), - backoff: Backoff::Fixed(Duration::ZERO), - jitter: false, - }, + RetryPolicy::fixed(NonZeroUsize::MIN.saturating_add(N), Duration::ZERO), move |attempt| { let strategy = if attempt == 0 { first } else { rest[attempt - 1] }; let future = operation(strategy); @@ -145,11 +113,7 @@ mod tests { #[tokio::test] async fn retries_stop_at_success_terminal_error_or_attempt_limit() { - let policy = RetryPolicy { - attempts: NonZeroUsize::MIN.saturating_add(2), - backoff: Backoff::Fixed(Duration::ZERO), - jitter: false, - }; + let policy = RetryPolicy::fixed(NonZeroUsize::MIN.saturating_add(2), Duration::ZERO); let mut calls = 0; let success = retry(policy, |attempt| { calls += 1; diff --git a/src/AGENTS.md b/src/AGENTS.md index 38528e0e7..51a3b125d 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,32 +1,23 @@ # Frontend Architecture Guardrails -These rules supplement the repository's root `AGENTS.md` and define the -frontend's ownership and module boundaries. +These rules supplement the repository's root `AGENTS.md`. -1. **Contract is generated, never hand-typed.** Event names, payload shapes, - and the `NoticeStatus` union come from `src/services/contract.ts` - (`@generated`) from the backend wire contract. Keep backend producers, - generated types, and the exhaustive notice table - (`src/services/notice-handlers.ts`) aligned; `Record` - makes notice handling part of that contract. -2. **`listen(`/`emit(` live only in the event bus.** All Tauri event - subscriptions and emissions go through `src/services/bus/`. The shared - bus owns subscription registration and teardown at the layout root. - Components and pages never touch `@tauri-apps/api/event`. - Window-lifecycle helpers that do not use the event-module API may stay - with their owning component. -3. **Event-derived state lives in the app store.** Cross-component state - produced by events is written only through pure reducer actions dispatched - by the bus or the funnel (`src/store/`). Handlers may perform documented - side effects (SWR revalidation, notices) but must not write store state - directly. New event-consumed state needs a reducer action first, not a - local `useState` in a page. -4. **User intent funnels through `mutate`.** Every mutating backend command - call goes through `src/services/mutate.ts` — components, hooks, and - dialogs included. Give each intent a stable `id`; express optimistic - writes as `optimistic`/`rollback` and success refreshes as `revalidate`/ - `onFulfilled`. Flows with bespoke error UX keep it via `errorNotice: - false`; suppressing both the funnel's and the site's handling is a bug. -5. **`invoke(` lives only in services.** Backend commands are wrapped in - `src/services/cmds.ts`; mihomo-plugin mutations use the same intent funnel. - Pages and components import the wrapper, never `@tauri-apps/api/core`. +- **Contract ownership.** Generated event and notice contracts are owned by the + backend wire contract. Handwritten payload types are acceptable when they + match their producers. Event names, payloads and the typed notice table stay + aligned across backend and frontend. +- **Event ownership.** `services/bus` owns event subscriptions, emissions and + their lifetime. Components and pages consume the bus rather than subscribing + independently; window lifecycle remains with its owning component. +- **State ownership.** `store` owns shared event state and the snapshot reads that + reconcile it through pure reducer actions. Query caches retain ordinary + configuration and profile reads. Reads and live events share an ordering + boundary so an older reply cannot replace newer authoritative state. + Components keep local UI state. +- **Intent ownership.** Mutating commands pass through the shared intent funnel. + Resource ownership determines serialization; distinct requests are preserved, + and success effects follow actual command success. Bespoke error flows retain + their handling without duplicate or suppressed errors. +- **Command ownership.** `services` owns backend command invocation and transport + wrappers. Components and hooks use those boundaries; plugin mutations follow + the same intent ownership as backend commands. diff --git a/src/components/layout/service-migration-dialog.test.ts b/src/components/layout/service-migration-dialog.test.ts index c05b9b508..8c292864d 100644 --- a/src/components/layout/service-migration-dialog.test.ts +++ b/src/components/layout/service-migration-dialog.test.ts @@ -23,7 +23,7 @@ vi.mock('react-i18next', () => ({ vi.mock('@mui/material', () => ({ Alert: 'div' })) vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' })) vi.mock('@/store/app-store-context', () => ({ - useAppDispatch: () => store.dispatch, + useAppReads: () => ({ readRunState: getRuntimeState }), useRunState: () => store.runState, })) vi.mock('@/services/cmds', () => ({ diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index 084a28fe4..756ceff24 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next' import { BaseDialog } from '@/components/base' import { continueWithSidecar, - getRuntimeState, installService, reinstallService, repairService, @@ -14,14 +13,14 @@ import { } from '@/services/cmds' import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' -import { useAppDispatch, useRunState } from '@/store/app-store-context' +import { useAppReads, useRunState } from '@/store/app-store-context' export const ServiceMigrationDialog = () => { const { t } = useTranslation() const [loading, setLoading] = useState(false) const [stateRefreshFailed, setStateRefreshFailed] = useState(false) const [workflowIncomplete, setWorkflowIncomplete] = useState(false) - const dispatch = useAppDispatch() + const { readRunState } = useAppReads() const runState = useRunState() // Whether the service needs a decision is derived once, in Rust, and travels with the // snapshot; a failed refresh is treated as needing one, since we cannot tell otherwise. @@ -59,8 +58,7 @@ export const ServiceMigrationDialog = () => { // One store entry to refresh, so there is nothing left to keep coherent by hand. const refreshRunState = async () => { try { - const data = await getRuntimeState() - dispatch({ type: 'runState/loaded', runState: data }) + const data = await readRunState() setStateRefreshFailed(false) return data } catch (error) { diff --git a/src/components/layout/sysproxy-privilege-dialog.tsx b/src/components/layout/sysproxy-privilege-dialog.tsx index 87a540a6d..fb493157c 100644 --- a/src/components/layout/sysproxy-privilege-dialog.tsx +++ b/src/components/layout/sysproxy-privilege-dialog.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next' import { BaseDialog } from '@/components/base' import { useDialogFailure } from '@/pages/_layout/hooks' import { - getRuntimeState, installService, patchVergeConfig, reinstallService, @@ -23,6 +22,7 @@ import { type ServiceRequest, type ServiceRequestReason, } from '@/services/service-request' +import { useAppReads } from '@/store/app-store-context' import getSystem from '@/utils/get-system' type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly' @@ -84,6 +84,7 @@ const STEP_MESSAGE = { } as const export const SysproxyPrivilegeDialog = () => { + const { readRunState } = useAppReads() const { t } = useTranslation() const { failure, dismiss } = useDialogFailure() const asked = useSyncExternalStore(subscribeServiceRequest, getServiceRequest) @@ -135,7 +136,7 @@ export const SysproxyPrivilegeDialog = () => { errorNotice: false, }) - const runState = await getRuntimeState() + const runState = await readRunState() const usingAdminFallback = remedy !== 'reinstallAndRestart' && getSystem() === 'windows' && diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index d29d9ffc5..69e718486 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -458,7 +458,7 @@ export const ProxyChain = ({ if (targetGroup) { try { await mutate(() => selectNodeForGroup(targetGroup, 'DIRECT'), { - id: 'select-proxy-node', + id: `select-proxy-node:${targetGroup}`, errorNotice: false, }) recordSelection(targetGroup, 'DIRECT') @@ -469,7 +469,7 @@ export const ProxyChain = ({ () => selectNodeForGroup(targetGroup, currentProxyChain[0].name), { - id: 'select-proxy-node', + id: `select-proxy-node:${targetGroup}`, errorNotice: false, }, ) @@ -539,7 +539,7 @@ export const ProxyChain = ({ await mutate( () => selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name), { - id: 'select-proxy-node', + id: `select-proxy-node:${targetGroup || 'GLOBAL'}`, errorNotice: false, }, ) diff --git a/src/components/setting/mods/backup-history-viewer.tsx b/src/components/setting/mods/backup-history-viewer.tsx index 64522d496..6147ab4be 100644 --- a/src/components/setting/mods/backup-history-viewer.tsx +++ b/src/components/setting/mods/backup-history-viewer.tsx @@ -257,7 +257,10 @@ export const BackupHistoryViewer = ({ showNotice.success('settings.modals.backup.messages.restoreSuccess') setIsRestarting(true) window.setTimeout(() => { - void restartApp().catch((err: unknown) => { + void mutate(() => restartApp(), { + id: 'restart-app', + errorNotice: false, + }).catch((err: unknown) => { setIsRestarting(false) showNotice.error(err) }) diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 0e9a2bb6b..15493da24 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -41,6 +41,7 @@ import { saveDnsConfig, validateDnsConfig, } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { useThemeMode } from '@/services/states' import type { MonacoEditorInstance } from '@/types/monaco' @@ -519,7 +520,10 @@ export function DnsViewer({ ref }: { ref?: Ref }) { config = parsedConfig as Record } - await saveDnsConfig(config) + await mutate(() => saveDnsConfig(config), { + id: 'save-dns-config', + errorNotice: false, + }) const validation = await validateDnsConfig() @@ -558,7 +562,10 @@ export function DnsViewer({ ref }: { ref?: Ref }) { } if (dnsEnabled) { - await applyDnsConfig(true) + await mutate(() => applyDnsConfig(true), { + id: 'apply-dns-config', + errorNotice: false, + }) mutateClash() } diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index df13cc45c..f818d61de 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -22,6 +22,7 @@ import { DEFAULT_HOVER_DELAY } from '@/components/proxy/proxy-group-navigator' import { useVerge } from '@/hooks/use-verge' import { useWindowDecorations } from '@/hooks/use-window' import { copyIconFile, getAppDir } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import getSystem from '@/utils/get-system' @@ -536,7 +537,9 @@ export const LayoutViewer = forwardRef((_, ref) => { }) if (selected) { - await copyIconFile(`${selected}`, 'common') + await mutate(() => copyIconFile(`${selected}`, 'common'), { + id: 'copy-icon:common', + }) await initIconPath() onChangeData({ common_tray_icon: true }) patchVerge({ common_tray_icon: true }) @@ -588,7 +591,10 @@ export const LayoutViewer = forwardRef((_, ref) => { ], }) if (selected) { - await copyIconFile(`${selected}`, 'sysproxy') + await mutate( + () => copyIconFile(`${selected}`, 'sysproxy'), + { id: 'copy-icon:sysproxy' }, + ) await initIconPath() onChangeData({ sysproxy_tray_icon: true }) patchVerge({ sysproxy_tray_icon: true }) @@ -636,7 +642,9 @@ export const LayoutViewer = forwardRef((_, ref) => { ], }) if (selected) { - await copyIconFile(`${selected}`, 'tun') + await mutate(() => copyIconFile(`${selected}`, 'tun'), { + id: 'copy-icon:tun', + }) await initIconPath() onChangeData({ tun_tray_icon: true }) patchVerge({ tun_tray_icon: true }) diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 9545d8dd6..8c4db006c 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -43,6 +43,7 @@ import { getSystemProxy, patchVergeConfig, } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { debugLog } from '@/utils/debug' import getSystem from '@/utils/get-system' @@ -374,9 +375,14 @@ export const SysproxyViewer = forwardRef((props, ref) => { : currentSysProxy?.enable if (isProxyActive) { - await patchVergeConfig({ enable_system_proxy: false }) - await sleep(50) - await patchVergeConfig({ enable_system_proxy: true }) + await mutate( + async () => { + await patchVergeConfig({ enable_system_proxy: false }) + await sleep(50) + await patchVergeConfig({ enable_system_proxy: true }) + }, + { id: 'patch-verge-config', errorNotice: false }, + ) await invalidateProxyState() } } diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index cbdd933c3..6704f93a9 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -15,7 +15,11 @@ import type { Options as ReactMarkdownOptions } from 'react-markdown' import { BaseDialog, DialogRef } from '@/components/base' import { useUpdate } from '@/hooks/use-update' -import { restartApp } from '@/services/cmds' +import { + cancelUpdateDownload, + installUpdate, + restartApp, +} from '@/services/cmds' import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import { useSetUpdateState, useUpdateState } from '@/services/states' @@ -271,14 +275,19 @@ export function UpdateViewer({ ref }: { ref?: Ref }) { } try { - await mutate(() => updateInfo.downloadAndInstall(onDownloadEvent), { - id: 'install-app-update', - errorNotice: false, - }) - await mutate(() => restartApp(), { - id: 'restart-app', - errorNotice: false, - }) + const result = await mutate( + () => installUpdate(updateInfo.version, onDownloadEvent), + { + id: 'install-app-update', + errorNotice: false, + }, + ) + if (result.ok && result.value) { + await mutate(() => restartApp(), { + id: 'restart-app', + errorNotice: false, + }) + } } catch (err: any) { showNotice.error(err) } finally { @@ -341,7 +350,15 @@ export function UpdateViewer({ ref }: { ref?: Ref }) { okBtn={t('settings.modals.update.actions.update')} cancelBtn={t('shared.actions.cancel')} onClose={() => setOpen(false)} - onCancel={() => setOpen(false)} + onCancel={() => { + if (updateState) { + void mutate(() => cancelUpdateDownload(), { + id: 'cancel-update-download', + errorNotice: false, + }).catch(showNotice.error) + } + setOpen(false) + }} onOk={onUpdate} > { const patchCurrent = useCallback( async (value: Partial) => { if (profiles?.current) { - await patchProfile(profiles.current, value) + const uid = profiles.current + await mutate(() => patchProfile(uid, value), { + id: `patch-profile:${uid}`, + errorNotice: false, + }) void mutateProfiles() } }, diff --git a/src/hooks/use-proxy-selection.test.ts b/src/hooks/use-proxy-selection.test.ts new file mode 100644 index 000000000..d176baf66 --- /dev/null +++ b/src/hooks/use-proxy-selection.test.ts @@ -0,0 +1,51 @@ +import { selectNodeForGroup } from 'tauri-plugin-mihomo-api' +import { expect, it, vi } from 'vitest' + +import { useProxySelection } from './use-proxy-selection' + +const record = vi.hoisted(() => vi.fn()) +vi.mock('react', () => ({ + useCallback: (callback: unknown) => callback, + useRef: (current: unknown) => ({ current }), +})) +vi.mock('@/hooks/use-record-selection', () => ({ + useRecordSelection: () => record, + useForgetSelection: () => vi.fn(), +})) +vi.mock('@/hooks/use-verge', () => ({ useVerge: () => ({ verge: {} }) })) +vi.mock('tauri-plugin-mihomo-api', () => ({ + selectNodeForGroup: vi.fn(), + unfixedProxy: vi.fn(), +})) +vi.mock('@/services/cmds', () => ({ syncTrayProxySelection: vi.fn() })) +vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } })) +vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() })) + +it('applies and records independent group selections while another group is pending', async () => { + let finishFirst!: () => void + vi.mocked(selectNodeForGroup) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve + }), + ) + .mockResolvedValueOnce(undefined) + const firstSuccess = vi.fn() + const secondSuccess = vi.fn() + const first = useProxySelection({ onSuccess: firstSuccess }) + const second = useProxySelection({ onSuccess: secondSuccess }) + first.changeProxy('Group A', 'Node A') + second.changeProxy('Group B', 'Node B') + await vi.waitFor(() => + expect(record).toHaveBeenCalledWith('Group B', 'Node B'), + ) + expect(selectNodeForGroup).toHaveBeenCalledTimes(2) + expect(firstSuccess).not.toHaveBeenCalled() + expect(secondSuccess).toHaveBeenCalledOnce() + finishFirst() + await vi.waitFor(() => + expect(record).toHaveBeenCalledWith('Group A', 'Node A'), + ) + expect(firstSuccess).toHaveBeenCalledOnce() +}) diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index 8300c3bf2..e3a1d40e6 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -78,17 +78,14 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { } try { - if (isFixedProxy) { - await mutate(() => unfixedProxy(groupName), { - id: 'select-proxy-node', - errorNotice: false, - }) - } else { - await mutate(() => selectNodeForGroup(groupName, proxyName), { - id: 'select-proxy-node', - errorNotice: false, - }) - } + const result = await mutate( + () => + isFixedProxy + ? unfixedProxy(groupName) + : selectNodeForGroup(groupName, proxyName), + { id: `select-proxy-node:${groupName}`, errorNotice: false }, + ) + if (!result.ok) return onSuccess?.() syncTraySelection() if (isFixedProxy) { diff --git a/src/hooks/use-record-selection.ts b/src/hooks/use-record-selection.ts index 6e0af590c..66e5736a8 100644 --- a/src/hooks/use-record-selection.ts +++ b/src/hooks/use-record-selection.ts @@ -11,7 +11,7 @@ export const useRecordSelection = () => { return useCallback(async (groupName: string, proxyName: string) => { try { await mutate(() => recordSelectedNode(groupName, proxyName), { - id: 'record-selected-node', + id: `persist-selected-node:${groupName}`, errorNotice: false, }) } catch (error) { @@ -24,7 +24,7 @@ export const useForgetSelection = () => { return useCallback(async (groupName: string) => { try { await mutate(() => forgetSelectedNode(groupName), { - id: 'forget-selected-node', + id: `persist-selected-node:${groupName}`, errorNotice: false, }) } catch (error) { diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 15aa4ceee..a2079c882 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -1,11 +1,6 @@ -import { - getAppUptime, - getRuntimeState, - type RunState, - type RunningMode, -} from '@/services/cmds' +import { getAppUptime, type RunState, type RunningMode } from '@/services/cmds' import { useQuery } from '@/services/query-client' -import { useAppDispatch, useRunState } from '@/store/app-store-context' +import { useAppReads, useRunState } from '@/store/app-store-context' const appUptimeQueryKey = ['appUptime'] as const @@ -25,7 +20,7 @@ const unknownRunState: RunState = { /** Event-driven run state; Rust owns all derived availability decisions. */ export function useSystemState() { - const dispatch = useAppDispatch() + const { readRunState } = useAppReads() const snapshot = useRunState() const runState = snapshot ?? unknownRunState @@ -37,11 +32,7 @@ export function useSystemState() { isServiceMode: runState.mode === 'Service', isTunModeAvailable: runState.tunCapable, serviceNeedsAttention: runState.serviceNeedsAttention, - mutateSystemState: async () => { - const fresh = await getRuntimeState() - dispatch({ type: 'runState/loaded', runState: fresh }) - return fresh - }, + mutateSystemState: readRunState, isLoading: snapshot == null, } } diff --git a/src/services/bus/use-event-bus.test.ts b/src/services/bus/use-event-bus.test.ts index a0deed42c..13ba8f138 100644 --- a/src/services/bus/use-event-bus.test.ts +++ b/src/services/bus/use-event-bus.test.ts @@ -1,5 +1,7 @@ import { beforeEach, expect, it, vi } from 'vitest' +import { createStoreReads } from '@/store/store-reads' + import { useEventBus } from './use-event-bus' const listen = vi.hoisted(() => @@ -25,6 +27,7 @@ vi.mock('react', async (importOriginal) => { }) vi.mock('@/store/app-store-context', () => ({ useAppDispatch: () => dispatch, + useAppReads: () => createStoreReads(dispatch), })) vi.mock('@/services/cmds', () => ({ getPendingFailures: vi.fn().mockResolvedValue([]), diff --git a/src/services/bus/use-event-bus.ts b/src/services/bus/use-event-bus.ts index 4a3fe2bb0..8c22e10ed 100644 --- a/src/services/bus/use-event-bus.ts +++ b/src/services/bus/use-event-bus.ts @@ -2,13 +2,8 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { useEffect } from 'react' -import { - getPendingFailures, - getRuntimeState, - getSidecarFailure, -} from '@/services/cmds' import { revalidateQueries } from '@/services/query-client' -import { useAppDispatch } from '@/store/app-store-context' +import { useAppDispatch, useAppReads } from '@/store/app-store-context' import { createEventBus } from './create-bus-handlers' @@ -21,31 +16,15 @@ export const useEventBus = ( handleNotice: (payload: [string, string]) => void, ) => { const dispatch = useAppDispatch() + const reads = useAppReads() useEffect(() => { const revalidateKeys = (keys: readonly string[]) => { void revalidateQueries(keys.map((key) => [key])) } - const readPendingFailures = () => { - getSidecarFailure() - .then((snapshot) => - dispatch({ type: 'sidecarFailure/loaded', snapshot }), - ) - .catch((error) => { - console.warn('[bus] Sidecar failure could not be read:', error) - }) - getPendingFailures() - .then((failures) => - dispatch({ type: 'pendingFailures/loaded', failures }), - ) - .catch((error) => { - console.warn('[bus] pending failures could not be read:', error) - }) - } + const { readPendingFailures } = reads const readRunState = () => { - getRuntimeState() - .then((runState) => dispatch({ type: 'runState/loaded', runState })) - .catch(() => {}) + void reads.readRunState().catch(() => {}) } const bus = createEventBus({ @@ -91,5 +70,5 @@ export const useEventBus = ( for (const unlisten of unlisteners.splice(0)) unlisten() void unlistenFocus.then((unlisten) => unlisten()) } - }, [dispatch, handleNotice]) + }, [dispatch, handleNotice, reads]) } diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 22a65bc11..c5d7c1197 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -1,4 +1,5 @@ import { Channel, invoke } from '@tauri-apps/api/core' +import type { DownloadEvent } from '@tauri-apps/plugin-updater' import dayjs from 'dayjs' import type { CommandFailure } from '@/services/notice-service' @@ -285,6 +286,19 @@ export async function restartApp() { return invoke('restart_app') } +export async function installUpdate( + version: string, + onEvent: (event: DownloadEvent) => void, +) { + const channel = new Channel() + channel.onmessage = onEvent + return invoke('install_update', { version, onEvent: channel }) +} + +export async function cancelUpdateDownload() { + return invoke('cancel_update_download') +} + export async function getAppDir() { return invoke('get_app_dir') } diff --git a/src/services/mutate.test.ts b/src/services/mutate.test.ts new file mode 100644 index 000000000..ce6299340 --- /dev/null +++ b/src/services/mutate.test.ts @@ -0,0 +1,33 @@ +import { expect, it, vi } from 'vitest' + +import { mutate } from './mutate' + +vi.mock('@/services/notice-service', () => ({ + showNotice: { error: vi.fn(), warning: vi.fn(), success: vi.fn() }, +})) +vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() })) + +it('executes every queued setting write even when its predecessor fails', async () => { + let reject!: (error: Error) => void + const first = mutate( + () => + new Promise((_resolve, rejectFirst) => { + reject = rejectFirst + }), + { id: 'patch-verge-config', errorNotice: false }, + ).catch((error) => error) + const writeSecond = vi.fn().mockResolvedValue(undefined) + const onFulfilled = vi.fn() + const second = mutate(writeSecond, { + id: 'patch-verge-config', + onFulfilled, + errorNotice: false, + }) + expect(writeSecond).not.toHaveBeenCalled() + const error = new Error('first write failed') + reject(error) + expect(await first).toBe(error) + expect(await second).toEqual({ ok: true, value: undefined }) + expect(writeSecond).toHaveBeenCalledOnce() + expect(onFulfilled).toHaveBeenCalledOnce() +}) diff --git a/src/services/mutate.ts b/src/services/mutate.ts index 546a62056..269629a90 100644 --- a/src/services/mutate.ts +++ b/src/services/mutate.ts @@ -1,25 +1,18 @@ import { showNotice } from '@/services/notice-service' import { revalidateQueries } from '@/services/query-client' -import type { AppStoreAction } from '@/store/app-state' /** Anything a notice call accepts: translation key, ReactNode, or failure payload. */ type MutateNoticeInput = Parameters[0] export interface MutateOptions { - /** Busy/in-flight identity; a second intent with the same id resolves `{ busy: true }`. */ + /** Commands with the same identity execute in request order. */ id: string - /** Applied before the command runs; undone by `rollback` on failure. */ - optimistic?: () => void - /** Restores pre-optimistic state when the command rejects. */ - rollback?: () => void | Promise /** Runs after a fulfilled (non-busy) result, e.g. cache merges. */ onFulfilled?: (value: T) => void /** SWR keys revalidated after a fulfilled result. */ revalidate?: readonly (readonly unknown[] | string)[] /** Shown after a fulfilled result (most flows leave this to backend notices). */ successNotice?: MutateNoticeInput - /** Shown when the command resolves with `{ status: 'busy' }`. */ - busyNotice?: MutateNoticeInput /** `false` suppresses the default error toast. */ errorNotice?: false | MutateNoticeInput } @@ -34,37 +27,23 @@ const isBusyOutcome = (value: unknown): boolean => value !== null && (value as { status?: unknown }).status === 'busy' -const inFlight = new Set() -let storeDispatch: ((action: AppStoreAction) => void) | null = null - -/** The app store registers its dispatcher so the funnel can record busy state. */ -export const bindStoreDispatch = ( - dispatch: ((action: AppStoreAction) => void) | null, -) => { - storeDispatch = dispatch -} - -/** - * The single funnel for "user intent → backend command": optimistic apply, - * invoke through the services layer, Busy handling, failure rollback, and the - * error toast, in that order, for every mutating call site. - */ +const inFlight = new Map>() export async function mutate( invoke: () => Promise, options: MutateOptions, ): Promise> { const { id } = options - if (inFlight.has(id)) return { ok: false, busy: true } - inFlight.add(id) - storeDispatch?.({ type: 'busy/started', id }) + const previous = inFlight.get(id) + let release!: () => void + const settled = new Promise((resolve) => { + release = resolve + }) + inFlight.set(id, settled) + if (previous) await previous - options.optimistic?.() try { const value = await invoke() if (isBusyOutcome(value)) { - if (options.busyNotice !== undefined) { - showNotice.warning(options.busyNotice) - } return { ok: false, busy: true, value } } options.onFulfilled?.(value) @@ -76,7 +55,6 @@ export async function mutate( } return { ok: true, value } } catch (error) { - await options.rollback?.() if (options.errorNotice !== false) { showNotice.error( options.errorNotice !== undefined ? options.errorNotice : error, @@ -84,7 +62,7 @@ export async function mutate( } throw error } finally { - inFlight.delete(id) - storeDispatch?.({ type: 'busy/settled', id }) + release() + if (inFlight.get(id) === settled) inFlight.delete(id) } } diff --git a/src/store/app-state.ts b/src/store/app-state.ts index 8493c7769..c0e143308 100644 --- a/src/store/app-state.ts +++ b/src/store/app-state.ts @@ -20,8 +20,6 @@ export interface AppStoreState { readonly profileUpdates: ProfileUpdatesState /** Bumped once per test-all request; test items run their delay test per bump. */ readonly testAllCounter: number - /** Mutate-funnel bookkeeping: in-flight intent ids. */ - readonly busy: ReadonlySet } export const initialAppStoreState: AppStoreState = { @@ -34,7 +32,6 @@ export const initialAppStoreState: AppStoreState = { timerRevisions: new Map(), }, testAllCounter: 0, - busy: new Set(), } export type AppStoreAction = @@ -46,8 +43,6 @@ export type AppStoreAction = | { type: 'profileUpdate/timerTick'; uid: string } | { type: 'profileLoading/set'; uids: readonly string[]; loading: boolean } | { type: 'testAll/requested' } - | { type: 'busy/started'; id: string } - | { type: 'busy/settled'; id: string } const bumpRevision = ( map: ReadonlyMap, @@ -125,17 +120,5 @@ export function appStoreReducer( return withLoading(state, action.uids, action.loading) case 'testAll/requested': return { ...state, testAllCounter: state.testAllCounter + 1 } - case 'busy/started': { - if (state.busy.has(action.id)) return state - const busy = new Set(state.busy) - busy.add(action.id) - return { ...state, busy } - } - case 'busy/settled': { - if (!state.busy.has(action.id)) return state - const busy = new Set(state.busy) - busy.delete(action.id) - return { ...state, busy } - } } } diff --git a/src/store/app-store-context.ts b/src/store/app-store-context.ts index 3e9ae1f72..aa2ca7351 100644 --- a/src/store/app-store-context.ts +++ b/src/store/app-store-context.ts @@ -5,11 +5,22 @@ import type { AppStoreState, ProfileUpdatesState, } from './app-state' +import type { createStoreReads } from './store-reads' export const AppStateContext = createContext(null) export const AppDispatchContext = createContext(null) +export const AppReadsContext = createContext | null>(null) + +export const useAppReads = () => { + const reads = use(AppReadsContext) + if (!reads) throw new Error('app store is missing its provider') + return reads +} + export type AppStoreActionDispatcher = (action: AppStoreAction) => void const useSelector = (select: (state: AppStoreState) => T): T => { diff --git a/src/store/app-store.tsx b/src/store/app-store.tsx index b053b910e..b84f404c1 100644 --- a/src/store/app-store.tsx +++ b/src/store/app-store.tsx @@ -1,15 +1,17 @@ -import { useEffect, useReducer } from 'react' +import { useCallback, useEffect, useMemo, useReducer } from 'react' import type { ReactNode } from 'react' import { - getPendingFailures, - getRuntimeState, - getSidecarFailure, -} from '@/services/cmds' -import { bindStoreDispatch } from '@/services/mutate' - -import { appStoreReducer, initialAppStoreState } from './app-state' -import { AppDispatchContext, AppStateContext } from './app-store-context' + type AppStoreAction, + appStoreReducer, + initialAppStoreState, +} from './app-state' +import { + AppDispatchContext, + AppReadsContext, + AppStateContext, +} from './app-store-context' +import { createStoreReads } from './store-reads' /** * Single app store (React context + reducer, per the frozen design). Event-bus @@ -20,33 +22,21 @@ import { AppDispatchContext, AppStateContext } from './app-store-context' export const AppStoreProvider = ({ children }: { children?: ReactNode }) => { const [state, dispatch] = useReducer(appStoreReducer, initialAppStoreState) - useEffect(() => { - bindStoreDispatch(dispatch) - return () => bindStoreDispatch(null) - }, []) + const reads = useMemo(() => createStoreReads(dispatch), [dispatch]) + const dispatchEvent = useCallback( + (action: AppStoreAction) => { + if (action.type === 'runState/loaded') + reads.acceptRunState(action.runState) + else dispatch(action) + }, + [reads], + ) useEffect(() => { const readRunState = () => { - getRuntimeState() - .then((runState) => dispatch({ type: 'runState/loaded', runState })) - .catch(() => {}) - } - const readPendingFailures = () => { - getSidecarFailure() - .then((snapshot) => - dispatch({ type: 'sidecarFailure/loaded', snapshot }), - ) - .catch((error) => { - console.warn('[app-store] Sidecar failure could not be read:', error) - }) - getPendingFailures() - .then((failures) => - dispatch({ type: 'pendingFailures/loaded', failures }), - ) - .catch((error) => { - console.warn('[app-store] pending failures could not be read:', error) - }) + void reads.readRunState().catch(() => {}) } + const { readPendingFailures } = reads readRunState() readPendingFailures() @@ -70,12 +60,17 @@ export const AppStoreProvider = ({ children }: { children?: ReactNode }) => { window.removeEventListener('focus', readWhenFocused) document.removeEventListener('visibilitychange', readWhenFocused) window.clearInterval(timer) + reads.invalidate() } - }, []) + }, [reads]) return ( - {children} + + + {children} + + ) } diff --git a/src/store/store-reads.test.ts b/src/store/store-reads.test.ts new file mode 100644 index 000000000..c8eebd51e --- /dev/null +++ b/src/store/store-reads.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +import { + getPendingFailures, + getRuntimeState, + type RunState, +} from '@/services/cmds' + +import { appStoreReducer, initialAppStoreState } from './app-state' +import { createStoreReads } from './store-reads' + +vi.mock('@/services/cmds', () => ({ + getRuntimeState: vi.fn(), + getPendingFailures: vi.fn().mockResolvedValue([]), + getSidecarFailure: vi.fn().mockResolvedValue({ revision: 0, detail: null }), +})) + +beforeEach(() => vi.clearAllMocks()) + +it('keeps a newer run-state event when an older snapshot returns', async () => { + let resolve!: (state: RunState) => void + vi.mocked(getRuntimeState).mockImplementationOnce( + () => + new Promise((r) => { + resolve = r + }), + ) + let state = initialAppStoreState + const reads = createStoreReads((action) => { + state = appStoreReducer(state, action) + }) + const pending = reads.readRunState() + const running = { mode: 'Service' } as RunState + reads.acceptRunState(running) + resolve({ mode: 'NotRunning' } as RunState) + expect(await pending).toBe(running) + expect(state.runState).toBe(running) +}) + +it('keeps the newer read when run-state replies arrive in reverse order', async () => { + let resolveFirst!: (state: RunState) => void + vi.mocked(getRuntimeState) + .mockImplementationOnce( + () => + new Promise((r) => { + resolveFirst = r + }), + ) + .mockResolvedValueOnce({ mode: 'Service' } as RunState) + let state = initialAppStoreState + const reads = createStoreReads((action) => { + state = appStoreReducer(state, action) + }) + const first = reads.readRunState() + const second = reads.readRunState() + await second + resolveFirst({ mode: 'NotRunning' } as RunState) + expect((await first).mode).toBe('Service') + expect(state.runState?.mode).toBe('Service') +}) + +it('does not restore cleared pending failures from an older read', async () => { + let resolveFirst!: ( + failures: Awaited>, + ) => void + vi.mocked(getPendingFailures) + .mockImplementationOnce( + () => + new Promise((r) => { + resolveFirst = r + }), + ) + .mockResolvedValueOnce([]) + let state = initialAppStoreState + const reads = createStoreReads((action) => { + state = appStoreReducer(state, action) + }) + reads.readPendingFailures() + reads.readPendingFailures() + await Promise.resolve() + resolveFirst([ + { + code: 'proxy', + detail: 'old', + operation: 'systemProxyEnable', + sequence: 1, + }, + ]) + await Promise.resolve() + expect(state.pendingFailures).toEqual([]) +}) diff --git a/src/store/store-reads.ts b/src/store/store-reads.ts new file mode 100644 index 000000000..3af3e1a03 --- /dev/null +++ b/src/store/store-reads.ts @@ -0,0 +1,71 @@ +import { + getPendingFailures, + getRuntimeState, + getSidecarFailure, + type RunState, +} from '@/services/cmds' + +import type { AppStoreAction } from './app-state' + +export const createStoreReads = ( + dispatch: (action: AppStoreAction) => void, +) => { + let runRevision = 0 + let failureRevision = 0 + let current: RunState | null = null + let latestRead: Promise | null = null + + const acceptRunState = (runState: RunState) => { + runRevision += 1 + latestRead = null + current = runState + dispatch({ type: 'runState/loaded', runState }) + } + + const readRunState = (): Promise => { + const revision = ++runRevision + const read: Promise = getRuntimeState().then((runState) => { + if (revision === runRevision) { + current = runState + dispatch({ type: 'runState/loaded', runState }) + } + if (latestRead && latestRead !== read) return latestRead + return current ?? runState + }) + latestRead = read + return read + } + + const readPendingFailures = () => { + const revision = ++failureRevision + getSidecarFailure() + .then((snapshot) => { + if (revision === failureRevision) { + dispatch({ type: 'sidecarFailure/loaded', snapshot }) + } + }) + .catch((error) => { + console.warn('[app-store] Sidecar failure could not be read:', error) + }) + getPendingFailures() + .then((failures) => { + if (revision === failureRevision) { + dispatch({ type: 'pendingFailures/loaded', failures }) + } + }) + .catch((error) => { + console.warn('[app-store] pending failures could not be read:', error) + }) + } + + return { + acceptRunState, + readRunState, + readPendingFailures, + invalidate: () => { + runRevision += 1 + failureRevision += 1 + latestRead = null + }, + } +}