mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-30 18:52:12 +08:00
chore: sync repairs and dev into once-cell cleanup
This commit is contained in:
Generated
+1
@@ -1113,6 +1113,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"nanoid",
|
||||
"network-interface",
|
||||
"objc2",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
|
||||
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
|
||||
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
|
||||
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
|
||||
- 修复下载更新时镜像缓慢或卡住不会切换下载地址、点击「取消」无法中止下载的问题
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
|
||||
+20
-36
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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);
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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' &&
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user