chore: sync frontend repairs into synchronization cleanup

This commit is contained in:
Tunglies
2026-10-06 14:43:55 +08:00
29 changed files with 467 additions and 330 deletions
+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.
+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;
+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::*;
+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()
}
}
+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])
}
+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
},
}
}