chore: sync repairs and dev into once-cell cleanup

This commit is contained in:
Tunglies
2026-10-06 14:58:24 +08:00
37 changed files with 801 additions and 442 deletions
Generated
+1
View File
@@ -1113,6 +1113,7 @@ dependencies = [
"getrandom 0.4.3",
"libc",
"log",
"minisign-verify",
"nanoid",
"network-interface",
"objc2",
+2
View File
@@ -6,6 +6,8 @@
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
**🖥️ Windows**
+20 -36
View File
@@ -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<str>` 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.
+1
View File
@@ -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"
+26 -2
View File
@@ -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<DownloadEvent>,
) -> CmdResult<bool> {
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<String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
+45 -2
View File
@@ -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<ValidationOutcome> {
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<IRuntime>,
previous: Option<u64>,
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 {
+3 -50
View File
@@ -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<Vec<Refresh>>;
}
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<T: Send>(operation: impl Future<Output = Result<T>> + Send) -> Result<(T, Vec<Refresh>)> {
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<T: Send>(operation: impl Future<Output = Result<T>> + Send) -> Result<T> {
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));
+1 -1
View File
@@ -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;
+261 -100
View File
@@ -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<Option<CancellationToken>>,
}
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<Vec<u8>> {
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<String> {
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<u64>,
},
#[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<tauri::Url> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Option<Update>> {
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<u8>) -> 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<bool> {
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<u8>)> {
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);
+5 -13
View File
@@ -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::*;
+2
View File
@@ -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,
+6 -42
View File
@@ -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<Item = Duration> {
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;
+20 -29
View File
@@ -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<NoticeStatus, …>`
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.
@@ -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', () => ({
@@ -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) {
@@ -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' &&
+3 -3
View File
@@ -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,
},
)
@@ -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)
})
+9 -2
View File
@@ -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<DialogRef> }) {
config = parsedConfig as Record<string, any>
}
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<DialogRef> }) {
}
if (dnsEnabled) {
await applyDnsConfig(true)
await mutate(() => applyDnsConfig(true), {
id: 'apply-dns-config',
errorNotice: false,
})
mutateClash()
}
+11 -3
View File
@@ -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<DialogRef>((_, 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<DialogRef>((_, 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<DialogRef>((_, 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 })
@@ -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<DialogRef>((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()
}
}
+27 -10
View File
@@ -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<DialogRef> }) {
}
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<DialogRef> }) {
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}
>
<Box
+5 -1
View File
@@ -74,7 +74,11 @@ export const useProfiles = () => {
const patchCurrent = useCallback(
async (value: Partial<IProfileItem>) => {
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()
}
},
+51
View File
@@ -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()
})
+8 -11
View File
@@ -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) {
+2 -2
View File
@@ -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) {
+4 -13
View File
@@ -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,
}
}
+3
View File
@@ -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([]),
+5 -26
View File
@@ -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])
}
+14
View File
@@ -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<void>('restart_app')
}
export async function installUpdate(
version: string,
onEvent: (event: DownloadEvent) => void,
) {
const channel = new Channel<DownloadEvent>()
channel.onmessage = onEvent
return invoke<boolean>('install_update', { version, onEvent: channel })
}
export async function cancelUpdateDownload() {
return invoke<void>('cancel_update_download')
}
export async function getAppDir() {
return invoke<string>('get_app_dir')
}
+33
View File
@@ -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<void>((_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()
})
+11 -33
View File
@@ -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<typeof showNotice.error>[0]
export interface MutateOptions<T> {
/** 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<void>
/** 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<string>()
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<string, Promise<void>>()
export async function mutate<T>(
invoke: () => Promise<T>,
options: MutateOptions<T>,
): Promise<MutateResult<T>> {
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<void>((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<T>(
}
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<T>(
}
throw error
} finally {
inFlight.delete(id)
storeDispatch?.({ type: 'busy/settled', id })
release()
if (inFlight.get(id) === settled) inFlight.delete(id)
}
}
-17
View File
@@ -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<string>
}
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<string, number>,
@@ -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 }
}
}
}
+11
View File
@@ -5,11 +5,22 @@ import type {
AppStoreState,
ProfileUpdatesState,
} from './app-state'
import type { createStoreReads } from './store-reads'
export const AppStateContext = createContext<AppStoreState | null>(null)
export const AppDispatchContext =
createContext<AppStoreActionDispatcher | null>(null)
export const AppReadsContext = createContext<ReturnType<
typeof createStoreReads
> | 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 = <T>(select: (state: AppStoreState) => T): T => {
+29 -34
View File
@@ -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 (
<AppStateContext value={state}>
<AppDispatchContext value={dispatch}>{children}</AppDispatchContext>
<AppReadsContext value={reads}>
<AppDispatchContext value={dispatchEvent}>
{children}
</AppDispatchContext>
</AppReadsContext>
</AppStateContext>
)
}
+91
View File
@@ -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<ReturnType<typeof getPendingFailures>>,
) => 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([])
})
+71
View File
@@ -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<RunState> | null = null
const acceptRunState = (runState: RunState) => {
runRevision += 1
latestRead = null
current = runState
dispatch({ type: 'runState/loaded', runState })
}
const readRunState = (): Promise<RunState> => {
const revision = ++runRevision
const read: Promise<RunState> = 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
},
}
}