refactor(frontend): contract flow (#8131)

* feat(contract): export wire contract from backend and generate frontend types

Adds a zero-dependency `frontend-contract` bin that serializes the real
NoticeStatus variants and FrontendEvent payloads, scripts/gen-contract.mjs
which emits the committed src/services/contract.ts, and a CI job that
regenerates it and fails on a non-empty diff. events.ts now derives its
event map from the generated contract instead of hand-typed shapes.

* feat(events): route all backend events through a single event bus and store

Adds src/services/bus (the only listen/emit site: one handler per contract
event plus the frontend-internal test-all event) and the React
context/reducer app store. Migrates the five scattered subscriptions
(layout events, pending failures, proxy refresh, profile update/timer,
test-all), deletes the old glue (events.ts, use-listen, use-layout-events,
LoadingCacheProvider), moves run state into the store, and adds an
architecture gate script wired into CI. Behavior is unchanged; the
run-state query's poll/focus safety net is preserved by the store
provider.

* refactor(notice): make the notice table exhaustive and drop dead wire entries

D5 cleanup, frontend and backend in one commit: removes the six
config_validate::* statuses that never receive a backend send and the
set_config::ok send (enum variants, golden list 41→34, send point in
restart_clash_core), their frontend handlers, and the five i18n keys only
those handlers used. The notice table is now typed as a Record over the
generated NoticeStatus union, so a status without a frontend entry fails
to compile. The info status (sent by the silent updater for breaking
change notices, previously dropped with a console warning) now shows an
info toast — the one deliberate behavior delta.

* feat(mutate): single funnel for user-intent commands and invoke gate

Adds src/services/mutate.ts: one path for optimistic apply, services-layer
invoke, Busy handling, failure rollback, and the error toast, with busy
bookkeeping mirrored into the app store. The patch hooks (verge, clash,
profiles) now route through it. Moves the remaining direct invokes
(unlock ×3, DNS ×4) into cmds.ts and turns on the invoke-only-in-services
architecture gate in CI.

* refactor(settings): route settings-domain commands through the mutate funnel

Ports port saving, webdav/local backup operations, core change/restart/
upgrade, service install/uninstall/repair flows, the system-proxy toggle
queue, the DNS override toggle, and app lifecycle commands onto the single
funnel. Bespoke flows keep their own error handling via errorNotice:false;
simple flows gain the funnel's default error toast and revalidation.

* refactor(profiles): route profile operations through the mutate funnel

Ports profile create/import/reorder/delete/update/patch/save-file and
enhance calls onto the funnel; retry and editor flows keep their own
error handling with errorNotice:false. Per-uid ids keep the update
worker pool concurrent under the funnel's in-flight dedupe.

* refactor(proxies): route proxy/home/misc commands through the mutate funnel

Ports mode switching (page and home card), chain runtime updates, node
selection with tray sync, selection recording, provider updates and
syncs, and lightweight-mode entry onto the funnel; every site keeps its
existing optimistic/revert and error-handling behavior.

* docs: add frontend architecture guardrails for src

Layering rules for the contract flow: generated contract as the only
wire-typing source, event bus as the only listen/emit site, pure-reducer
store for event-derived state, the mutate funnel for user intent, and
invoke only in the services layer — each backed by the CI gates.

* fix(events): close run-state race and restore single error toasts

Self-review findings: the bus now re-reads the run-state snapshot in
onSubscribed so an event racing the store's initial read cannot leave a
stale snapshot (the ordering the old layout listener guaranteed), and the
patch hooks pass errorNotice:false so GuardState/page handlers remain the
only error toast, matching the pre-migration behavior.

* fix(frontend): preserve queued intents and fresh runtime snapshots

* refactor(frontend): trim unused mutation and busy state APIs

* docs(frontend): focus guardrails on architecture ownership
This commit is contained in:
Tunglies
2026-10-07 07:38:55 +00:00
committed by GitHub
parent 8328e34208
commit 69b76cc6a9
100 changed files with 2208 additions and 1127 deletions
+79
View File
@@ -85,3 +85,82 @@ jobs:
- name: Run Knip
if: steps.check_frontend.outputs.frontend == 'true'
run: pnpm knip:check
- name: Architecture gates (listen/emit only in the event bus, invoke only in services)
if: steps.check_frontend.outputs.frontend == 'true'
run: node scripts/check-architecture.mjs listen emit invoke
contract-parity:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- name: Check contract inputs
id: check_inputs
uses: dorny/paths-filter@v4
with:
filters: |
contract:
- 'src/services/contract.ts'
- 'scripts/gen-contract.mjs'
- 'src-tauri/src/core/notify/**'
- 'src-tauri/src/bin/**'
- 'src-tauri/src/lib.rs'
- '.github/workflows/frontend-check.yml'
- name: Skip if contract inputs unchanged
if: steps.check_inputs.outputs.contract != 'true'
run: echo "No contract inputs changed, skipping parity check."
- name: Install Rust Stable
if: steps.check_inputs.outputs.contract == 'true'
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- name: Rust Cache
if: steps.check_inputs.outputs.contract == 'true'
uses: Swatinem/rust-cache@v2
with:
prefix-key: 'v1-rust'
key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract'
workspaces: |
. -> target
cache-all-crates: true
cache-workspace-crates: true
- name: Install dependencies
if: steps.check_inputs.outputs.contract == 'true'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
if: steps.check_inputs.outputs.contract == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v7
if: steps.check_inputs.outputs.contract == 'true'
with:
node-version: '24.21.0'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
if: steps.check_inputs.outputs.contract == 'true'
- name: Provision sidecar placeholders for tauri-build
if: steps.check_inputs.outputs.contract == 'true'
run: |
mkdir -p src-tauri/sidecar src-tauri/resources dist
touch dist/index.html
for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do
touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu"
done
- name: Regenerate contract and require empty diff
if: steps.check_inputs.outputs.contract == 'true'
run: |
pnpm gen:contract
git diff --exit-code -- src/services/contract.ts
+2 -1
View File
@@ -17,7 +17,8 @@
"ignoreBinaries": ["rustc", "cc"],
"ignoreDependencies": ["lint-staged"],
"ignoreIssues": {
"src/types/generated/**": ["exports", "types"]
"src/types/generated/**": ["exports", "types"],
"src/services/contract.ts": ["exports", "types"]
},
"cycles": {
"dynamicImports": true
+2
View File
@@ -30,6 +30,8 @@
"i18n:check": "node scripts/cleanup-unused-i18n.mjs",
"i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply",
"i18n:types": "node scripts/generate-i18n-keys.mjs",
"gen:contract": "node scripts/gen-contract.mjs",
"arch:check": "node scripts/check-architecture.mjs listen emit invoke",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
+84
View File
@@ -0,0 +1,84 @@
import fs from 'node:fs'
import path from 'node:path'
/**
* Architecture grep gates for the frontend contract flow. Select gates via
* argv:
*
* node scripts/check-architecture.mjs listen emit invoke
*
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
* like `this.emit(` are unrelated class methods) or as importing the name
* from '@tauri-apps/api/event'.
* - invoke: `invoke(` calls may exist only under src/services/**.
*/
const ROOT = path.resolve(import.meta.dirname, '..')
const SRC = path.join(ROOT, 'src')
const tauriEventImport = (name) =>
new RegExp(
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
)
const GATES = {
listen: {
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri listen outside the event bus (src/services/bus)',
},
emit: {
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
allowed: (file) => file.startsWith('src/services/bus/'),
description: 'Tauri emit outside the event bus (src/services/bus)',
},
invoke: {
patterns: [/\binvoke\(/],
allowed: (file) => file.startsWith('src/services/'),
description: 'invoke( outside the services layer (src/services)',
},
}
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) return walk(full)
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
})
}
const selected = process.argv.slice(2)
if (selected.length === 0) {
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
process.exit(2)
}
let violations = 0
for (const gate of selected) {
const rule = GATES[gate]
if (!rule) {
console.error(`unknown gate: ${gate}`)
process.exit(2)
}
for (const file of walk(SRC)) {
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
if (rule.allowed(relative)) continue
const lines = fs.readFileSync(file, 'utf8').split('\n')
lines.forEach((line, index) => {
if (rule.patterns.some((pattern) => pattern.test(line))) {
violations += 1
console.error(
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
)
}
})
}
}
if (violations > 0) {
console.error(`[arch] ${violations} violation(s) found`)
process.exit(1)
}
console.log('[arch] no violations')
+91
View File
@@ -0,0 +1,91 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
/**
* Regenerates src/services/contract.ts from the backend wire contract.
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
* src-tauri, which serializes the same enum variants and event payloads the
* app emits at runtime. CI reruns this and fails on a non-empty diff.
*/
const ROOT = path.resolve(import.meta.dirname, '..')
const OUT = path.join(ROOT, 'src/services/contract.ts')
const contract = JSON.parse(
execFileSync(
'cargo',
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'],
{
cwd: path.join(ROOT, 'src-tauri'),
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
},
),
)
if (contract.version !== 1) {
throw new Error(`unsupported wire contract version: ${contract.version}`)
}
/** TS type per event, derived from the serialized sample payload's JSON shape. */
const payloadType = (name, sample) => {
if (sample === null) return 'null'
if (typeof sample === 'string') return 'string'
if (Array.isArray(sample)) {
if (name === 'verge://notice-message') return '[NoticeStatus, string]'
throw new Error(`unexpected array payload for ${name}`)
}
if (typeof sample === 'object') {
const keys = Object.keys(sample)
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
if (name === 'verge://run-state-changed') return 'RunState'
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
}
throw new Error(`unexpected payload for ${name}`)
}
const needsRunState = contract.events.some(
(e) => e.name === 'verge://run-state-changed',
)
if (!needsRunState)
throw new Error('run-state-changed event missing from contract')
const lines = []
lines.push(
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
)
lines.push('// Regen: pnpm gen:contract')
lines.push('')
lines.push("import type { RunState } from './cmds'")
lines.push('')
lines.push(
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
)
lines.push('export type NoticeStatus =')
for (const status of contract.noticeStatuses) {
lines.push(` | ${JSON.stringify(status)}`)
}
lines.push('')
lines.push('/** Event name → payload type for every backend-emitted event. */')
lines.push('export interface VergeEventPayloads {')
for (const event of contract.events) {
const key = JSON.stringify(event.name)
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
}
lines.push('}')
lines.push('')
const content = `${lines.join('\n')}\n`
fs.writeFileSync(OUT, content)
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
execFileSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
{ cwd: ROOT, stdio: 'ignore' },
)
console.log(
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
)
+8
View File
@@ -0,0 +1,8 @@
//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies:
//! the payload is derived from the same serializers the app uses at runtime.
//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed
//! TypeScript artifact.
fn main() {
println!("{}", app_lib::frontend_wire_contract());
}
+44 -78
View File
@@ -3,10 +3,10 @@ pub use announce::{Refresh, announce};
pub mod desktop;
pub mod handle;
pub mod notification;
pub use notification::frontend_wire_contract;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum NoticeStatus {
// dead send: no frontend handler
#[serde(rename = "info")]
Info,
#[serde(rename = "import_sub_url::ok")]
@@ -77,33 +77,48 @@ pub enum NoticeStatus {
MixedPortFallback,
#[serde(rename = "mixed_port::fallback_error")]
MixedPortFallbackError,
// dead listener: frontend handler retained
#[allow(dead_code)]
#[serde(rename = "config_validate::core_change")]
ConfigValidateCoreChange,
// dead listener: frontend handler retained
#[allow(dead_code)]
#[serde(rename = "config_validate::stdout_error")]
ConfigValidateStdoutError,
// dead listener: frontend handler retained
#[allow(dead_code)]
#[serde(rename = "config_validate::yaml_key_error")]
ConfigValidateYamlKeyError,
// dead listener: frontend handler retained
#[allow(dead_code)]
#[serde(rename = "config_validate::yaml_error")]
ConfigValidateYamlError,
// dead listener: frontend handler retained
#[allow(dead_code)]
#[serde(rename = "config_validate::merge_key_error")]
ConfigValidateMergeKeyError,
// dead listener: frontend handler retained
#[allow(dead_code)]
#[serde(rename = "config_validate::merge_error")]
ConfigValidateMergeError,
// dead send: no frontend handler
#[serde(rename = "set_config::ok")]
SetConfigOk,
}
impl NoticeStatus {
/// Every variant in golden order. The wire-contract export and the golden
/// test both derive their variant list from here; expected strings stay
/// hand-written in the test so renames cannot slip through.
pub const ALL: [Self; 34] = [
Self::Info,
Self::ImportSubUrlOk,
Self::ImportSubUrlError,
Self::SetConfigError,
Self::CoreStartError,
Self::ServiceCoreRepairRequired,
Self::ServiceCoreAppDataNotOwned,
Self::ServiceCoreSidecarFallback,
Self::DnsOverrideAutoDisabled,
Self::EnhanceDiscardedKeys,
Self::TunModeAutoDisabled,
Self::TunModeAutoDisableFailed,
Self::AppRestartCoreStopFailed,
Self::AppQuitCoreStopFailed,
Self::UpdateWithClashProxy,
Self::UpdateFailedEvenWithClash,
Self::ReactivateProfilesError,
Self::UpdateFailed,
Self::ConfigValidateBootError,
Self::ConfigValidateError,
Self::ConfigValidateProcessTerminated,
Self::ConfigValidateScriptError,
Self::ConfigValidateScriptSyntaxError,
Self::ConfigValidateScriptMissingMain,
Self::ConfigValidateFileNotFound,
Self::ConfigValidateYamlSyntaxError,
Self::ConfigValidateYamlReadError,
Self::ConfigValidateYamlMappingError,
Self::ConfigValidateMergeSyntaxError,
Self::ConfigValidateMergeMappingError,
Self::ConfigCoreChangeSuccess,
Self::ConfigCoreChangeError,
Self::MixedPortFallback,
Self::MixedPortFallbackError,
];
}
#[cfg(test)]
@@ -112,49 +127,7 @@ mod tests {
#[test]
fn notice_status_wire_contract() -> Result<(), serde_json::Error> {
let statuses = [
NoticeStatus::Info,
NoticeStatus::ImportSubUrlOk,
NoticeStatus::ImportSubUrlError,
NoticeStatus::SetConfigError,
NoticeStatus::CoreStartError,
NoticeStatus::ServiceCoreRepairRequired,
NoticeStatus::ServiceCoreAppDataNotOwned,
NoticeStatus::ServiceCoreSidecarFallback,
NoticeStatus::DnsOverrideAutoDisabled,
NoticeStatus::EnhanceDiscardedKeys,
NoticeStatus::TunModeAutoDisabled,
NoticeStatus::TunModeAutoDisableFailed,
NoticeStatus::AppRestartCoreStopFailed,
NoticeStatus::AppQuitCoreStopFailed,
NoticeStatus::UpdateWithClashProxy,
NoticeStatus::UpdateFailedEvenWithClash,
NoticeStatus::ReactivateProfilesError,
NoticeStatus::UpdateFailed,
NoticeStatus::ConfigValidateBootError,
NoticeStatus::ConfigValidateError,
NoticeStatus::ConfigValidateProcessTerminated,
NoticeStatus::ConfigValidateScriptError,
NoticeStatus::ConfigValidateScriptSyntaxError,
NoticeStatus::ConfigValidateScriptMissingMain,
NoticeStatus::ConfigValidateFileNotFound,
NoticeStatus::ConfigValidateYamlSyntaxError,
NoticeStatus::ConfigValidateYamlReadError,
NoticeStatus::ConfigValidateYamlMappingError,
NoticeStatus::ConfigValidateMergeSyntaxError,
NoticeStatus::ConfigValidateMergeMappingError,
NoticeStatus::ConfigCoreChangeSuccess,
NoticeStatus::ConfigCoreChangeError,
NoticeStatus::MixedPortFallback,
NoticeStatus::MixedPortFallbackError,
NoticeStatus::ConfigValidateCoreChange,
NoticeStatus::ConfigValidateStdoutError,
NoticeStatus::ConfigValidateYamlKeyError,
NoticeStatus::ConfigValidateYamlError,
NoticeStatus::ConfigValidateMergeKeyError,
NoticeStatus::ConfigValidateMergeError,
NoticeStatus::SetConfigOk,
];
let statuses = NoticeStatus::ALL;
let expected = [
"info",
"import_sub_url::ok",
@@ -190,13 +163,6 @@ mod tests {
"config_core::change_error",
"mixed_port::fallback",
"mixed_port::fallback_error",
"config_validate::core_change",
"config_validate::stdout_error",
"config_validate::yaml_key_error",
"config_validate::yaml_error",
"config_validate::merge_key_error",
"config_validate::merge_error",
"set_config::ok",
];
let actual: Vec<_> = statuses
.into_iter()
+89 -62
View File
@@ -12,7 +12,7 @@ use std::{
};
use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow};
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum FrontendEvent<'a> {
RefreshClash,
RefreshVerge,
@@ -364,67 +364,7 @@ mod wire_tests {
#[test]
fn frontend_event_wire_contract() -> Result<(), serde_json::Error> {
let uid = String::from("profile");
let events = [
(
FrontendEvent::RefreshClash,
"verge://refresh-clash-config",
json!("yes"),
),
(
FrontendEvent::RefreshVerge,
"verge://refresh-verge-config",
json!("yes"),
),
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")),
(
FrontendEvent::RefreshProxyConfig,
"verge://refresh-proxy-config",
json!(null),
),
(
FrontendEvent::NoticeMessage {
status: NoticeStatus::SetConfigOk,
message: Arc::from("ok"),
},
"verge://notice-message",
json!(["set_config::ok", "ok"]),
),
(
FrontendEvent::TimerUpdated { profile_index: &uid },
"verge://timer-updated",
json!("profile"),
),
(
FrontendEvent::RunStateChanged {
state: json!({"running": true}),
},
"verge://run-state-changed",
json!({"running": true}),
),
(
FrontendEvent::PendingFailuresChanged,
"verge://pending-failures-changed",
json!(null),
),
(
FrontendEvent::ProfileChanged {
current_profile_id: &uid,
},
"profile-changed",
json!("profile"),
),
(
FrontendEvent::ProfileUpdateStarted { uid: &uid },
"profile-update-started",
json!({"uid": "profile"}),
),
(
FrontendEvent::ProfileUpdateCompleted { uid: &uid },
"profile-update-completed",
json!({"uid": "profile"}),
),
];
for (event, name, payload) in events {
for (event, name, payload) in super::sample_frontend_events(&uid) {
let (actual_name, actual_payload) = NotificationSystem::serialize_event(event);
assert_eq!(actual_name, name);
assert_eq!(actual_payload?, payload);
@@ -432,3 +372,90 @@ mod wire_tests {
Ok(())
}
}
/// One sample per `FrontendEvent` variant, paired with its wire name and payload.
/// Shared by the wire golden test and the contract export so the two cannot drift.
fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> {
vec![
(
FrontendEvent::RefreshClash,
"verge://refresh-clash-config",
json!("yes"),
),
(
FrontendEvent::RefreshVerge,
"verge://refresh-verge-config",
json!("yes"),
),
(FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")),
(
FrontendEvent::RefreshProxyConfig,
"verge://refresh-proxy-config",
json!(null),
),
(
FrontendEvent::NoticeMessage {
status: NoticeStatus::Info,
message: Arc::from("ok"),
},
"verge://notice-message",
json!(["info", "ok"]),
),
(
FrontendEvent::TimerUpdated { profile_index: uid },
"verge://timer-updated",
json!("profile"),
),
(
FrontendEvent::RunStateChanged {
state: json!({"running": true}),
},
"verge://run-state-changed",
json!({"running": true}),
),
(
FrontendEvent::PendingFailuresChanged,
"verge://pending-failures-changed",
json!(null),
),
(
FrontendEvent::ProfileChanged {
current_profile_id: uid,
},
"profile-changed",
json!("profile"),
),
(
FrontendEvent::ProfileUpdateStarted { uid },
"profile-update-started",
json!({"uid": "profile"}),
),
(
FrontendEvent::ProfileUpdateCompleted { uid },
"profile-update-completed",
json!({"uid": "profile"}),
),
]
}
/// Machine-readable form of the frontend wire contract: every event name with a
/// serialized sample payload, plus every notice status string. The sample
/// payload's JSON shape (string / null / array / object) is what the frontend
/// generator maps to TypeScript types. `ThemeChanged` is a Tauri built-in
/// linux-only window event, not part of this contract.
pub fn frontend_wire_contract() -> serde_json::Value {
let uid = String::from("sample");
let events: Vec<_> = sample_frontend_events(&uid)
.into_iter()
.map(|(event, _, _)| {
let (name, payload) = NotificationSystem::serialize_event(event);
json!({ "name": name, "sample": payload.unwrap_or(serde_json::Value::Null) })
})
.collect();
let statuses: Vec<_> = NoticeStatus::ALL
.iter()
// A unit variant with a string rename cannot fail to serialize.
.filter_map(|status| serde_json::to_value(status).ok())
.collect();
json!({ "version": 1, "events": events, "noticeStatuses": statuses })
}
-1
View File
@@ -29,7 +29,6 @@ pub async fn restart_clash_core() {
match CoreManager::global().restart_core().await {
Ok(_) => {
announce(Refresh::Clash);
handle::Handle::notice(NoticeStatus::SetConfigOk, "ok");
}
Err(err) => {
handle::Handle::notice(NoticeStatus::SetConfigError, format!("{err:#}"));
+4
View File
@@ -25,6 +25,10 @@ use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt as _;
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
// Re-exported for src/bin/frontend-contract.rs; the wire contract stays owned
// by core::notify.
pub use crate::core::notify::frontend_wire_contract;
/// Application initialization helper functions
mod app_init {
use super::*;
+23
View File
@@ -0,0 +1,23 @@
# Frontend Architecture Guardrails
These rules supplement the repository's root `AGENTS.md`.
- **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.
+5 -1
View File
@@ -16,6 +16,7 @@ import {
useCoreDataStatus,
} from '@/providers/app-data-context'
import { patchClashMode } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { setCacheData } from '@/services/query-client'
import type { TranslationKey } from '@/types/generated/i18n-keys'
@@ -90,7 +91,10 @@ export const ClashModeCard = () => {
setOptimisticMode(mode)
try {
await patchClashMode(mode)
await mutate(() => patchClashMode(mode), {
id: 'patch-clash-mode',
errorNotice: false,
})
} catch (error) {
setOptimisticMode(null)
showNotice.error(error)
+5 -1
View File
@@ -26,6 +26,7 @@ import { useNavigate } from 'react-router'
import { useAppRefreshers } from '@/providers/app-data-context'
import { updateProfile } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { isValidUrl } from '@/utils/network'
import { openExternalUrl } from '@/utils/open-external-url'
@@ -297,7 +298,10 @@ export const HomeProfileCard = ({
setUpdating(true)
try {
await updateProfile(current.uid, current.option)
await mutate(() => updateProfile(current.uid, current.option), {
id: `update-profile:${current.uid}`,
errorNotice: false,
})
onProfileUpdated?.()
// 刷新首页数据
+2 -2
View File
@@ -6,7 +6,6 @@ import {
import { isSortable } from '@dnd-kit/react/sortable'
import { Add, NetworkCheck } from '@mui/icons-material'
import { Box, IconButton, Tooltip, alpha, styled } from '@mui/material'
import { emit } from '@tauri-apps/api/event'
import { nanoid } from 'nanoid'
import { useEffect, useRef, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,6 +19,7 @@ import { SortableItem } from '@/components/base'
import { TestItem } from '@/components/test/test-item'
import { TestViewer, type TestViewerRef } from '@/components/test/test-viewer'
import { useVerge } from '@/hooks/use-verge'
import { emitTestAll } from '@/services/bus'
import { EnhancedCard } from './enhanced-card'
@@ -178,7 +178,7 @@ export const TestCard = () => {
)
const handleTestAll = useCallback(() => {
emit('verge://test-all')
emitTestAll()
}, [])
const handleCreateTest = useCallback(() => {
@@ -8,18 +8,24 @@ import {
type RunState,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useQuery } from '@/services/query-client'
import { ServiceMigrationDialog } from './service-migration-dialog'
const store = vi.hoisted(() => ({
dispatch: vi.fn(),
runState: null as RunState | null,
}))
vi.mock('react', () => ({ useState: vi.fn() }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@mui/material', () => ({ Alert: 'div' }))
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/store/app-store-context', () => ({
useAppReads: () => ({ readRunState: getRuntimeState }),
useRunState: () => store.runState,
}))
vi.mock('@/services/cmds', () => ({
getRuntimeState: vi.fn(),
installService: vi.fn(),
@@ -28,13 +34,10 @@ vi.mock('@/services/cmds', () => ({
vi.mock('@/services/notice-service', () => ({
showNotice: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
}))
vi.mock('@/services/query-client', () => ({
useQuery: vi.fn(),
setCacheData: vi.fn(),
}))
beforeEach(() => {
vi.clearAllMocks()
store.runState = null
vi.mocked(useState)
.mockReturnValueOnce([false, vi.fn()])
.mockReturnValueOnce([false, vi.fn()])
@@ -50,9 +53,7 @@ it.each([
] satisfies Partial<RunState>[])(
'does not offer Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
store.runState = state as unknown as RunState
const dialog = ServiceMigrationDialog()
expect(dialog.props.open).toBe(true)
expect(dialog.props.disableCancel).toBe(true)
@@ -60,9 +61,10 @@ it.each([
)
it('reports the permission refusal and finishes after installation falls back to Sidecar', async () => {
vi.mocked(useQuery).mockReturnValue({
data: { service: 'notInstalled', mode: 'NotRunning' },
} as ReturnType<typeof useQuery>)
store.runState = {
service: 'notInstalled',
mode: 'NotRunning',
} as unknown as RunState
const reason = 'core path C:\\ is writable by Everyone'
vi.mocked(installService).mockResolvedValue({ status: 'sidecar', reason })
vi.mocked(getRuntimeState).mockResolvedValue({
@@ -98,9 +100,7 @@ it.each([
] satisfies Partial<RunState>[])(
'offers Sidecar continuation from %j',
(state) => {
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
typeof useQuery
>)
store.runState = state as unknown as RunState
expect(ServiceMigrationDialog().props.disableCancel).toBe(false)
},
)
@@ -3,34 +3,25 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
import { runStateQueryKey } from '@/hooks/use-system-state'
import { useVisibility } from '@/hooks/use-visibility'
import {
continueWithSidecar,
getRuntimeState,
installService,
reinstallService,
repairService,
restartCore,
type RunState,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { setCacheData, useQuery } from '@/services/query-client'
import { useAppReads, useRunState } from '@/store/app-store-context'
export const ServiceMigrationDialog = () => {
const { t } = useTranslation()
const pageVisible = useVisibility()
const [loading, setLoading] = useState(false)
const [stateRefreshFailed, setStateRefreshFailed] = useState(false)
const [workflowIncomplete, setWorkflowIncomplete] = useState(false)
const { data: runState } = useQuery({
queryKey: runStateQueryKey,
queryFn: getRuntimeState,
enabled: true,
retry: 1,
refetchInterval: pageVisible ? 30000 : false,
})
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.
const needsDecision =
@@ -64,11 +55,10 @@ export const ServiceMigrationDialog = () => {
runState.service === 'unavailable')),
)
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
// One store entry to refresh, so there is nothing left to keep coherent by hand.
const refreshRunState = async () => {
try {
const data = await getRuntimeState()
await setCacheData<RunState>(runStateQueryKey, data)
const data = await readRunState()
setStateRefreshFailed(false)
return data
} catch (error) {
@@ -83,11 +73,23 @@ export const ServiceMigrationDialog = () => {
let outcome: ServiceInstallOutcome | undefined
try {
if (remedy === 'install') {
outcome = await installService()
const result = await mutate(() => installService(), {
id: 'install-service',
errorNotice: false,
})
if (result.ok) outcome = result.value
} else if (remedy === 'repair') {
outcome = await repairService()
const result = await mutate(() => repairService(), {
id: 'repair-service',
errorNotice: false,
})
if (result.ok) outcome = result.value
} else {
outcome = await reinstallService()
const result = await mutate(() => reinstallService(), {
id: 'reinstall-service',
errorNotice: false,
})
if (result.ok) outcome = result.value
}
} catch (error) {
showNotice.error(
@@ -123,8 +125,11 @@ export const ServiceMigrationDialog = () => {
let restartSucceeded = false
try {
await restartCore()
restartSucceeded = true
const result = await mutate(() => restartCore(), {
id: 'restart-core',
errorNotice: false,
})
restartSucceeded = result.ok
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.restartFailed',
@@ -154,7 +159,10 @@ export const ServiceMigrationDialog = () => {
setWorkflowIncomplete(true)
let startupError: unknown
try {
await continueWithSidecar()
await mutate(() => continueWithSidecar(), {
id: 'continue-with-sidecar',
errorNotice: false,
})
} catch (error) {
startupError = 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,
@@ -14,6 +13,7 @@ import {
type PendingFailure,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import {
clearServiceRequest,
@@ -22,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'
@@ -83,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)
@@ -106,10 +108,18 @@ export const SysproxyPrivilegeDialog = () => {
let outcome: ServiceInstallOutcome | undefined
if (remedy === 'reinstallAndRestart') {
setStep('installing')
outcome = await reinstallService()
const result = await mutate(() => reinstallService(), {
id: 'reinstall-service',
errorNotice: false,
})
if (result.ok) outcome = result.value
} else if (remedy === 'installAndRestart') {
setStep('installing')
outcome = await installService()
const result = await mutate(() => installService(), {
id: 'install-service',
errorNotice: false,
})
if (result.ok) outcome = result.value
}
if (outcome?.status === 'sidecar') {
showNotice.warning(
@@ -121,9 +131,12 @@ export const SysproxyPrivilegeDialog = () => {
return
}
setStep('restarting')
await restartCore()
await mutate(() => restartCore(), {
id: 'restart-core',
errorNotice: false,
})
const runState = await getRuntimeState()
const runState = await readRunState()
const usingAdminFallback =
remedy !== 'reinstallAndRestart' &&
getSystem() === 'windows' &&
@@ -134,7 +147,10 @@ export const SysproxyPrivilegeDialog = () => {
if (runState.mode === 'Service' || usingAdminFallback) {
if (restoring !== undefined) {
setStep('applying')
await patchVergeConfig(restoring)
await mutate(() => patchVergeConfig(restoring), {
id: 'sysproxy-privilege-restore',
errorNotice: false,
})
}
if (!usingAdminFallback) {
showNotice.success(
@@ -42,6 +42,7 @@ import {
readProfileFile,
saveProfileFile,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
@@ -472,7 +473,11 @@ export const GroupsEditorViewer = (props: Props) => {
setCurrData(nextData)
}
if (!(await saveProfileFile(property, nextData))) {
const result = await mutate(() => saveProfileFile(property, nextData), {
id: `save-profile-file:${property}`,
errorNotice: false,
})
if (!(result.ok && result.value === true)) {
await fetchContent()
onClose()
return
+35 -18
View File
@@ -39,8 +39,12 @@ import {
updateProfile,
viewProfile,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
import {
useProfileLoadingCache,
useSetProfileLoading,
} from '@/store/app-store-context'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
@@ -105,8 +109,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const { t } = useTranslation()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [position, setPosition] = useState({ left: 0, top: 0 })
const loadingCache = useLoadingCache()
const setLoadingCache = useSetLoadingCache()
const loadingCache = useProfileLoadingCache()
const setProfileLoading = useSetProfileLoading()
const [showNextUpdate, setShowNextUpdate] = useState(false)
const showNextUpdateRef = useRef(false)
@@ -116,17 +120,9 @@ const ProfileItemBase = (props: ProfileItemProps) => {
)
const setLoading = useCallback(
(loading: boolean) => {
setLoadingCache((cache) => {
const next = new Set(cache)
if (loading) {
next.add(itemData.uid)
} else {
next.delete(itemData.uid)
}
return next
})
setProfileLoading([itemData.uid], loading)
},
[itemData.uid, setLoadingCache],
[itemData.uid, setProfileLoading],
)
const { uid, name = 'Profile', extra, updated = 0, option } = itemData
@@ -374,7 +370,10 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const onOpenFile = useLockFn(async () => {
setAnchorEl(null)
try {
await viewProfile(itemData.uid)
await mutate(() => viewProfile(itemData.uid), {
id: 'view-profile',
errorNotice: false,
})
} catch (err) {
showNotice.error(err)
}
@@ -403,7 +402,10 @@ const ProfileItemBase = (props: ProfileItemProps) => {
try {
const payload = Object.keys(option).length > 0 ? option : undefined
await updateProfile(itemData.uid, payload)
await mutate(() => updateProfile(itemData.uid, payload), {
id: `update-profile:${itemData.uid}`,
errorNotice: false,
})
void mutateProfiles()
} catch {
@@ -591,7 +593,11 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const handleSaveProfileDocument = useLockFn(async () => {
const currentValue = profileDocument.value
if (!(await saveProfileFile(uid, currentValue))) {
const result = await mutate(() => saveProfileFile(uid, currentValue), {
id: `save-profile-file:${uid}`,
errorNotice: false,
})
if (!(result.ok && result.value === true)) {
await profileDocument.reload()
return
}
@@ -602,7 +608,11 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const handleSaveMergeDocument = useLockFn(async () => {
const mergeUid = option?.merge ?? ''
const currentValue = mergeDocument.value
if (!(await saveProfileFile(mergeUid, currentValue))) {
const result = await mutate(() => saveProfileFile(mergeUid, currentValue), {
id: `save-profile-file:${mergeUid}`,
errorNotice: false,
})
if (!(result.ok && result.value === true)) {
await mergeDocument.reload()
return
}
@@ -613,7 +623,14 @@ const ProfileItemBase = (props: ProfileItemProps) => {
const handleSaveScriptDocument = useLockFn(async () => {
const scriptUid = option?.script ?? ''
const currentValue = scriptDocument.value
if (!(await saveProfileFile(scriptUid, currentValue))) {
const result = await mutate(
() => saveProfileFile(scriptUid, currentValue),
{
id: `save-profile-file:${scriptUid}`,
errorNotice: false,
},
)
if (!(result.ok && result.value === true)) {
await scriptDocument.reload()
return
}
+10 -2
View File
@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'
import { EditorViewer } from '@/components/profile/editor-viewer'
import { useEditorDocument } from '@/hooks/use-editor-document'
import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { EnhanceHint } from './enhance-hint'
@@ -63,7 +64,10 @@ export const ProfileMore = (props: Props) => {
const onOpenFile = useLockFn(async () => {
setAnchorEl(null)
try {
await viewProfile(id)
await mutate(() => viewProfile(id), {
id: 'view-profile',
errorNotice: false,
})
} catch (err) {
showNotice.error(err)
}
@@ -96,7 +100,11 @@ export const ProfileMore = (props: Props) => {
const handleSave = useLockFn(async () => {
const currentValue = document.value
if (!(await saveProfileFile(id, currentValue))) {
const result = await mutate(() => saveProfileFile(id, currentValue), {
id: `save-profile-file:${id}`,
errorNotice: false,
})
if (!(result.ok && result.value === true)) {
await document.reload()
return
}
+35 -7
View File
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog, Switch } from '@/components/base'
import { useProfiles } from '@/hooks/use-profiles'
import { createProfile, patchProfile } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { version } from '@root/package.json'
@@ -128,26 +129,38 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
if (!isRemote) {
if (openType === 'new') {
await createProfile(item, fileDataRef.current)
await mutate(() => createProfile(item, fileDataRef.current), {
id: 'create-profile',
errorNotice: false,
})
} else {
if (!form.uid) {
throw new Error(
t('profiles.modals.profileForm.errors.uidMissing'),
)
}
await patchProfile(form.uid, item)
await mutate(() => patchProfile(form.uid, item), {
id: 'patch-profile',
errorNotice: false,
})
}
} else {
try {
if (openType === 'new') {
await createProfile(item, fileDataRef.current)
await mutate(() => createProfile(item, fileDataRef.current), {
id: 'create-profile',
errorNotice: false,
})
} else {
if (!form.uid) {
throw new Error(
t('profiles.modals.profileForm.errors.uidMissing'),
)
}
await patchProfile(form.uid, item)
await mutate(() => patchProfile(form.uid, item), {
id: 'patch-profile',
errorNotice: false,
})
}
} catch {
showNotice.info(
@@ -164,16 +177,31 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
}
if (openType === 'new') {
await createProfile(retryItem, fileDataRef.current)
await mutate(
() => createProfile(retryItem, fileDataRef.current),
{
id: 'create-profile',
errorNotice: false,
},
)
} else {
if (!form.uid) {
throw new Error(
t('profiles.modals.profileForm.errors.uidMissing'),
)
}
await patchProfile(form.uid, retryItem)
await mutate(() => patchProfile(form.uid, retryItem), {
id: 'patch-profile',
errorNotice: false,
})
await patchProfile(form.uid, { option: originalOptions })
await mutate(
() => patchProfile(form.uid, { option: originalOptions }),
{
id: 'patch-profile',
errorNotice: false,
},
)
}
showNotice.success(
@@ -30,6 +30,7 @@ import { useTranslation } from 'react-i18next'
import { BaseSearchBox, MonacoEditor } from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } 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'
@@ -329,7 +330,11 @@ export const ProxiesEditorViewer = (props: Props) => {
const handleSave = useLockFn(async () => {
try {
if (!(await saveProfileFile(property, currData))) {
const result = await mutate(() => saveProfileFile(property, currData), {
id: `save-profile-file:${property}`,
errorNotice: false,
})
if (!(result.ok && result.value === true)) {
await fetchContent()
onClose()
return
@@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next'
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
import { RuleItem } from '@/components/profile/rule-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import type { TranslationKey } from '@/types/generated/i18n-keys'
@@ -570,7 +571,11 @@ export const RulesEditorViewer = (props: Props) => {
const handleSave = useLockFn(async () => {
try {
if (!(await saveProfileFile(property, currData))) {
const result = await mutate(() => saveProfileFile(property, currData), {
id: `save-profile-file:${property}`,
errorNotice: false,
})
if (!(result.ok && result.value === true)) {
await fetchContent()
onClose()
return
+17 -4
View File
@@ -24,6 +24,7 @@ import { updateProxyProvider } from 'tauri-plugin-mihomo-api'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import { syncRuntimeProviders } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import parseTraffic from '@/utils/parse-traffic'
@@ -57,10 +58,16 @@ export const ProviderButton = () => {
try {
setUpdating((prev) => ({ ...prev, [name]: true }))
await updateProxyProvider(name)
await mutate(() => updateProxyProvider(name), {
id: `update-proxy-provider-${name}`,
errorNotice: false,
})
await refreshProxy()
void syncRuntimeProviders()
void mutate(() => syncRuntimeProviders(), {
id: 'sync-runtime-providers',
errorNotice: false,
})
showNotice.success(
'proxies.feedback.notifications.provider.updateSuccess',
@@ -97,7 +104,10 @@ export const ProviderButton = () => {
for (const name of allProviders) {
try {
await updateProxyProvider(name)
await mutate(() => updateProxyProvider(name), {
id: `update-proxy-provider-${name}`,
errorNotice: false,
})
setUpdating((prev) => ({ ...prev, [name]: false }))
} catch (err) {
console.error(`更新 ${name} 失败`, err)
@@ -105,7 +115,10 @@ export const ProviderButton = () => {
}
await refreshProxy()
void syncRuntimeProviders()
void mutate(() => syncRuntimeProviders(), {
id: 'sync-runtime-providers',
errorNotice: false,
})
showNotice.success('proxies.feedback.notifications.provider.allUpdated')
} catch (err) {
+36 -7
View File
@@ -45,6 +45,7 @@ import { useRuntimeConfig } from '@/hooks/use-clash'
import { useRecordSelection } from '@/hooks/use-record-selection'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import {
selectGlobalChainNodes,
selectRuleChainMembers,
@@ -444,7 +445,10 @@ export const ProxyChain = ({
if (isConnected) {
setIsConnecting(true)
try {
await updateProxyChainConfigInRuntime(null)
await mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
const targetGroup =
mode === 'global'
@@ -453,12 +457,22 @@ export const ProxyChain = ({
if (targetGroup) {
try {
await selectNodeForGroup(targetGroup, 'DIRECT')
await mutate(() => selectNodeForGroup(targetGroup, 'DIRECT'), {
id: `select-proxy-node:${targetGroup}`,
errorNotice: false,
})
recordSelection(targetGroup, 'DIRECT')
} catch {
if (currentProxyChain.length >= 1) {
try {
await selectNodeForGroup(targetGroup, currentProxyChain[0].name)
await mutate(
() =>
selectNodeForGroup(targetGroup, currentProxyChain[0].name),
{
id: `select-proxy-node:${targetGroup}`,
errorNotice: false,
},
)
recordSelection(targetGroup, currentProxyChain[0].name)
} catch {
// ignore
@@ -471,7 +485,10 @@ export const ProxyChain = ({
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
await closeAllConnections()
await mutate(() => closeAllConnections(), {
id: 'close-all-connections',
errorNotice: false,
})
await refreshProxy()
onUpdateChain([])
@@ -502,7 +519,10 @@ export const ProxyChain = ({
// 第一步:保存链式代理配置
const chainProxies = currentProxyChain.map((node) => node.name)
debugLog('Saving chain config:', chainProxies)
await updateProxyChainConfigInRuntime(chainProxies)
await mutate(() => updateProxyChainConfigInRuntime(chainProxies), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
debugLog('Chain configuration saved successfully')
// 第二步:连接到代理链的最后一个节点
@@ -516,7 +536,13 @@ export const ProxyChain = ({
const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup
await selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name)
await mutate(
() => selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name),
{
id: `select-proxy-node:${targetGroup || 'GLOBAL'}`,
errorNotice: false,
},
)
// The chain moves the group like any other selection, so the profile has to learn about
// it: what the profile holds is what gets re-applied the next time the core starts.
recordSelection(targetGroup || 'GLOBAL', lastNode.name)
@@ -593,7 +619,10 @@ export const ProxyChain = ({
<IconButton
size="small"
onClick={() => {
updateProxyChainConfigInRuntime(null)
mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
+7 -7
View File
@@ -4,11 +4,10 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { runStateQueryKey } from '@/hooks/use-system-state'
import { useSystemState } from '@/hooks/use-system-state'
import { useAppRefreshers } from '@/providers/app-data-context'
import { openLogsDir, restartCore } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { revalidateQuery } from '@/services/query-client'
import { mutate } from '@/services/mutate'
import type { ProxyEmptyStateReason } from './proxy-empty-state-model'
@@ -20,15 +19,16 @@ export const ProxyEmptyState = ({ reason }: Props) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { refreshProxy } = useAppRefreshers()
const { mutateSystemState } = useSystemState()
const [isRestarting, setIsRestarting] = useState(false)
const handleRestart = useLockFn(async () => {
setIsRestarting(true)
try {
await restartCore()
await Promise.all([refreshProxy(), revalidateQuery(runStateQueryKey)])
} catch (error) {
showNotice.error(error)
await mutate(() => restartCore(), { id: 'restart-core' })
await Promise.all([refreshProxy(), mutateSystemState()])
} catch {
// the funnel already notified
} finally {
setIsRestarting(false)
}
+5 -1
View File
@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next'
import { useProxiesData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import {
isInteractableMember,
type ProxyGroupView,
@@ -404,7 +405,10 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) {
handleGroupMenuClose()
if (mode === 'rule') {
updateProxyChainConfigInRuntime(null)
mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
+17 -4
View File
@@ -23,6 +23,7 @@ import { updateRuleProvider } from 'tauri-plugin-mihomo-api'
import { useAppRefreshers, useRulesData } from '@/providers/app-data-context'
import { syncRuntimeProviders } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
@@ -50,11 +51,17 @@ export const ProviderButton = () => {
try {
setUpdating((prev) => ({ ...prev, [name]: true }))
await updateRuleProvider(name)
await mutate(() => updateRuleProvider(name), {
id: `update-rule-provider-${name}`,
errorNotice: false,
})
await refreshRules()
await refreshRuleProviders()
void syncRuntimeProviders()
void mutate(() => syncRuntimeProviders(), {
id: 'sync-runtime-providers',
errorNotice: false,
})
showNotice.success(
'rules.feedback.notifications.provider.updateSuccess',
@@ -91,7 +98,10 @@ export const ProviderButton = () => {
for (const name of allProviders) {
try {
await updateRuleProvider(name)
await mutate(() => updateRuleProvider(name), {
id: `update-rule-provider-${name}`,
errorNotice: false,
})
setUpdating((prev) => ({ ...prev, [name]: false }))
} catch (err) {
console.error(`更新 ${name} 失败`, err)
@@ -100,7 +110,10 @@ export const ProviderButton = () => {
await refreshRules()
await refreshRuleProviders()
void syncRuntimeProviders()
void mutate(() => syncRuntimeProviders(), {
id: 'sync-runtime-providers',
errorNotice: false,
})
showNotice.success('rules.feedback.notifications.provider.allUpdated')
} catch (err) {
@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'
import { useVerge } from '@/hooks/use-verge'
import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service'
import {
buildWebdavSignature,
@@ -119,7 +120,13 @@ export const BackupConfigViewer = memo(
try {
setLoading(true)
await saveWebdavConfig(trimmedUrl, trimmedUsername, data.password)
await mutate(
() => saveWebdavConfig(trimmedUrl, trimmedUsername, data.password),
{
id: 'save-webdav-config',
errorNotice: false,
},
)
await mutateVerge(
(current) =>
current
@@ -156,7 +163,10 @@ export const BackupConfigViewer = memo(
try {
setLoading(true)
await createWebdavBackup().then(async () => {
await mutate(() => createWebdavBackup(), {
id: 'create-webdav-backup',
errorNotice: false,
}).then(async () => {
showNotice.success('settings.modals.backup.messages.backupCreated')
await onBackupSuccess()
})
@@ -35,6 +35,7 @@ import {
restoreLocalBackup,
restoreWebDavBackup,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import {
buildWebdavSignature,
@@ -228,23 +229,38 @@ export const BackupHistoryViewer = ({
try {
if (action === 'delete') {
if (actionIsLocal) {
await deleteLocalBackup(filename)
await mutate(() => deleteLocalBackup(filename), {
id: 'delete-local-backup',
errorNotice: false,
})
} else {
await deleteWebdavBackup(filename)
await mutate(() => deleteWebdavBackup(filename), {
id: 'delete-webdav-backup',
errorNotice: false,
})
}
setPendingConfirmation(null)
await fetchRows()
} else {
if (actionIsLocal) {
await restoreLocalBackup(filename)
await mutate(() => restoreLocalBackup(filename), {
id: 'restore-local-backup',
errorNotice: false,
})
} else {
await restoreWebDavBackup(filename)
await mutate(() => restoreWebDavBackup(filename), {
id: 'restore-webdav-backup',
errorNotice: false,
})
}
setPendingConfirmation(null)
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)
})
@@ -265,8 +281,11 @@ export const BackupHistoryViewer = ({
const savePath = await save({ defaultPath: filename })
if (!savePath || Array.isArray(savePath)) return
try {
await exportLocalBackup(filename, savePath)
showNotice.success('settings.modals.backup.messages.localBackupExported')
await mutate(() => exportLocalBackup(filename, savePath), {
id: 'export-local-backup',
errorNotice: false,
successNotice: 'settings.modals.backup.messages.localBackupExported',
})
} catch (ignoreError: unknown) {
showNotice.error(
'settings.modals.backup.messages.localBackupExportFailed',
+17 -7
View File
@@ -19,6 +19,7 @@ import {
createWebdavBackup,
importLocalBackup,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service'
import { buildWebdavSignature, setWebdavStatus } from '@/services/webdav-status'
@@ -55,12 +56,18 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
try {
setBusyAction(target)
if (target === 'local') {
await createLocalBackup()
showNotice.success('settings.modals.backup.messages.localBackupCreated')
await mutate(() => createLocalBackup(), {
id: 'create-local-backup',
errorNotice: false,
successNotice: 'settings.modals.backup.messages.localBackupCreated',
})
} else {
await createWebdavBackup()
showNotice.success('settings.modals.backup.messages.backupCreated')
setWebdavStatus(webdavSignature, 'ready')
await mutate(() => createWebdavBackup(), {
id: 'create-webdav-backup',
errorNotice: false,
successNotice: 'settings.modals.backup.messages.backupCreated',
onFulfilled: () => setWebdavStatus(webdavSignature, 'ready'),
})
}
} catch (error) {
console.error(error)
@@ -86,8 +93,11 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
if (!selected || Array.isArray(selected)) return
try {
setLocalImporting(true)
await importLocalBackup(selected)
showNotice.success('settings.modals.backup.messages.localBackupImported')
await mutate(() => importLocalBackup(selected), {
id: 'import-local-backup',
errorNotice: false,
successNotice: 'settings.modals.backup.messages.localBackupImported',
})
openHistory('local')
} catch (error) {
console.error(error)
@@ -21,6 +21,7 @@ import { BaseDialog, DialogRef } from '@/components/base'
import { useClash, useClashInfo } from '@/hooks/use-clash'
import { useVerge } from '@/hooks/use-verge'
import { changeClashCore, restartCore, upgradeClashCore } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
const VALID_CORE = [
@@ -60,8 +61,16 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
try {
setChangingCore(core)
closeAllConnections()
const errorMsg = await changeClashCore(core)
void mutate(() => closeAllConnections(), {
id: 'close-all-connections',
errorNotice: false,
})
const result = await mutate(() => changeClashCore(core), {
id: 'change-clash-core',
errorNotice: false,
})
if (!result.ok) return
const errorMsg = result.value
if (errorMsg) {
showNotice.error(errorMsg)
@@ -83,28 +92,33 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
const onRestart = useLockFn(async () => {
try {
setRestarting(true)
await restartCore()
showNotice.success(
t('settings.feedback.notifications.clash.restartSuccess'),
)
await mutate(() => restartCore(), {
id: 'restart-core',
successNotice: t(
'settings.feedback.notifications.clash.restartSuccess',
),
})
setRestarting(false)
} catch (err) {
} catch {
setRestarting(false)
showNotice.error(err)
}
})
const onUpgrade = useLockFn(async () => {
try {
setUpgrading(true)
const report = await upgradeClashCore()
showNotice.success(
report.upgraded
? t('settings.feedback.notifications.clash.versionUpdated')
: t('settings.feedback.notifications.clash.alreadyLatestVersion'),
)
} catch (err) {
showNotice.error(err)
await mutate(() => upgradeClashCore(), {
id: 'upgrade-clash-core',
onFulfilled: (report) => {
showNotice.success(
report.upgraded
? t('settings.feedback.notifications.clash.versionUpdated')
: t('settings.feedback.notifications.clash.alreadyLatestVersion'),
)
},
})
} catch {
// the funnel already notified
} finally {
setUpgrading(false)
}
@@ -17,6 +17,7 @@ import { useClashInfo } from '@/hooks/use-clash'
import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
import { useVerge } from '@/hooks/use-verge'
import { type ProxyPortSettings, saveProxyPorts } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import type { TranslationKey } from '@/types/generated/i18n-keys'
import getSystem from '@/utils/get-system'
@@ -165,22 +166,31 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
) => setPorts((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }))
// 添加保存请求,防止GUI卡死
const { loading, runAsync: saveSettings } = useRequest(saveProxyPorts, {
manual: true,
onSuccess: (outcome) => {
if (outcome.status === 'conflict') {
showNotice.error('settings.modals.clashPort.messages.portInUse', {
port: outcome.port,
})
return
}
setOpen(false)
showNotice.success('settings.modals.clashPort.messages.saved')
const { loading, runAsync: saveSettings } = useRequest(
async (settings: ProxyPortSettings) =>
mutate(() => saveProxyPorts(settings), {
id: 'save-proxy-ports',
errorNotice: false,
}),
{
manual: true,
onSuccess: (result) => {
if (!result.ok) return
const outcome = result.value
if (outcome.status === 'conflict') {
showNotice.error('settings.modals.clashPort.messages.portInUse', {
port: outcome.port,
})
return
}
setOpen(false)
showNotice.success('settings.modals.clashPort.messages.saved')
},
onError: (error) => {
showNotice.error('settings.modals.clashPort.messages.saveFailed', error)
},
},
onError: (error) => {
showNotice.error('settings.modals.clashPort.messages.saveFailed', error)
},
})
)
useImperativeHandle(ref, () => ({
open: () => {
+17 -11
View File
@@ -13,7 +13,6 @@ import {
TextField,
Typography,
} from '@mui/material'
import { invoke } from '@tauri-apps/api/core'
import { useLockFn } from 'ahooks'
import * as yaml from 'js-yaml'
import type { Ref } from 'react'
@@ -36,6 +35,13 @@ import {
import { useClash } from '@/hooks/use-clash'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge'
import {
applyDnsConfig,
getDnsConfigContent,
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'
@@ -465,10 +471,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const initDnsConfig = useCallback(async () => {
try {
const dnsConfig = await invoke<string | null>(
'get_dns_config_content',
{},
)
const dnsConfig = await getDnsConfigContent()
if (dnsConfig !== null) {
const config = yaml.load(dnsConfig) as any
@@ -517,12 +520,12 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
config = parsedConfig as Record<string, any>
}
await invoke('save_dns_config', { dnsConfig: config })
await mutate(() => saveDnsConfig(config), {
id: 'save-dns-config',
errorNotice: false,
})
const validation = await invoke<ValidationOutcome>(
'validate_dns_config',
{},
)
const validation = await validateDnsConfig()
if (validation.status !== 'valid') {
const errorMsg =
@@ -559,7 +562,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
if (dnsEnabled) {
await invoke('apply_dns_config', { apply: true })
await mutate(() => applyDnsConfig(true), {
id: 'apply-dns-config',
errorNotice: false,
})
mutateClash()
}
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog, Switch } from '@/components/base'
import { useClash } from '@/hooks/use-clash'
import { restartCore } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
// Development origins must never be persisted into production configuration.
@@ -135,7 +136,10 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
),
},
})
await restartCore()
await mutate(() => restartCore(), {
id: 'restart-core',
errorNotice: false,
})
await 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 })
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import { entry_lightweight_mode } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
@@ -72,7 +73,12 @@ export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
color: 'primary.main',
'&:hover': { textDecoration: 'underline' },
}}
onClick={async () => await entry_lightweight_mode()}
onClick={async () =>
await mutate(() => entry_lightweight_mode(), {
id: 'entry-lightweight-mode',
errorNotice: false,
})
}
>
{t('shared.actions.enable')}
</Typography>
@@ -36,22 +36,14 @@ import { useSystemProxyState } from '@/hooks/use-system-proxy-state'
import { useVerge } from '@/hooks/use-verge'
import { useSystemData } from '@/providers/app-data-context'
import {
getAutotemProxy,
getEmbeddedServerPort,
getNetworkInterfacesInfo,
getSystemHostname,
getSystemProxy,
patchVergeConfig,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
import getSystem from '@/utils/get-system'
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
const DEFAULT_PAC = `function FindProxyForURL(url, host) {
return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;";
}`
@@ -344,13 +336,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
patch.proxy_host = proxyHost
}
const needResetProxy =
value.pac !== proxy_auto_config ||
proxyHost !== proxy_host ||
pacContent !== pac_file_content ||
value.bypass !== system_proxy_bypass ||
value.use_default !== use_default_bypass
Promise.resolve().then(async () => {
try {
if (Object.keys(patch).length > 0) {
@@ -359,31 +344,11 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
if (Object.keys(patch).length > 0) {
await patchVerge(patch)
}
setTimeout(async () => {
try {
await invalidateProxyState()
if (needResetProxy && enabled) {
const [currentSysProxy, currentAutoProxy] = await Promise.all([
getSystemProxy(),
getAutotemProxy(),
])
const isProxyActive = value.pac
? currentAutoProxy?.enable
: currentSysProxy?.enable
if (isProxyActive) {
await patchVergeConfig({ enable_system_proxy: false })
await sleep(50)
await patchVergeConfig({ enable_system_proxy: true })
await invalidateProxyState()
}
}
} catch (err) {
console.warn('代理状态更新失败:', err)
}
}, 50)
try {
await invalidateProxyState()
} catch (err) {
console.warn('代理状态更新失败:', err)
}
} catch (err) {
console.error('配置保存失败:', err)
mutateVerge()
+5 -1
View File
@@ -21,6 +21,7 @@ import {
} from '@/components/base'
import { useClash } from '@/hooks/use-clash'
import { enhanceProfiles } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system'
import { areValidIpCidrs } from '@/utils/network'
@@ -129,7 +130,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
)
setOpen(false)
showNotice.success('settings.modals.tun.messages.applied')
void enhanceProfiles().catch((err: any) => {
void mutate(() => enhanceProfiles(), {
id: 'enhance-profiles',
errorNotice: false,
}).catch((err: any) => {
showNotice.error(err)
})
} catch (err: any) {
+17 -3
View File
@@ -20,6 +20,7 @@ import {
installUpdate,
restartApp,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useSetUpdateState, useUpdateState } from '@/services/states'
import { openExternalUrl } from '@/utils/open-external-url'
@@ -274,8 +275,18 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
try {
if (await installUpdate(updateInfo.version, onDownloadEvent)) {
await restartApp()
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)
@@ -341,7 +352,10 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
onClose={() => setOpen(false)}
onCancel={() => {
if (updateState) {
void cancelUpdateDownload().catch(showNotice.error)
void mutate(() => cancelUpdateDownload(), {
id: 'cancel-update-download',
errorNotice: false,
}).catch(showNotice.error)
}
setOpen(false)
}}
+14 -2
View File
@@ -12,6 +12,7 @@ import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge'
import { invoke_uwp_tool, setDnsOverride } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system'
@@ -74,7 +75,10 @@ const SettingClash = ({ onError }: Props) => {
}
const onUpdateGeo = async () => {
try {
await updateGeo()
await mutate(() => updateGeo(), {
id: 'update-geo',
errorNotice: false,
})
showNotice.success('settings.feedback.notifications.clash.geoDataUpdated')
} catch (err: any) {
showNotice.error(err)
@@ -90,7 +94,15 @@ const SettingClash = ({ onError }: Props) => {
if (!profileUid) return
setDnsUpdating(true)
try {
const outcome = await setDnsOverride(profileUid, enable, confirmation)
const result = await mutate(
() => setDnsOverride(profileUid, enable, confirmation),
{
id: 'set-dns-override',
errorNotice: false,
},
)
if (!result.ok) return
const outcome = result.value
if (outcome.status === 'confirmation_required') {
setDnsConfirmation({ profileUid, source: outcome.source })
} else {
@@ -13,6 +13,7 @@ import {
openDevTools,
openLogsDir,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { checkUpdateSafe as checkUpdate } from '@/services/update'
import { version } from '@root/package.json'
@@ -61,7 +62,10 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
}
const onExportDiagnosticInfo = useCallback(async () => {
await exportDiagnosticInfo()
await mutate(() => exportDiagnosticInfo(), {
id: 'export-diagnostic-info',
errorNotice: false,
})
showNotice.success('shared.feedback.notifications.common.copySuccess', 1000)
}, [])
@@ -128,7 +132,12 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
/>
<SettingItem
onClick={openDevTools}
onClick={() => {
void mutate(() => openDevTools(), {
id: 'open-devtools',
errorNotice: false,
}).catch(() => {})
}}
label={t('settings.components.verge.advanced.fields.openDevTools')}
/>
@@ -145,7 +154,10 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
<SettingItem
onClick={() => {
exitApp()
void mutate(() => exitApp(), {
id: 'exit-app',
errorNotice: false,
}).catch(() => {})
}}
label={t('settings.components.verge.advanced.fields.exit')}
/>
+15 -6
View File
@@ -1,15 +1,15 @@
import { LanguageRounded } from '@mui/icons-material'
import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseLoading } from '@/components/base'
import { useIconCache } from '@/hooks/use-icon-cache'
import { cmdTestDelay } from '@/services/cmds'
import delayManager from '@/services/delay'
import { subscribeVergeEvents } from '@/services/events'
import { showNotice } from '@/services/notice-service'
import { useTestAllCounter } from '@/store/app-store-context'
import { TestBox } from './test-box'
@@ -52,10 +52,19 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
{ label: t('shared.actions.delete'), handler: onDelete },
]
useEffect(
() => subscribeVergeEvents({ 'verge://test-all': () => onDelay() }),
[url, onDelay],
)
// Store bump replaces the per-item event subscription; skip the initial
// mount value and call through a ref so url changes do not re-trigger tests.
const testAllCounter = useTestAllCounter()
const onDelayRef = useRef(onDelay)
onDelayRef.current = onDelay
const mountedRef = useRef(false)
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true
return
}
void onDelayRef.current()
}, [testAllCounter])
return (
<Box>
+11 -5
View File
@@ -8,6 +8,7 @@ import {
getRuntimeConfig,
patchClashConfig,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import {
revalidateQuery,
setCacheData,
@@ -104,8 +105,11 @@ export const useClash = () => {
}
const patchClash = useLockFn(async (patch: Partial<IConfigData>) => {
await patchClashConfig(patch)
mutateClash()
await mutate(() => patchClashConfig(patch), {
id: 'patch-clash-config',
revalidate: [['getRuntimeConfig']],
errorNotice: false,
})
})
const version = versionData?.meta
@@ -132,9 +136,11 @@ export const useClashInfo = () => {
validatePorts(patch)
await patchClashConfig(patch)
mutateInfo()
revalidateQuery(['getClashConfig'])
await mutate(() => patchClashConfig(patch), {
id: 'patch-clash-info',
revalidate: [['getClashInfo'], ['getClashConfig']],
errorNotice: false,
})
})
const invalidateClashConfig = () => revalidateQuery(['getClashConfig'])
-15
View File
@@ -1,15 +0,0 @@
import { listen, EventCallback } from '@tauri-apps/api/event'
import { useCallback } from 'react'
export const useListen = () => {
const addListener = useCallback(
async <T>(eventName: string, handler: EventCallback<T>) => {
return await listen(eventName, handler)
},
[],
)
return {
addListener,
}
}
+23 -16
View File
@@ -1,18 +1,12 @@
import { useCallback, useRef } from 'react'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import {
fetchCacheData,
revalidateQuery,
setCacheData,
useQuery,
} from '@/services/query-client'
import { mutate } from '@/services/mutate'
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
import { debugLog } from '@/utils/debug'
const profilesQueryKey = ['getProfiles'] as const
export const revalidateProfiles = () => revalidateQuery(profilesQueryKey)
export const fetchProfilesIntoCache = () =>
fetchCacheData(profilesQueryKey, getProfiles)
@@ -49,16 +43,25 @@ export const useProfiles = () => {
const patchProfiles = useCallback(
async (value: Partial<IProfilesConfig>) => {
try {
const outcome = await patchProfilesConfig(value)
const result = await mutate(() => patchProfilesConfig(value), {
id: 'patch-profiles-config',
onFulfilled: (outcome) => {
if (outcome.status === 'valid') {
void setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
current ? { ...current, ...value } : current,
)
}
},
})
if (outcome.status === 'valid') {
await setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
current ? { ...current, ...value } : current,
)
} else if (outcome.status !== 'busy') {
if (!result.ok) {
// Backend Busy keeps local state untouched, as before.
return result.value
}
const outcome = result.value
if (outcome.status !== 'valid' && outcome.status !== 'busy') {
await mutateProfiles()
}
return outcome
} catch (error) {
await mutateProfiles()
@@ -71,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()
})
+13 -6
View File
@@ -12,6 +12,7 @@ import {
} from '@/hooks/use-record-selection'
import { useVerge } from '@/hooks/use-verge'
import { syncTrayProxySelection } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { debugLog } from '@/utils/debug'
// 缓存连接清理
@@ -58,7 +59,10 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
// 切换节点
const syncTraySelection = useCallback(() => {
syncTrayProxySelection().catch((error) => {
mutate(() => syncTrayProxySelection(), {
id: 'sync-tray-selection',
errorNotice: false,
}).catch((error) => {
console.error('[ProxySelection] 托盘状态同步失败:', error)
})
}, [])
@@ -74,11 +78,14 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
}
try {
if (isFixedProxy) {
await unfixedProxy(groupName)
} else {
await selectNodeForGroup(groupName, proxyName)
}
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) {
+9 -2
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { forgetSelectedNode, recordSelectedNode } from '@/services/cmds'
import { mutate } from '@/services/mutate'
/**
* Persists every group selection because core-local state is not durable across all run modes.
@@ -9,7 +10,10 @@ import { forgetSelectedNode, recordSelectedNode } from '@/services/cmds'
export const useRecordSelection = () => {
return useCallback(async (groupName: string, proxyName: string) => {
try {
await recordSelectedNode(groupName, proxyName)
await mutate(() => recordSelectedNode(groupName, proxyName), {
id: `persist-selected-node:${groupName}`,
errorNotice: false,
})
} catch (error) {
console.error('[Selection] 保存代理选择失败:', error)
}
@@ -19,7 +23,10 @@ export const useRecordSelection = () => {
export const useForgetSelection = () => {
return useCallback(async (groupName: string) => {
try {
await forgetSelectedNode(groupName)
await mutate(() => forgetSelectedNode(groupName), {
id: `persist-selected-node:${groupName}`,
errorNotice: false,
})
} catch (error) {
console.error('[Selection] 清除代理选择失败:', error)
}
+15 -5
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { installService, restartCore } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
const executeWithErrorHandling = async <T>(
@@ -23,14 +24,19 @@ const executeWithErrorHandling = async <T>(
export const useServiceInstaller = () => {
const installServiceAndRestartCore = useCallback(async () => {
const outcome = await executeWithErrorHandling(
() => installService(),
const installResult = await executeWithErrorHandling(
() =>
mutate(() => installService(), {
id: 'install-service',
errorNotice: false,
}),
'settings.statuses.clashService.installing',
)
if (outcome.status === 'sidecar') {
if (!installResult.ok) return
if (installResult.value.status === 'sidecar') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
{ reason: installResult.value.reason },
0,
)
return
@@ -40,7 +46,11 @@ export const useServiceInstaller = () => {
)
await executeWithErrorHandling(
() => restartCore(),
() =>
mutate(() => restartCore(), {
id: 'restart-core',
errorNotice: false,
}),
'settings.statuses.clash.restarting',
'settings.feedback.notifications.clash.restartSuccess',
)
+10 -4
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { uninstallService } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useSystemState } from './use-system-state'
@@ -12,10 +13,15 @@ export const useServiceUninstaller = () => {
let uninstallError: unknown
showNotice.info('settings.statuses.clashService.uninstalling')
try {
await uninstallService()
showNotice.success(
'settings.feedback.notifications.clashService.uninstallSuccess',
)
const result = await mutate(() => uninstallService(), {
id: 'uninstall-service',
errorNotice: false,
})
if (result.ok) {
showNotice.success(
'settings.feedback.notifications.clashService.uninstallSuccess',
)
}
} catch (error) {
uninstallError = error
}
+9 -2
View File
@@ -9,6 +9,7 @@ import {
getEmbeddedServerPort,
patchVergeConfig,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import {
removeCacheData,
revalidateQueries,
@@ -64,10 +65,16 @@ export const useSystemProxyState = () => {
while (pendingRef.current !== null) {
const target = pendingRef.current
pendingRef.current = null
await patchVergeConfig({ enable_system_proxy: target })
await mutate(() => patchVergeConfig({ enable_system_proxy: target }), {
id: 'toggle-system-proxy',
errorNotice: false,
})
confirmed = target
if (!target && verge?.auto_close_connection) {
await closeAllConnections().catch(() => {})
await mutate(() => closeAllConnections(), {
id: 'close-all-connections',
errorNotice: false,
}).catch(() => {})
}
}
} catch (error) {
+7 -25
View File
@@ -1,14 +1,7 @@
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 { useAppReads, useRunState } from '@/store/app-store-context'
import { useVisibility } from './use-visibility'
export const runStateQueryKey = ['getRuntimeState'] as const
const appUptimeQueryKey = ['appUptime'] as const
/** Fail closed until the first snapshot so TUN never flashes as available. */
@@ -27,20 +20,9 @@ const unknownRunState: RunState = {
/** Event-driven run state; Rust owns all derived availability decisions. */
export function useSystemState() {
const pageVisible = useVisibility()
const {
data: runState = unknownRunState,
refetch: mutateSystemState,
isLoading,
} = useQuery({
queryKey: runStateQueryKey,
queryFn: getRuntimeState,
// A safety net only; transitions normally arrive by event.
refetchInterval: pageVisible ? 30000 : false,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
})
const { readRunState } = useAppReads()
const snapshot = useRunState()
const runState = snapshot ?? unknownRunState
return {
runState,
@@ -50,8 +32,8 @@ export function useSystemState() {
isServiceMode: runState.mode === 'Service',
isTunModeAvailable: runState.tunCapable,
serviceNeedsAttention: runState.serviceNeedsAttention,
mutateSystemState,
isLoading,
mutateSystemState: readRunState,
isLoading: snapshot == null,
}
}
+10 -7
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { getVergeConfig, patchVergeConfig } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { getPreloadConfig, setPreloadConfig } from '@/services/preload'
import { setCacheData, useQuery } from '@/services/query-client'
@@ -38,13 +39,15 @@ export const useVerge = () => {
)
}
const patchVerge = useCallback(
async (value: Partial<IVergeConfig>) => {
await patchVergeConfig(value)
await refetch()
},
[refetch],
)
// Callers own the error toast (GuardState onCatch / page onError), so the
// funnel must not add a second one.
const patchVerge = useCallback(async (value: Partial<IVergeConfig>) => {
await mutate(() => patchVergeConfig(value), {
id: 'patch-verge-config',
revalidate: [['getVergeConfig']],
errorNotice: false,
})
}, [])
return {
verge,
+4 -8
View File
@@ -1,4 +1,3 @@
import { TauriEvent } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEffect, useState } from 'react'
@@ -42,13 +41,10 @@ export const useVisibility = () => {
window.addEventListener('focus', handleVisibleEvent)
const unlistenFocusChanged = appWindow.onFocusChanged(updateVisibleSoon)
const unlistenCloseRequested = appWindow.listen(
TauriEvent.WINDOW_CLOSE_REQUESTED,
() => {
setVisible(false)
updateVisibleSoon()
},
)
const unlistenCloseRequested = appWindow.onCloseRequested(() => {
setVisible(false)
updateVisibleSoon()
})
void updateVisible()
return () => {
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "فشل التحقق من تكوين الاشتراك، يرجى فحص ملف التكوين، تم التراجع عن التغييرات، تفاصيل الخطأ:",
"bootFailed": "فشل التحقق من التكوين عند الإقلاع، تم استخدام التكوين الافتراضي، يرجى فحص ملف التكوين، تفاصيل الخطأ:",
"coreChangeFailed": "فشل التحقق من التكوين عند تغيير النواة، تم استخدام التكوين الافتراضي، يرجى فحص ملف التكوين، تفاصيل الخطأ:",
"processTerminated": "تم إنهاء عملية التحقق. يرجى التحقق مما إذا كان برنامج الأمان يمنع تشغيل النواة، أو ما إذا كانت وحدة المعالجة المركزية وبيئة التشغيل في الكمبيوتر تدعمان برنامج النواة."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML syntax error, changes reverted",
"readError": "YAML read error, changes reverted",
"mappingError": "YAML mapping error, changes reverted",
"keyError": "YAML key error, changes reverted",
"generalError": "YAML error, changes reverted"
"mappingError": "YAML mapping error, changes reverted"
},
"merge": {
"syntaxError": "Merge file syntax error, changes reverted",
"mappingError": "Merge file mapping error, changes reverted",
"keyError": "Merge file key error, changes reverted",
"generalError": "Merge file error, changes reverted"
"mappingError": "Merge file mapping error, changes reverted"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "Abonnement-Konfigurationsüberprüfung fehlgeschlagen. Bitte überprüfen Sie die Abonnement-Konfigurationsdatei. Die Änderungen wurden rückgängig gemacht. Fehlerdetails: ",
"bootFailed": "Start-Abonnement-Konfigurationsüberprüfung fehlgeschlagen. Die Standardkonfiguration wurde verwendet, um die App zu starten. Bitte überprüfen Sie die Abonnement-Konfigurationsdatei. Fehlerdetails: ",
"coreChangeFailed": "Konfigurationsüberprüfung beim Wechsel des Kerns fehlgeschlagen. Die Standardkonfiguration wurde verwendet, um die App zu starten. Bitte überprüfen Sie die Abonnement-Konfigurationsdatei. Fehlerdetails: ",
"processTerminated": "Der Validierungsprozess wurde beendet. Bitte prüfen Sie, ob eine Sicherheitssoftware die Ausführung des Kerns verhindert oder ob die CPU und Laufzeitumgebung Ihres Computers das Kernprogramm unterstützen."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML-Syntaxfehler. Die Änderungen wurden rückgängig gemacht.",
"readError": "YAML-Lesefehler. Die Änderungen wurden rückgängig gemacht.",
"mappingError": "YAML-Mappingfehler. Die Änderungen wurden rückgängig gemacht.",
"keyError": "YAML-Schlüsselfehler. Die Änderungen wurden rückgängig gemacht.",
"generalError": "YAML-Fehler. Die Änderungen wurden rückgängig gemacht."
"mappingError": "YAML-Mappingfehler. Die Änderungen wurden rückgängig gemacht."
},
"merge": {
"syntaxError": "Syntaxfehler in der Überdeckungsdatei. Die Änderungen wurden rückgängig gemacht.",
"mappingError": "Mappingfehler in der Überdeckungsdatei. Die Änderungen wurden rückgängig gemacht.",
"keyError": "Schlüsselfehler in der Überdeckungsdatei. Die Änderungen wurden rückgängig gemacht.",
"generalError": "Fehler in der Überdeckungsdatei. Die Änderungen wurden rückgängig gemacht."
"mappingError": "Mappingfehler in der Überdeckungsdatei. Die Änderungen wurden rückgängig gemacht."
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "Subscription configuration validation failed. Please check the subscription configuration file; modifications have been rolled back.",
"bootFailed": "Boot subscription configuration validation failed. Started with the default configuration; please check the subscription configuration file.",
"coreChangeFailed": "Configuration validation failed when switching the kernel. Started with the default configuration; please check the subscription configuration file.",
"processTerminated": "The validation process was terminated. Please check whether security software is preventing the core from running, or whether your computer's CPU and runtime environment support the core executable."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML syntax error, changes reverted",
"readError": "YAML read error, changes reverted",
"mappingError": "YAML mapping error, changes reverted",
"keyError": "YAML key error, changes reverted",
"generalError": "YAML error, changes reverted"
"mappingError": "YAML mapping error, changes reverted"
},
"merge": {
"syntaxError": "Merge file syntax error, changes reverted",
"mappingError": "Merge file mapping error, changes reverted",
"keyError": "Merge file key error, changes reverted",
"generalError": "Merge file error, changes reverted"
"mappingError": "Merge file mapping error, changes reverted"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "Error de validación de la configuración de la suscripción. Compruebe el archivo de configuración de la suscripción. Los cambios se han deshecho. Detalles del error: ",
"bootFailed": "Error de validación de la configuración de la suscripción de arranque. Se ha iniciado con la configuración predeterminada. Compruebe el archivo de configuración de la suscripción. Detalles del error: ",
"coreChangeFailed": "Error de validación de la configuración al cambiar el núcleo. Se ha iniciado con la configuración predeterminada. Compruebe el archivo de configuración de la suscripción. Detalles del error: ",
"processTerminated": "El proceso de validación ha terminado. Compruebe si algún software de seguridad impide que se ejecute el núcleo o si la CPU y el entorno de ejecución del equipo son compatibles con el programa del núcleo."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "Error de sintaxis YAML. Los cambios se han deshecho",
"readError": "Error al leer el archivo YAML. Los cambios se han deshecho",
"mappingError": "Error de mapeo YAML. Los cambios se han deshecho",
"keyError": "Error de clave YAML. Los cambios se han deshecho",
"generalError": "Error YAML. Los cambios se han deshecho"
"mappingError": "Error de mapeo YAML. Los cambios se han deshecho"
},
"merge": {
"syntaxError": "Error de sintaxis en el archivo de sobrescritura. Los cambios se han deshecho",
"mappingError": "Error de mapeo en el archivo de sobrescritura. Los cambios se han deshecho",
"keyError": "Error de clave en el archivo de sobrescritura. Los cambios se han deshecho",
"generalError": "Error en el archivo de sobrescritura. Los cambios se han deshecho"
"mappingError": "Error de mapeo en el archivo de sobrescritura. Los cambios se han deshecho"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "اعتبارسنجی پیکربندی اشتراک ناموفق بود، فایل پیکربندی را بررسی کنید، تغییرات برگشت داده شد، جزئیات خطا:",
"bootFailed": "اعتبارسنجی پیکربندی هنگام راه‌اندازی ناموفق بود، پیکربندی پیش‌فرض استفاده شد، فایل پیکربندی را بررسی کنید، جزئیات خطا:",
"coreChangeFailed": "اعتبارسنجی پیکربندی هنگام تغییر هسته ناموفق بود، پیکربندی پیش‌فرض استفاده شد، فایل پیکربندی را بررسی کنید، جزئیات خطا:",
"processTerminated": "فرآیند اعتبارسنجی متوقف شد. بررسی کنید که آیا نرم‌افزار امنیتی مانع اجرای هسته شده است یا پردازنده و محیط اجرای رایانه از برنامه هسته پشتیبانی می‌کنند."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML syntax error, changes reverted",
"readError": "YAML read error, changes reverted",
"mappingError": "YAML mapping error, changes reverted",
"keyError": "YAML key error, changes reverted",
"generalError": "YAML error, changes reverted"
"mappingError": "YAML mapping error, changes reverted"
},
"merge": {
"syntaxError": "Merge file syntax error, changes reverted",
"mappingError": "Merge file mapping error, changes reverted",
"keyError": "Merge file key error, changes reverted",
"generalError": "Merge file error, changes reverted"
"mappingError": "Merge file mapping error, changes reverted"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "Validasi konfigurasi langganan gagal, periksa file konfigurasi, perubahan dibatalkan, detail kesalahan:",
"bootFailed": "Validasi konfigurasi saat boot gagal, menggunakan konfigurasi default, periksa file konfigurasi, detail kesalahan:",
"coreChangeFailed": "Validasi konfigurasi saat ganti inti gagal, menggunakan konfigurasi default, periksa file konfigurasi, detail kesalahan:",
"processTerminated": "Proses validasi dihentikan. Periksa apakah perangkat lunak keamanan mencegah inti berjalan, atau apakah CPU dan lingkungan runtime komputer mendukung program inti."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML syntax error, changes reverted",
"readError": "YAML read error, changes reverted",
"mappingError": "YAML mapping error, changes reverted",
"keyError": "YAML key error, changes reverted",
"generalError": "YAML error, changes reverted"
"mappingError": "YAML mapping error, changes reverted"
},
"merge": {
"syntaxError": "Merge file syntax error, changes reverted",
"mappingError": "Merge file mapping error, changes reverted",
"keyError": "Merge file key error, changes reverted",
"generalError": "Merge file error, changes reverted"
"mappingError": "Merge file mapping error, changes reverted"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "プロファイル設定の検証に失敗しました。プロファイル設定ファイルを確認してください。変更は取り消されました。エラー詳細:",
"bootFailed": "起動時のプロファイル設定の検証に失敗しました。デフォルト設定で起動しました。プロファイル設定ファイルを確認してください。エラー詳細:",
"coreChangeFailed": "コアを切り替える際の設定検証に失敗しました。デフォルト設定で起動しました。プロファイル設定ファイルを確認してください。エラー詳細:",
"processTerminated": "検証プロセスが中断されました。セキュリティソフトウェアがコアの実行を妨げていないか、またはコンピューターの CPU/実行環境がコアプログラムに対応しているかを確認してください。"
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML構文エラーがあります。変更は取り消されました。",
"readError": "YAMLファイルの読み取りエラーがあります。変更は取り消されました。",
"mappingError": "YAMLマッピングエラーがあります。変更は取り消されました。",
"keyError": "YAMLキーエラーがあります。変更は取り消されました。",
"generalError": "YAMLエラーがあります。変更は取り消されました。"
"mappingError": "YAMLマッピングエラーがあります。変更は取り消されました。"
},
"merge": {
"syntaxError": "上書きファイルの構文エラーがあります。変更は取り消されました。",
"mappingError": "上書きファイルのマッピングエラーがあります。変更は取り消されました。",
"keyError": "上書きファイルのキーエラーがあります。変更は取り消されました。",
"generalError": "上書きファイルにエラーがあります。変更は取り消されました。"
"mappingError": "上書きファイルのマッピングエラーがあります。変更は取り消されました。"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "설정 검증 실패",
"bootFailed": "부팅 설정 검증 실패",
"coreChangeFailed": "코어 변경 설정 검증 실패",
"processTerminated": "설정 검증 프로세스가 종료되었습니다. 보안 소프트웨어가 코어 실행을 차단하고 있는지 또는 컴퓨터의 CPU 및 실행 환경이 코어 프로그램을 지원하는지 확인하세요."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML 구문 오류",
"readError": "YAML 읽기 오류",
"mappingError": "YAML 매핑 오류",
"keyError": "YAML 키 오류",
"generalError": "YAML 오류"
"mappingError": "YAML 매핑 오류"
},
"merge": {
"syntaxError": "병합 파일 구문 오류",
"mappingError": "병합 파일 매핑 오류",
"keyError": "병합 파일 키 오류",
"generalError": "병합 파일 오류"
"mappingError": "병합 파일 매핑 오류"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "Ошибка проверки конфигурации подписки, проверьте файл конфигурации, изменения отменены, ошибка:",
"bootFailed": "Ошибка проверки конфигурации при запуске, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
"coreChangeFailed": "Ошибка проверки конфигурации при смене ядра, используется конфигурация по умолчанию, проверьте файл конфигурации, ошибка:",
"processTerminated": "Процесс проверки был прерван. Проверьте, не блокирует ли защитное ПО запуск ядра и поддерживают ли процессор и среда выполнения компьютера программу ядра."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "Ошибка синтаксиса YAML, откат изменений",
"readError": "Ошибка чтения YAML, откат изменений",
"mappingError": "Ошибка сопоставления YAML, откат изменений",
"keyError": "Ошибка ключа YAML, откат изменений",
"generalError": "Ошибка YAML, откат изменений"
"mappingError": "Ошибка сопоставления YAML, откат изменений"
},
"merge": {
"syntaxError": "Ошибка синтаксиса Merge File, откат изменений",
"mappingError": "Ошибка сопоставления в Merge File, откат изменений",
"keyError": "Ошибка ключа в Merge File, откат изменений",
"generalError": "Ошибка Merge File, откат изменений"
"mappingError": "Ошибка сопоставления в Merge File, откат изменений"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "Abonelik yapılandırması doğrulaması başarısız oldu. Lütfen abonelik yapılandırma dosyasını kontrol edin; değişiklikler geri alındı.",
"bootFailed": "Başlangıç abonelik yapılandırması doğrulaması başarısız oldu. Varsayılan yapılandırma ile başlatıldı; lütfen abonelik yapılandırma dosyasını kontrol edin.",
"coreChangeFailed": "Çekirdek değiştirilirken yapılandırma doğrulaması başarısız oldu. Varsayılan yapılandırma ile başlatıldı; lütfen abonelik yapılandırma dosyasını kontrol edin.",
"processTerminated": "Doğrulama işlemi sonlandırıldı. Güvenlik yazılımının çekirdeğin çalışmasını engelleyip engellemediğini veya bilgisayarınızın CPU ve çalışma ortamının çekirdek programını destekleyip desteklemediğini kontrol edin."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML sözdizimi hatası, değişiklikler geri alındı",
"readError": "YAML okuma hatası, değişiklikler geri alındı",
"mappingError": "YAML eşleme hatası, değişiklikler geri alındı",
"keyError": "YAML anahtar hatası, değişiklikler geri alındı",
"generalError": "YAML hatası, değişiklikler geri alındı"
"mappingError": "YAML eşleme hatası, değişiklikler geri alındı"
},
"merge": {
"syntaxError": "Birleştirme dosyası sözdizimi hatası, değişiklikler geri alındı",
"mappingError": "Birleştirme dosyası eşleme hatası, değişiklikler geri alındı",
"keyError": "Birleştirme dosyası anahtar hatası, değişiklikler geri alındı",
"generalError": "Birleştirme dosyası hatası, değişiklikler geri alındı"
"mappingError": "Birleştirme dosyası eşleme hatası, değişiklikler geri alındı"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "Язылу көйләү тикшерүе уңышсыз, көйләү файлын тикшерегез, үзгәрешләр кире кайтарылды, хата:",
"bootFailed": "Йөкләү вакытында көйләү тикшерүе уңышсыз, стандарт көйләү кулланылды, көйләү файлын тикшерегез, хата:",
"coreChangeFailed": "Ядро алыштырганда көйләү тикшерүе уңышсыз, стандарт көйләү кулланылды, көйләү файлын тикшерегез, хата:",
"processTerminated": "Тикшерү процессы туктатылды. Иминлек программасы үзәкнең эшләвенә комачауламыймы һәм компьютерның CPU/эш мохите үзәк программасын эшләтә аламы икәнен тикшерегез."
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML syntax error, changes reverted",
"readError": "YAML read error, changes reverted",
"mappingError": "YAML mapping error, changes reverted",
"keyError": "YAML key error, changes reverted",
"generalError": "YAML error, changes reverted"
"mappingError": "YAML mapping error, changes reverted"
},
"merge": {
"syntaxError": "Merge file syntax error, changes reverted",
"mappingError": "Merge file mapping error, changes reverted",
"keyError": "Merge file key error, changes reverted",
"generalError": "Merge file error, changes reverted"
"mappingError": "Merge file mapping error, changes reverted"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "订阅配置校验失败,请检查订阅配置文件,变更已撤销,错误详情:",
"bootFailed": "启动订阅配置校验失败,已使用默认配置启动;请检查订阅配置文件,错误详情:",
"coreChangeFailed": "切换内核时配置校验失败,已使用默认配置启动;请检查订阅配置文件,错误详情:",
"processTerminated": "验证进程被终止,请检查是否有安全软件阻止了内核运行,或电脑 CPU/运行环境是否支持该内核程序。"
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML 语法错误,变更已撤销",
"readError": "YAML 读取错误,变更已撤销",
"mappingError": "YAML 映射错误,变更已撤销",
"keyError": "YAML 键错误,变更已撤销",
"generalError": "YAML 错误,变更已撤销"
"mappingError": "YAML 映射错误,变更已撤销"
},
"merge": {
"syntaxError": "覆写文件语法错误,变更已撤销",
"mappingError": "覆写文件映射错误,变更已撤销",
"keyError": "覆写文件键错误,变更已撤销",
"generalError": "覆写文件错误,变更已撤销"
"mappingError": "覆写文件映射错误,变更已撤销"
}
}
},
+2 -7
View File
@@ -108,7 +108,6 @@
"config": {
"failed": "訂閱配置校驗失敗,請檢查訂閱配置文件,變更已撤銷,錯誤詳情:",
"bootFailed": "啟動訂閱配置校驗失敗,已使用預設配置啟動;請檢查訂閱配置文件,錯誤詳情:",
"coreChangeFailed": "切換內核時配置校驗失敗,已使用預設配置啟動;請檢查訂閱配置文件,錯誤詳情:",
"processTerminated": "驗證程序被終止,請檢查是否有安全軟體阻止了內核執行,或電腦的 CPU/執行環境是否支援該內核程式。"
},
"script": {
@@ -120,15 +119,11 @@
"yaml": {
"syntaxError": "YAML 語法錯誤,變更已撤銷",
"readError": "YAML 讀取錯誤,變更已撤銷",
"mappingError": "YAML 映射錯誤,變更已撤銷",
"keyError": "YAML 鍵錯誤,變更已撤銷",
"generalError": "YAML 錯誤,變更已撤銷"
"mappingError": "YAML 映射錯誤,變更已撤銷"
},
"merge": {
"syntaxError": "覆寫檔案語法錯誤,變更已撤銷",
"mappingError": "覆寫檔案映射錯誤,變更已撤銷",
"keyError": "覆寫檔案鍵錯誤,變更已撤銷",
"generalError": "覆寫檔案錯誤,變更已撤銷"
"mappingError": "覆寫檔案映射錯誤,變更已撤銷"
}
}
},
+3 -6
View File
@@ -20,11 +20,8 @@ import {
getPreloadConfig,
} from './services/preload'
import { swrConfig } from './services/query-client'
import {
LoadingCacheProvider,
ThemeModeProvider,
UpdateStateProvider,
} from './services/states'
import { ThemeModeProvider, UpdateStateProvider } from './services/states'
import { AppStoreProvider } from './store/app-store'
import { disableWebViewShortcuts } from './utils/disable-webview-shortcuts'
if (!window.ResizeObserver) {
@@ -43,7 +40,7 @@ disableWebViewShortcuts()
const initializeApp = (initialThemeMode: 'light' | 'dark') => {
const contexts = [
<ThemeModeProvider key="theme" initialState={initialThemeMode} />,
<LoadingCacheProvider key="loading" />,
<AppStoreProvider key="app-store" />,
<UpdateStateProvider key="update" />,
]
+3 -3
View File
@@ -17,16 +17,16 @@ import {
import { useI18n } from '@/hooks/use-i18n'
import { useVerge } from '@/hooks/use-verge'
import { useWindowDecorations } from '@/hooks/use-window'
import { useEventBus } from '@/services/bus'
import { handleNoticeMessage } from '@/services/notice-handlers'
import { useThemeMode } from '@/services/states'
import getSystem from '@/utils/get-system'
import {
useCustomTheme,
useLayoutEvents,
useLoadingOverlay,
usePendingFailures,
} from './_layout/hooks'
import { handleNoticeMessage } from './_layout/utils'
import 'dayjs/locale/ru'
import 'dayjs/locale/zh-cn'
@@ -77,7 +77,7 @@ const Layout = () => {
[t, navigate],
)
useLayoutEvents(handleNotice)
useEventBus(handleNotice)
usePendingFailures()
useEffect(() => {
-1
View File
@@ -1,4 +1,3 @@
export { useLayoutEvents } from './use-layout-events'
export { usePendingFailures, useDialogFailure } from './use-pending-failures'
export { useLoadingOverlay } from './use-loading-overlay'
export { useNavMenuOrder } from './use-nav-menu-order'
@@ -1,86 +0,0 @@
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEffect } from 'react'
import { revalidateProfiles } from '@/hooks/use-profiles'
import { runStateQueryKey } from '@/hooks/use-system-state'
import type { RunState } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { revalidateQueries, setCacheData } from '@/services/query-client'
import { forgetShownStartupError } from '../utils/notification-handlers'
export const useLayoutEvents = (
handleNotice: (payload: [string, string]) => void,
) => {
useEffect(() => {
let lastProfileId: string | null = null
let lastProfileUpdateTime = 0
const refreshThrottle = 800
const revalidateKeys = (keys: readonly string[]) => {
void revalidateQueries(keys.map((key) => [key]))
}
const handleProfileChanged = (newProfileId: string) => {
const now = Date.now()
if (
lastProfileId === newProfileId &&
now - lastProfileUpdateTime < refreshThrottle
) {
return
}
lastProfileId = newProfileId
lastProfileUpdateTime = now
void revalidateProfiles()
}
const unsubscribe = subscribeVergeEvents(
{
'profile-changed': handleProfileChanged,
'verge://refresh-profiles': () => void revalidateProfiles(),
'verge://refresh-clash-config': () => {
revalidateKeys([
'getProxyView',
'getVersion',
'getClashConfig',
'getClashInfo',
'getClashMode',
'getRuntimeConfig',
'getRules',
'getRuleProviders',
])
},
'verge://refresh-verge-config': () => {
revalidateKeys([
'getVergeConfig',
'getSystemProxy',
'getAutotemProxy',
])
},
// Transitions carry the full run-state snapshot, so write it directly to cache.
'verge://run-state-changed': (payload) => {
void setCacheData<RunState>(runStateQueryKey, payload)
if (payload.mode !== 'NotRunning') forgetShownStartupError()
},
'verge://notice-message': handleNotice,
},
// Re-read event-only state after subscribing to close the initial race window.
() => {
revalidateKeys(['getRuntimeState', 'getVergeConfig'])
handleNotice(['dns_override::auto_disabled', ''])
handleNotice(['enhance::discarded_keys', ''])
handleNotice(['service_core::sidecar_fallback', ''])
handleNotice(['service_core::repair_required', ''])
handleNotice(['service_core::app_data_not_owned', ''])
handleNotice(['core_start::error', ''])
},
)
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
if (payload) handleNotice(['core_start::error', ''])
})
return () => {
unsubscribe()
void unlistenFocus.then((unlisten) => unlisten())
}
}, [handleNotice])
}
+25 -95
View File
@@ -1,14 +1,12 @@
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
getPendingFailures,
getSidecarFailure,
type PendingFailure,
type SidecarFailureSnapshot,
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import type { PendingFailure } from '@/services/cmds'
import { showNotice, syncSidecarFailure } from '@/services/notice-service'
import {
usePendingFailureList,
useSidecarFailure,
} from '@/store/app-store-context'
/** Failures handled by the recovery dialog instead of a toast. */
const CODES_SHOWN_AS_A_DIALOG = new Set<string>([
@@ -31,111 +29,43 @@ const windowIsWatched = async () => {
}
}
const usePendingFailureReader = (
onFailures: (failures: PendingFailure[]) => void,
onSidecarFailure?: (snapshot: SidecarFailureSnapshot) => void,
) => {
// Avoid resubscribing when the handler changes.
const handlerRef = useRef({ onFailures, onSidecarFailure })
handlerRef.current = { onFailures, onSidecarFailure }
useEffect(() => {
let disposed = false
const read = () => {
if (handlerRef.current.onSidecarFailure) {
void getSidecarFailure()
.then((snapshot) => {
if (!disposed) handlerRef.current.onSidecarFailure?.(snapshot)
})
.catch((error) => {
console.warn(
'[pending-failures] Sidecar failure could not be read:',
error,
)
})
}
void (async () => {
let failures: PendingFailure[]
try {
failures = await getPendingFailures()
} catch (error) {
console.warn('[pending-failures] could not be read:', error)
return
}
if (!disposed) handlerRef.current.onFailures(failures)
})()
}
const unsubscribe = subscribeVergeEvents(
{ 'verge://pending-failures-changed': read },
read,
)
const readWhenVisible = () => {
if (document.visibilityState === 'visible') read()
}
document.addEventListener('visibilitychange', readWhenVisible)
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
if (payload) read()
})
return () => {
disposed = true
unsubscribe()
document.removeEventListener('visibilitychange', readWhenVisible)
void unlistenFocus.then((unlisten) => unlisten())
}
}, [])
}
/** Show each pending failure sequence once when the window can be read. */
export const usePendingFailures = () => {
const failures = usePendingFailureList()
const sidecarFailure = useSidecarFailure()
const shownSequencesRef = useRef(new Map<string, number>())
const showNewFailures = useCallback(async (failures: PendingFailure[]) => {
useEffect(() => {
if (sidecarFailure) syncSidecarFailure(sidecarFailure)
}, [sidecarFailure])
useEffect(() => {
const asToast = failures.filter(
(failure) => !CODES_SHOWN_AS_A_DIALOG.has(failure.code),
)
if (asToast.length === 0) return
if (!(await windowIsWatched())) return
for (const failure of asToast) {
const shown = shownSequencesRef.current.get(failure.code)
if (shown !== undefined && shown >= failure.sequence) continue
shownSequencesRef.current.set(failure.code, failure.sequence)
showNotice.error({ code: failure.code, detail: failure.detail })
}
}, [])
usePendingFailureReader(
useCallback(
(failures) => {
void showNewFailures(failures)
},
[showNewFailures],
),
syncSidecarFailure,
)
void windowIsWatched().then((watched) => {
if (!watched) return
for (const failure of asToast) {
const shown = shownSequencesRef.current.get(failure.code)
if (shown !== undefined && shown >= failure.sequence) continue
shownSequencesRef.current.set(failure.code, failure.sequence)
showNotice.error({ code: failure.code, detail: failure.detail })
}
})
}, [failures])
}
/** Return the oldest undismissed failure owned by the dialog. */
export const useDialogFailure = () => {
const [failure, setFailure] = useState<PendingFailure | null>(null)
// Dismiss locally without retiring the backend failure.
const failures = usePendingFailureList()
const [dismissedSequence, setDismissedSequence] = useState<number | null>(
null,
)
usePendingFailureReader(
useCallback((failures) => {
setFailure(
failures.find((entry) => CODES_SHOWN_AS_A_DIALOG.has(entry.code)) ??
null,
)
}, []),
)
const failure: PendingFailure | null =
failures.find((entry) => CODES_SHOWN_AS_A_DIALOG.has(entry.code)) ?? null
const dismiss = useCallback(() => {
setDismissedSequence(failure?.sequence ?? null)
-1
View File
@@ -1,2 +1 @@
export { hideInitialOverlay } from './initial-loading-overlay'
export { handleNoticeMessage } from './notification-handlers'
+7 -1
View File
@@ -41,6 +41,7 @@ import { useConnectionData } from '@/hooks/use-connection-data'
import { useConnectionSetting } from '@/hooks/use-connection-setting'
import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVisibility } from '@/hooks/use-visibility'
import { mutate } from '@/services/mutate'
import parseTraffic from '@/utils/parse-traffic'
type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[]
@@ -152,7 +153,12 @@ const ConnectionsPage = () => {
[connectionsType, filterConn],
)
const onCloseAll = useLockFn(closeAllConnections)
const onCloseAll = useLockFn(() =>
mutate(() => closeAllConnections(), {
id: 'close-all-connections',
errorNotice: false,
}),
)
const handleSearch = useCallback(
(match: (content: string) => boolean, state: SearchState) => {
+7 -1
View File
@@ -35,6 +35,7 @@ import { ProxyTunCard } from '@/components/home/proxy-tun-card'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge'
import { entry_lightweight_mode } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { openExternalUrl } from '@/utils/open-external-url'
@@ -327,7 +328,12 @@ const HomePage = () => {
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={t('home.page.tooltips.lightweightMode')} arrow>
<IconButton
onClick={async () => await entry_lightweight_mode()}
onClick={async () =>
await mutate(() => entry_lightweight_mode(), {
id: 'entry-lightweight-mode',
errorNotice: false,
})
}
size="small"
color="inherit"
>
+85 -105
View File
@@ -17,7 +17,7 @@ import {
TextSnippetOutlined,
} from '@mui/icons-material'
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
import { TauriEvent } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { readText } from '@tauri-apps/plugin-clipboard-manager'
import { readTextFile } from '@tauri-apps/plugin-fs'
import { useLockFn } from 'ahooks'
@@ -39,7 +39,6 @@ import {
type ProfileViewerRef,
} from '@/components/profile/profile-viewer'
import { ConfigViewer } from '@/components/setting/mods/config-viewer'
import { useListen } from '@/hooks/use-listen'
import { fetchProfilesIntoCache, useProfiles } from '@/hooks/use-profiles'
import {
createProfile,
@@ -50,14 +49,15 @@ import {
reorderProfile,
updateProfile,
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { mutate } from '@/services/mutate'
import { errorDetail, showNotice } from '@/services/notice-service'
import { revalidateQuery, useQuery } from '@/services/query-client'
import { useThemeMode } from '@/services/states'
import {
useLoadingCache,
useSetLoadingCache,
useThemeMode,
} from '@/services/states'
useProfileLoadingCache,
useProfileUpdates,
useSetProfileLoading,
} from '@/store/app-store-context'
import { debugLog } from '@/utils/debug'
import { isValidUrl } from '@/utils/network'
@@ -82,7 +82,6 @@ const debugProfileSwitch = (action: string, profile: string, extra?: any) => {
const ProfilePage = () => {
const { t } = useTranslation()
const location = useLocation()
const { addListener } = useListen()
const [url, setUrl] = useState('')
const [disabled, setDisabled] = useState(false)
const [profileDndRevision, setProfileDndRevision] = useState(0)
@@ -92,12 +91,6 @@ const ProfilePage = () => {
string | null
>(null)
const [loading, setLoading] = useState(false)
const [timerUpdateRevisions, setTimerUpdateRevisions] = useState<
Map<string, number>
>(() => new Map())
const [completedUpdateRevisions, setCompletedUpdateRevisions] = useState<
Map<string, number>
>(() => new Map())
const [batchMode, setBatchMode] = useState(false)
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(
@@ -128,44 +121,42 @@ const ProfilePage = () => {
}, [profiles])
useEffect(() => {
const handleFileDrop = async () => {
const unlisten = await addListener(
TauriEvent.DRAG_DROP,
async (event: any) => {
const paths = event.payload.paths
const unlisten = getCurrentWindow().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') return
const paths = event.payload.paths
for (const file of paths) {
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
showNotice.error('profiles.page.feedback.errors.onlyYaml')
continue
}
const item = {
type: 'local',
name: file.split(/\/|\\/).pop() ?? 'New Profile',
desc: '',
url: '',
option: {
with_proxy: false,
self_proxy: false,
},
} as IProfileItem
const data = await readTextFile(file)
await createProfile(item, data)
await mutateProfiles()
}
await enhanceProfiles()
},
)
return unlisten
}
const unsubscribe = handleFileDrop()
for (const file of paths) {
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
showNotice.error('profiles.page.feedback.errors.onlyYaml')
continue
}
const item = {
type: 'local',
name: file.split(/\/|\\/).pop() ?? 'New Profile',
desc: '',
url: '',
option: {
with_proxy: false,
self_proxy: false,
},
} as IProfileItem
const data = await readTextFile(file)
await mutate(() => createProfile(item, data), {
id: 'create-profile',
errorNotice: false,
revalidate: [['getProfiles']],
})
}
await mutate(() => enhanceProfiles(), {
id: 'enhance-profiles',
errorNotice: false,
})
})
return () => {
unsubscribe.then((cleanup) => cleanup())
void unlisten.then((cleanup) => cleanup())
}
}, [addListener, mutateProfiles])
}, [])
const onEmergencyRefresh = useLockFn(async () => {
debugLog('[紧急刷新] 开始强制刷新所有数据')
@@ -227,7 +218,10 @@ const ProfilePage = () => {
await performRobustRefresh()
}
try {
await importProfile(url)
await mutate(() => importProfile(url), {
id: 'import-profile',
errorNotice: false,
})
await handleImportSuccess('shared.feedback.notifications.importSuccess')
} catch (initialErr) {
console.warn('[订阅导入] 首次导入失败:', initialErr)
@@ -240,10 +234,17 @@ const ProfilePage = () => {
showNotice.info('profiles.page.feedback.notifications.importRetry')
try {
await importProfile(url, {
with_proxy: false,
self_proxy: true,
})
await mutate(
() =>
importProfile(url, {
with_proxy: false,
self_proxy: true,
}),
{
id: 'import-profile',
errorNotice: false,
},
)
await handleImportSuccess(
'shared.feedback.notifications.importWithClashProxy',
)
@@ -324,7 +325,10 @@ const ProfilePage = () => {
if (activeId == null || overId == null || activeId === overId) return
try {
await reorderProfile(activeId, overId)
await mutate(() => reorderProfile(activeId, overId), {
id: 'reorder-profile',
errorNotice: false,
})
mutateProfiles()
} catch (error) {
setProfileDndRevision((revision) => revision + 1)
@@ -355,7 +359,10 @@ const ProfilePage = () => {
if (outcome.status === 'valid') {
currentProfileRef.current = profile
void mutateLogs().catch(() => {})
void closeAllConnections().catch(() => {})
void mutate(() => closeAllConnections(), {
id: 'close-all-connections',
errorNotice: false,
}).catch(() => {})
if (
notifySuccess &&
@@ -481,7 +488,11 @@ const ProfilePage = () => {
setActivatings((prev) => [...new Set([...prev, ...currentProfiles])])
try {
if (!(await enhanceProfiles())) return
const result = await mutate(() => enhanceProfiles(), {
id: 'enhance-profiles',
errorNotice: false,
})
if (!(result.ok && result.value === true)) return
mutateLogs()
if (notifySuccess) {
showNotice.success(
@@ -500,7 +511,10 @@ const ProfilePage = () => {
const current = profiles.current === uid
try {
setActivatings([...(current ? currentActivatings() : []), uid])
await deleteProfile(uid)
await mutate(() => deleteProfile(uid), {
id: 'delete-profile',
errorNotice: false,
})
mutateProfiles()
mutateLogs()
if (current) {
@@ -513,51 +527,10 @@ const ProfilePage = () => {
}
})
const loadingCache = useLoadingCache()
const setLoadingCache = useSetLoadingCache()
const setLoadingProfiles = useCallback(
(uids: string[], loading: boolean) => {
setLoadingCache((cache) => {
const next = new Set(cache)
for (const uid of uids) {
if (loading) {
next.add(uid)
} else {
next.delete(uid)
}
}
return next
})
},
[setLoadingCache],
)
useEffect(
() =>
subscribeVergeEvents({
'profile-update-started': ({ uid }) => {
if (uid) setLoadingProfiles([uid], true)
},
'profile-update-completed': ({ uid }) => {
if (!uid) return
setLoadingProfiles([uid], false)
setCompletedUpdateRevisions((current) => {
const next = new Map(current)
next.set(uid, (next.get(uid) ?? 0) + 1)
return next
})
void mutateProfiles()
},
'verge://timer-updated': (uid) => {
setTimerUpdateRevisions((current) => {
const next = new Map(current)
next.set(uid, (next.get(uid) ?? 0) + 1)
return next
})
},
}),
[mutateProfiles, setLoadingProfiles],
)
const loadingCache = useProfileLoadingCache()
const { updateRevisions: completedUpdateRevisions, timerRevisions } =
useProfileUpdates()
const setLoadingProfiles = useSetProfileLoading()
const runProfileUpdates = useCallback(
async (uids: string[]) => {
@@ -570,7 +543,11 @@ const ProfilePage = () => {
const updateOne = async (uid: string) => {
try {
await updateProfile(uid)
// Per-uid id: the worker pool must not dedupe distinct subscriptions.
await mutate(() => updateProfile(uid), {
id: `update-profile:${uid}`,
errorNotice: false,
})
throttleMutate()
} catch (err: any) {
console.error(`更新订阅 ${uid} 失败:`, err)
@@ -664,7 +641,10 @@ const ProfilePage = () => {
setActivatings((prev) => [...new Set([...prev, ...currentActivating])])
for (const uid of selectedProfiles) {
await deleteProfile(uid)
await mutate(() => deleteProfile(uid), {
id: 'delete-profile',
errorNotice: false,
})
}
await mutateProfiles()
@@ -924,7 +904,7 @@ const ProfilePage = () => {
visibleSwitchingProfile === item.uid
}
itemData={item}
timerUpdateRevision={timerUpdateRevisions.get(item.uid) ?? 0}
timerUpdateRevision={timerRevisions.get(item.uid) ?? 0}
completedUpdateRevision={
completedUpdateRevisions.get(item.uid) ?? 0
}
+9 -2
View File
@@ -16,6 +16,7 @@ import {
patchClashMode,
updateProxyChainConfigInRuntime,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { debugLog } from '@/utils/debug'
@@ -57,7 +58,10 @@ const ProxyPage = () => {
const onChangeMode = useLockFn(async (mode: Mode) => {
try {
// patchClashMode 在后端 PATCH 失败时会 reject,需提示用户而非静默失败
await patchClashMode(mode)
await mutate(() => patchClashMode(mode), {
id: 'patch-clash-mode',
errorNotice: false,
})
refreshClashConfig()
} catch (error) {
showNotice.error(error)
@@ -75,7 +79,10 @@ const ProxyPage = () => {
// 退出链式代理模式时,清除链式代理配置
try {
debugLog('Exiting chain mode, clearing chain configuration')
await updateProxyChainConfigInRuntime(null)
await mutate(() => updateProxyChainConfigInRuntime(null), {
id: 'update-proxy-chain-runtime',
errorNotice: false,
})
debugLog('Chain configuration cleared successfully')
} catch (error) {
console.error('Failed to clear chain configuration:', error)
+10 -15
View File
@@ -19,21 +19,20 @@ import {
alpha,
useTheme,
} from '@mui/material'
import { Channel, invoke } from '@tauri-apps/api/core'
import { Channel } from '@tauri-apps/api/core'
import { useLockFn } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseEmpty, BasePage } from '@/components/base'
import {
checkMediaUnlock,
checkMediaUnlockItem,
getUnlockItems,
type UnlockItem,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
interface UnlockItem {
name: string
status: string
region?: string | null
check_time?: string | null
}
const UNLOCK_RESULTS_STORAGE_KEY = 'clash_verge_unlock_results'
const STATUS_LABEL_KEYS: Record<string, string> = {
@@ -119,7 +118,7 @@ const UnlockPage = () => {
void (async () => {
try {
const defaultItems = await invoke<UnlockItem[]>('get_unlock_items')
const defaultItems = await getUnlockItems()
const existingMap = new Map(
storedItems.map((item) => [normalizeUnlockName(item.name), item]),
)
@@ -153,9 +152,7 @@ const UnlockPage = () => {
try {
setIsCheckingAll(true)
setLoadingItems(unlockItems.map((item) => item.name))
const result = await invoke<UnlockItem[]>('check_media_unlock', {
onComplete,
})
const result = await checkMediaUnlock(onComplete)
const sortedItems = result.sort((a, b) => a.name.localeCompare(b.name))
unlockItemsRef.current = sortedItems
@@ -174,9 +171,7 @@ const UnlockPage = () => {
const checkSingleMedia = async (name: string) => {
setLoadingItems((items) => [...items, name])
try {
const result = await invoke<UnlockItem>('check_media_unlock_item', {
name,
})
const result = await checkMediaUnlockItem(name)
const updatedItems = unlockItemsRef.current.map((item) =>
item.name === name ? result : item,
)
+6 -26
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import {
getBaseConfig,
getRuleProviders,
@@ -6,11 +6,10 @@ import {
} from 'tauri-plugin-mihomo-api'
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
import { runStateQueryKey } from '@/hooks/use-system-state'
import { useVerge } from '@/hooks/use-verge'
import { getProxyView, getRuntimeState, getSystemProxy } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { getProxyView, getSystemProxy } from '@/services/cmds'
import { useQuery } from '@/services/query-client'
import { useRunState } from '@/store/app-store-context'
import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
import {
@@ -95,12 +94,9 @@ export const AppDataProvider = ({
...TQ_DEFAULTS,
})
// Same key as `useSystemState`, so this is the one Run State cache entry, not a second one.
const { data: runState, isPending: isRunningModePending } = useQuery({
queryKey: runStateQueryKey,
queryFn: getRuntimeState,
...TQ_DEFAULTS,
})
// Store-owned run state; the event bus and the store's safety net write it.
const runState = useRunState()
const isRunningModePending = runState == null
const runningMode = runState?.mode
const refreshProxy = useStableFn(_refetchProxyView)
@@ -109,22 +105,6 @@ export const AppDataProvider = ({
const refreshSysproxy = useStableFn(_refetchSysproxy)
const refreshRuleProviders = useStableFn(_refetchRuleProviders)
useEffect(() => {
let lastProxyUpdateTime = 0
const refreshThrottle = 800
const handleRefreshProxy = () => {
const now = Date.now()
if (now - lastProxyUpdateTime <= refreshThrottle) return
lastProxyUpdateTime = now
refreshProxy().catch(() => {})
}
return subscribeVergeEvents({
'verge://refresh-proxy-config': handleRefreshProxy,
})
}, [refreshProxy])
const refreshAll = useCallback(async () => {
await Promise.all([
refreshProxy(),
@@ -7,29 +7,21 @@ import {
takeServiceFallbackNotice,
takeServiceRepairNotice,
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { showNotice } from '@/services/notice-service'
import { revalidateQueries } from '@/services/query-client'
import { requestService } from '@/services/service-request'
import { useLayoutEvents } from './use-layout-events'
let handleNoticeMessage: typeof import('../utils/notification-handlers').handleNoticeMessage
import type { AppStoreAction } from '@/store/app-state'
const nativeWindow = vi.hoisted(() => ({
isVisible: vi.fn().mockResolvedValue(true),
isMinimized: vi.fn().mockResolvedValue(false),
onFocusChanged: vi.fn().mockResolvedValue(() => {}),
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => nativeWindow,
}))
vi.mock('react', () => ({ useEffect: (effect: () => void) => effect() }))
vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() }))
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() }))
vi.mock('@/services/cmds', () => ({
getCoreStartupError: vi.fn().mockResolvedValue(null),
getPendingFailures: vi.fn().mockResolvedValue([]),
takeDiscardedKeysNotice: vi.fn().mockResolvedValue(null),
takeDnsOverrideNotice: vi.fn().mockResolvedValue(false),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(null),
@@ -42,12 +34,32 @@ vi.mock('@/services/notice-service', () => ({
}))
vi.mock('@/services/query-client', () => ({
revalidateQueries: vi.fn(),
setCacheData: vi.fn(),
}))
beforeEach(async () => {
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
// Everything (bus + notice handlers) comes from one fresh module registry per
// test: startup-error dedup state is module-scoped in notice-handlers.
const mountBus = async () => {
vi.resetModules()
;({ handleNoticeMessage } = await import('../utils/notification-handlers'))
const [{ createEventBus }, { handleNoticeMessage: notice }] =
await Promise.all([
import('./create-bus-handlers'),
import('@/services/notice-handlers'),
])
const dispatch = vi.fn<(action: AppStoreAction) => void>()
return createEventBus({
dispatch,
handleNotice: ([status, message]: [string, string]) => {
notice(status, message, (key) => key, vi.fn())
},
revalidateKeys: (keys) => void revalidateQueries(keys.map((key) => [key])),
readPendingFailures: () => {},
readRunState: () => {},
})
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getCoreStartupError).mockResolvedValue(null)
nativeWindow.isVisible.mockResolvedValue(true)
@@ -55,19 +67,22 @@ beforeEach(async () => {
})
it('does not replay a recovered startup error after WebView recreation', async () => {
const first = await import('@/services/notice-handlers')
vi.mocked(getCoreStartupError).mockResolvedValue({
kind: 'startFailed',
detail: 'startup failed',
})
handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
await new Promise((resolve) => setTimeout(resolve, 0))
first.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
await flush()
expect(showNotice.error).toHaveBeenCalledOnce()
// Mocked modules share one instance across resetModules; the notice-handlers
// module state is what a WebView recreation actually resets.
vi.mocked(getCoreStartupError).mockResolvedValue(null)
vi.resetModules()
const recreated = await import('../utils/notification-handlers')
const recreated = await import('@/services/notice-handlers')
recreated.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(showNotice.error).toHaveBeenCalledOnce()
})
@@ -84,20 +99,15 @@ it.each([
kind: 'startFailed',
detail,
})
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers, onSubscribed] =
vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['core_start::error', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
const bus = await mountBus()
bus.onSubscribed()
bus.handlers['verge://notice-message'](['core_start::error', ''])
await flush()
expect(showNotice.error).not.toHaveBeenCalled()
nativeWindow.isVisible.mockResolvedValue(true)
nativeWindow.isMinimized.mockResolvedValue(false)
const onFocus = nativeWindow.onFocusChanged.mock.calls[0][0]
onFocus({ payload: true })
await new Promise((resolve) => setTimeout(resolve, 0))
bus.onWindowFocus()
await flush()
expect(showNotice.error).toHaveBeenCalledExactlyOnceWith(
'settings.feedback.errors.clash.startFailed',
detail,
@@ -111,13 +121,10 @@ it('delivers an early core startup failure once after listeners mount', async ()
kind: 'startFailed',
detail,
})
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['core_start::error', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
const bus = await mountBus()
bus.onSubscribed()
bus.handlers['verge://notice-message'](['core_start::error', ''])
await flush()
expect(showNotice.error).toHaveBeenCalledExactlyOnceWith(
'settings.feedback.errors.clash.startFailed',
detail,
@@ -125,20 +132,16 @@ it('delivers an early core startup failure once after listeners mount', async ()
})
it('shows the same core failure again after the core has started in between', async () => {
const { useLayoutEvents } = await import('./use-layout-events')
const failure = { kind: 'serviceCoreStopped', detail: 'stopped' } as const
vi.mocked(getCoreStartupError).mockResolvedValue(failure)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers] = vi.mocked(subscribeVergeEvents).mock.calls[0]
handlers['verge://notice-message']?.(['core_start::error', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
const bus = await mountBus()
bus.handlers['verge://notice-message'](['core_start::error', ''])
await flush()
// The core started and stopped again before the window handled either event.
handlers['verge://run-state-changed']?.({ mode: 'Service' } as RunState)
handlers['verge://notice-message']?.(['core_start::error', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
bus.handlers['verge://run-state-changed']({ mode: 'Service' } as RunState)
bus.handlers['verge://notice-message'](['core_start::error', ''])
await flush()
expect(showNotice.error).toHaveBeenCalledTimes(2)
})
@@ -147,24 +150,18 @@ it('drains a DNS notice after listeners mount without duplicating its live event
.mockResolvedValueOnce(true)
.mockResolvedValue(false)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const bus = await mountBus()
expect(takeDnsOverrideNotice).not.toHaveBeenCalled()
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
bus.onSubscribed()
expect(takeDnsOverrideNotice).toHaveBeenCalledOnce()
handlers['verge://notice-message']?.(['dns_override::auto_disabled', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
bus.handlers['verge://notice-message'](['dns_override::auto_disabled', ''])
await flush()
expect(showNotice.info).toHaveBeenCalledExactlyOnceWith(
'settings.modals.dns.protection.autoDisabled',
)
expect(revalidateQueries).toHaveBeenCalledWith([
['getRuntimeState'],
['getVergeConfig'],
])
expect(revalidateQueries).toHaveBeenCalledWith([['getVergeConfig']])
})
it('offers service reinstallation for a startup path refusal even before listeners mount', async () => {
@@ -172,14 +169,11 @@ it('offers service reinstallation for a startup path refusal even before listene
.mockResolvedValueOnce(true)
.mockResolvedValue(false)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const bus = await mountBus()
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['service_core::repair_required', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
bus.onSubscribed()
bus.handlers['verge://notice-message'](['service_core::repair_required', ''])
await flush()
expect(requestService).toHaveBeenCalledExactlyOnceWith({
reason: 'serviceLocationRefused',
@@ -193,14 +187,10 @@ it('explains a startup core rejection instead of the generic fallback notice', a
.mockResolvedValueOnce({ kind: 'coreRejected', reason })
.mockResolvedValue(null)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['service_core::sidecar_fallback', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
const bus = await mountBus()
bus.onSubscribed()
bus.handlers['verge://notice-message'](['service_core::sidecar_fallback', ''])
await flush()
expect(showNotice.warning).toHaveBeenCalledExactlyOnceWith(
'settings.feedback.notifications.clashService.permissionFallback',
+120
View File
@@ -0,0 +1,120 @@
import type { VergeEventPayloads } from '@/services/contract'
import { forgetShownStartupError } from '@/services/notice-handlers'
import type { AppStoreAction } from '@/store/app-state'
/** Frontend-internal event: home test card → test items. Not in the backend contract. */
export const TEST_ALL_EVENT = 'verge://test-all'
type BusEventName = keyof VergeEventPayloads | typeof TEST_ALL_EVENT
type BusHandlers = {
[Name in BusEventName]: (
payload: Name extends keyof VergeEventPayloads
? VergeEventPayloads[Name]
: null,
) => void
}
export interface BusHandlerDeps {
dispatch: (action: AppStoreAction) => void
/** Notice-table entry point; layout owns t/navigate wiring. */
handleNotice: (payload: [string, string]) => void
revalidateKeys: (keys: readonly string[]) => void
/** Re-read the backend pending-failure snapshot into the store. */
readPendingFailures: () => void
/** Re-read the run-state snapshot into the store. */
readRunState: () => void
}
/**
* Pure per-event handler table for the bus. Handlers may trigger documented
* side effects (SWR revalidation, notice toasts) but write store state only
* through dispatched pure actions. Created exactly once per bus mount; the
* throttles below live in the returned closure, mirroring the previous
* scattered listeners.
*/
export const createEventBus = (deps: BusHandlerDeps) => {
let lastProfileId: string | null = null
let lastProfileChangeTime = 0
let lastProxyRefreshTime = 0
const refreshThrottle = 800
const handleProfileChanged = (newProfileId: string) => {
const now = Date.now()
if (
lastProfileId === newProfileId &&
now - lastProfileChangeTime < refreshThrottle
) {
return
}
lastProfileId = newProfileId
lastProfileChangeTime = now
deps.revalidateKeys(['getProfiles'])
}
const handleRefreshProxyConfig = () => {
const now = Date.now()
if (now - lastProxyRefreshTime <= refreshThrottle) return
lastProxyRefreshTime = now
deps.revalidateKeys(['getProxyView'])
}
const handlers: BusHandlers = {
'verge://refresh-clash-config': () =>
deps.revalidateKeys([
'getProxyView',
'getVersion',
'getClashConfig',
'getClashInfo',
'getClashMode',
'getRuntimeConfig',
'getRules',
'getRuleProviders',
]),
'verge://refresh-verge-config': () =>
deps.revalidateKeys([
'getVergeConfig',
'getSystemProxy',
'getAutotemProxy',
]),
'verge://refresh-profiles': () => deps.revalidateKeys(['getProfiles']),
'verge://refresh-proxy-config': handleRefreshProxyConfig,
'verge://notice-message': (payload) => deps.handleNotice(payload),
'profile-changed': handleProfileChanged,
'verge://timer-updated': (uid) =>
deps.dispatch({ type: 'profileUpdate/timerTick', uid }),
'profile-update-started': ({ uid }) =>
deps.dispatch({ type: 'profileUpdate/started', uid }),
'profile-update-completed': ({ uid }) => {
deps.dispatch({ type: 'profileUpdate/completed', uid })
deps.revalidateKeys(['getProfiles'])
},
'verge://run-state-changed': (payload) => {
deps.dispatch({ type: 'runState/loaded', runState: payload })
// A running core resolves every failure shown before it.
if (payload.mode !== 'NotRunning') forgetShownStartupError()
},
'verge://pending-failures-changed': () => deps.readPendingFailures(),
[TEST_ALL_EVENT]: () => deps.dispatch({ type: 'testAll/requested' }),
}
const onSubscribed = () => {
// Run state is read after subscribing so an event racing the store's
// initial read cannot leave a stale snapshot behind.
deps.readRunState()
deps.revalidateKeys(['getVergeConfig'])
deps.readPendingFailures()
deps.handleNotice(['dns_override::auto_disabled', ''])
deps.handleNotice(['enhance::discarded_keys', ''])
deps.handleNotice(['service_core::sidecar_fallback', ''])
deps.handleNotice(['service_core::repair_required', ''])
deps.handleNotice(['service_core::app_data_not_owned', ''])
deps.handleNotice(['core_start::error', ''])
}
const onWindowFocus = () => {
deps.handleNotice(['core_start::error', ''])
}
return { handlers, onSubscribed, onWindowFocus }
}
+10
View File
@@ -0,0 +1,10 @@
import { emit } from '@tauri-apps/api/event'
import { TEST_ALL_EVENT } from './create-bus-handlers'
export { useEventBus } from './use-event-bus'
/** The only emitter of the frontend-internal test-all event. */
export const emitTestAll = () => {
void emit(TEST_ALL_EVENT)
}
+76
View File
@@ -0,0 +1,76 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createStoreReads } from '@/store/store-reads'
import { useEventBus } from './use-event-bus'
const listen = vi.hoisted(() =>
vi.fn<
(name: string, handler: (event: unknown) => void) => Promise<() => void>
>(() => Promise.resolve(() => {})),
)
const handleNotice = vi.hoisted(() => vi.fn())
const dispatch = vi.hoisted(() => vi.fn())
vi.mock('@tauri-apps/api/event', () => ({ listen }))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({
onFocusChanged: vi.fn(() => Promise.resolve(() => {})),
}),
}))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
useEffect: (effect: () => void | (() => void)) => effect(),
}
})
vi.mock('@/store/app-store-context', () => ({
useAppDispatch: () => dispatch,
useAppReads: () => createStoreReads(dispatch),
}))
vi.mock('@/services/cmds', () => ({
getPendingFailures: vi.fn().mockResolvedValue([]),
getSidecarFailure: vi.fn().mockResolvedValue({ revision: 0, detail: null }),
getRuntimeState: vi.fn().mockResolvedValue({ mode: 'NotRunning' }),
}))
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
beforeEach(() => {
listen.mockClear()
handleNotice.mockClear()
dispatch.mockClear()
listen.mockImplementation(() => Promise.resolve(() => {}))
})
it('drains event-only state only after every listener is registered', async () => {
const registrations: Array<() => void> = []
listen.mockImplementation(
() =>
new Promise((resolve) => {
registrations.push(() => resolve(() => {}))
}),
)
useEventBus(handleNotice)
await flush()
expect(handleNotice).not.toHaveBeenCalled()
for (const resolve of registrations.slice(0, -1)) resolve()
await flush()
expect(handleNotice).not.toHaveBeenCalled()
registrations.at(-1)?.()
await flush()
expect(handleNotice).toHaveBeenCalled()
expect(dispatch).toHaveBeenCalledWith({
type: 'pendingFailures/loaded',
failures: [],
})
expect(dispatch).toHaveBeenCalledWith({
type: 'sidecarFailure/loaded',
snapshot: { revision: 0, detail: null },
})
})
+70
View File
@@ -0,0 +1,70 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useEffect } from 'react'
import { revalidateQueries } from '@/services/query-client'
import { useAppDispatch, useAppReads } from '@/store/app-store-context'
import { createEventBus } from './create-bus-handlers'
/**
* Mounts the event bus exactly once (layout root): one `listen` per contract
* event plus the frontend-internal test-all event. Replaces every scattered
* subscription; teardown disposes late-resolving registrations.
*/
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 } = reads
const readRunState = () => {
void reads.readRunState().catch(() => {})
}
const bus = createEventBus({
dispatch,
handleNotice,
revalidateKeys,
readPendingFailures,
readRunState,
})
let disposed = false
const unlisteners: UnlistenFn[] = []
const registrations = (
Object.keys(bus.handlers) as Array<keyof typeof bus.handlers>
).map((name) =>
listen(name, ({ payload }) => {
;(bus.handlers[name] as (payload: unknown) => void)(payload)
}).then((unlisten) => {
if (disposed) {
unlisten()
return
}
unlisteners.push(unlisten)
}),
)
// Re-read event-only state after subscribing to close the initial race window.
void Promise.all(registrations).then(() => {
if (disposed) return
bus.onSubscribed()
})
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
if (payload) bus.onWindowFocus()
})
return () => {
disposed = true
for (const unlisten of unlisteners.splice(0)) unlisten()
void unlistenFocus.then((unlisten) => unlisten())
}
}, [dispatch, handleNotice, reads])
}
+35
View File
@@ -183,6 +183,22 @@ export async function setDnsOverride(
>('set_dns_override', { profileUid, enabled, confirmation })
}
export async function getDnsConfigContent() {
return invoke<string | null>('get_dns_config_content')
}
export async function saveDnsConfig(dnsConfig: Record<string, unknown>) {
return invoke<void>('save_dns_config', { dnsConfig })
}
export async function validateDnsConfig() {
return invoke<ValidationOutcome>('validate_dns_config')
}
export async function applyDnsConfig(apply: boolean) {
return invoke<void>('apply_dns_config', { apply })
}
export async function takeDnsOverrideNotice() {
return invoke<boolean>('take_dns_override_notice')
}
@@ -568,3 +584,22 @@ export const probeListener = async (request: ListenerProbe) => {
export const saveProxyPorts = async (settings: ProxyPortSettings) => {
return invoke<SaveProxyPortsOutcome>('save_proxy_ports', { settings })
}
export interface UnlockItem {
name: string
status: string
region?: string | null
check_time?: string | null
}
export async function getUnlockItems() {
return invoke<UnlockItem[]>('get_unlock_items')
}
export async function checkMediaUnlock(onComplete: Channel<UnlockItem>) {
return invoke<UnlockItem[]>('check_media_unlock', { onComplete })
}
export async function checkMediaUnlockItem(name: string) {
return invoke<UnlockItem>('check_media_unlock_item', { name })
}
+56
View File
@@ -0,0 +1,56 @@
// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.
// Regen: pnpm gen:contract
import type { RunState } from './cmds'
/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */
export type NoticeStatus =
| 'info'
| 'import_sub_url::ok'
| 'import_sub_url::error'
| 'set_config::error'
| 'core_start::error'
| 'service_core::repair_required'
| 'service_core::app_data_not_owned'
| 'service_core::sidecar_fallback'
| 'dns_override::auto_disabled'
| 'enhance::discarded_keys'
| 'tun_mode::auto_disabled'
| 'tun_mode::auto_disable_failed'
| 'app_restart::core_stop_failed'
| 'app_quit::core_stop_failed'
| 'update_with_clash_proxy'
| 'update_failed_even_with_clash'
| 'reactivate_profiles::error'
| 'update_failed'
| 'config_validate::boot_error'
| 'config_validate::error'
| 'config_validate::process_terminated'
| 'config_validate::script_error'
| 'config_validate::script_syntax_error'
| 'config_validate::script_missing_main'
| 'config_validate::file_not_found'
| 'config_validate::yaml_syntax_error'
| 'config_validate::yaml_read_error'
| 'config_validate::yaml_mapping_error'
| 'config_validate::merge_syntax_error'
| 'config_validate::merge_mapping_error'
| 'config_core::change_success'
| 'config_core::change_error'
| 'mixed_port::fallback'
| 'mixed_port::fallback_error'
/** Event name → payload type for every backend-emitted event. */
export interface VergeEventPayloads {
'verge://refresh-clash-config': string
'verge://refresh-verge-config': string
'verge://refresh-profiles': string
'verge://refresh-proxy-config': null
'verge://notice-message': [NoticeStatus, string]
'verge://timer-updated': string
'verge://run-state-changed': RunState
'verge://pending-failures-changed': null
'profile-changed': string
'profile-update-started': { uid: string }
'profile-update-completed': { uid: string }
}
-58
View File
@@ -1,58 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { subscribeVergeEvents } from './events'
const listen = vi.hoisted(() => vi.fn())
vi.mock('@tauri-apps/api/event', () => ({ listen }))
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
beforeEach(() => {
listen.mockReset()
})
describe('subscribeVergeEvents', () => {
it('reports readiness only after every listener is registered', async () => {
const registrations: Array<() => void> = []
listen.mockImplementation(
() =>
new Promise((resolve) => {
registrations.push(() => resolve(() => {}))
}),
)
const onSubscribed = vi.fn()
subscribeVergeEvents(
{
'verge://run-state-changed': () => {},
'verge://notice-message': () => {},
},
onSubscribed,
)
registrations[0]?.()
await flush()
expect(onSubscribed).not.toHaveBeenCalled()
registrations[1]?.()
await flush()
expect(onSubscribed).toHaveBeenCalledOnce()
})
it('delivers a payload to its matching handler', () => {
const handlers = new Map<string, (payload: unknown) => void>()
listen.mockImplementation(
(name: string, handler: (event: unknown) => void) => {
handlers.set(name, (payload) => handler({ payload }))
return Promise.resolve(() => {})
},
)
const onRunState = vi.fn()
subscribeVergeEvents({ 'verge://run-state-changed': onRunState })
handlers.get('verge://run-state-changed')?.({ mode: 'Sidecar' })
expect(onRunState).toHaveBeenCalledWith({ mode: 'Sidecar' })
})
})
-75
View File
@@ -1,75 +0,0 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { RunState } from './cmds'
/** Centralizes frontend event names and payload types for the unchecked Rust IPC seam. */
interface VergeEvents {
'verge://refresh-clash-config': string
'verge://refresh-verge-config': string
'verge://refresh-profiles': string
'verge://refresh-proxy-config': null
/** A backend message for the user: `[status, message]`. */
'verge://notice-message': [string, string]
'verge://timer-updated': string
'verge://run-state-changed': RunState
'verge://pending-failures-changed': null
'profile-changed': string
'profile-update-started': { uid?: string }
'profile-update-completed': { uid?: string }
'verge://test-all': null
}
type VergeEventName = keyof VergeEvents
type VergeEventHandlers = {
[Name in VergeEventName]?: (payload: VergeEvents[Name]) => void
}
/**
* Returns synchronous teardown for asynchronous registrations. `onSubscribed` lets callers
* reread event-only state after all listeners are live, closing the initial race window.
*/
export const subscribeVergeEvents = (
handlers: VergeEventHandlers,
onSubscribed?: () => void,
): (() => void) => {
let disposed = false
const unlisteners: UnlistenFn[] = []
const registrations = Object.entries(handlers).map(([name, handler]) => {
if (!handler) return Promise.resolve()
return listen(name, ({ payload }) => {
;(handler as (payload: unknown) => void)(payload)
})
.then((unlisten) => {
// Resolved after teardown: unsubscribe immediately rather than leak.
if (disposed) {
unlisten()
return
}
unlisteners.push(unlisten)
})
.catch((error) => {
console.error(`[events] failed to subscribe to ${name}:`, error)
})
})
if (onSubscribed) {
void Promise.all(registrations).then(() => {
if (disposed) return
onSubscribed()
})
}
return () => {
disposed = true
for (const unlisten of unlisteners.splice(0)) {
try {
unlisten()
} catch (error) {
console.error('[events] teardown failed:', error)
}
}
}
}
+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()
})
+64
View File
@@ -0,0 +1,64 @@
import { showNotice } from '@/services/notice-service'
import { revalidateQueries } from '@/services/query-client'
/** Anything a notice call accepts: translation key, ReactNode, or failure payload. */
type MutateNoticeInput = Parameters<typeof showNotice.error>[0]
export interface MutateOptions<T> {
/** Commands with the same identity execute in request order. */
id: string
/** 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
/** `false` suppresses the default error toast. */
errorNotice?: false
}
export type MutateResult<T> = { ok: true; value: T } | { ok: false; value: T }
/** ValidationOutcome-shaped results report backend-side Busy rejection this way. */
const isBusyOutcome = (value: unknown): boolean =>
typeof value === 'object' &&
value !== null &&
(value as { status?: unknown }).status === 'busy'
const inFlight = new Map<string, Promise<void>>()
export async function mutate<T>(
invoke: () => Promise<T>,
options: MutateOptions<T>,
): Promise<MutateResult<T>> {
const { id } = options
const previous = inFlight.get(id)
let release!: () => void
const settled = new Promise<void>((resolve) => {
release = resolve
})
inFlight.set(id, settled)
if (previous) await previous
try {
const value = await invoke()
if (isBusyOutcome(value)) {
return { ok: false, value }
}
options.onFulfilled?.(value)
if (options.revalidate?.length) {
await revalidateQueries([...options.revalidate])
}
if (options.successNotice !== undefined) {
showNotice.success(options.successNotice)
}
return { ok: true, value }
} catch (error) {
if (options.errorNotice !== false) {
showNotice.error(error)
}
throw error
} finally {
release()
if (inFlight.get(id) === settled) inFlight.delete(id)
}
}
@@ -8,6 +8,7 @@ import {
takeServiceOwnerNotice,
takeServiceRepairNotice,
} from '@/services/cmds'
import type { NoticeStatus } from '@/services/contract'
import { hideNotice, showNotice } from '@/services/notice-service'
import { requestService } from '@/services/service-request'
@@ -26,13 +27,21 @@ export const forgetShownStartupError = () => {
settledStartupErrorRead = ++startupErrorReads
}
/**
* Exhaustive notice table: one entry per contract `NoticeStatus`. The Record
* type makes a backend status without a frontend entry a compile error (and
* contract regeneration keeps the union in sync).
*/
export const handleNoticeMessage = (
status: string,
msg: string,
t: TranslateFunction,
navigate: NavigateFunction,
) => {
const handlers: Record<string, () => void> = {
const handlers: Record<NoticeStatus, () => void> = {
info: () => {
if (msg) showNotice.info(msg)
},
'import_sub_url::ok': () => {
// 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
// 未来细分事件通知时,可以考虑传入订阅 ID 或其他标识符
@@ -198,17 +207,10 @@ export const handleNoticeMessage = (
update_failed: () => showNotice.error(msg),
'config_validate::boot_error': () =>
showNotice.error('shared.feedback.validation.config.bootFailed', msg),
'config_validate::core_change': () =>
showNotice.error(
'shared.feedback.validation.config.coreChangeFailed',
msg,
),
'config_validate::error': () =>
showNotice.error('shared.feedback.validation.config.failed', msg),
'config_validate::process_terminated': () =>
showNotice.error('shared.feedback.validation.config.processTerminated'),
'config_validate::stdout_error': () =>
showNotice.error('shared.feedback.validation.config.failed', msg),
'config_validate::script_error': () =>
showNotice.error('shared.feedback.validation.script.fileError', msg),
'config_validate::script_syntax_error': () =>
@@ -223,18 +225,10 @@ export const handleNoticeMessage = (
showNotice.error('shared.feedback.validation.yaml.readError', msg),
'config_validate::yaml_mapping_error': () =>
showNotice.error('shared.feedback.validation.yaml.mappingError', msg),
'config_validate::yaml_key_error': () =>
showNotice.error('shared.feedback.validation.yaml.keyError', msg),
'config_validate::yaml_error': () =>
showNotice.error('shared.feedback.validation.yaml.generalError', msg),
'config_validate::merge_syntax_error': () =>
showNotice.error('shared.feedback.validation.merge.syntaxError', msg),
'config_validate::merge_mapping_error': () =>
showNotice.error('shared.feedback.validation.merge.mappingError', msg),
'config_validate::merge_key_error': () =>
showNotice.error('shared.feedback.validation.merge.keyError', msg),
'config_validate::merge_error': () =>
showNotice.error('shared.feedback.validation.merge.generalError', msg),
'config_core::change_success': () =>
showNotice.success(
'settings.feedback.notifications.clash.changeSuccess',
@@ -259,7 +253,7 @@ export const handleNoticeMessage = (
),
}
const handler = handlers[status]
const handler = handlers[status as NoticeStatus]
if (handler) {
handler()
} else {
-7
View File
@@ -4,10 +4,6 @@ const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState<
'light' | 'dark'
>()
// save the state of each profile item loading
const [LoadingCacheProvider, useLoadingCache, useSetLoadingCache] =
createContextState<Set<string>>(new Set())
// save update state
const [UpdateStateProvider, useUpdateState, useSetUpdateState] =
createContextState<boolean>(false)
@@ -16,9 +12,6 @@ export {
ThemeModeProvider,
useThemeMode,
useSetThemeMode,
LoadingCacheProvider,
useLoadingCache,
useSetLoadingCache,
UpdateStateProvider,
useUpdateState,
useSetUpdateState,
+124
View File
@@ -0,0 +1,124 @@
import type {
PendingFailure,
RunState,
SidecarFailureSnapshot,
} from '@/services/cmds'
/** Event-derived per-profile update tracking, shared by the profiles page and items. */
export interface ProfileUpdatesState {
readonly loading: ReadonlySet<string>
readonly updateRevisions: ReadonlyMap<string, number>
readonly timerRevisions: ReadonlyMap<string, number>
}
export interface AppStoreState {
/** Written by `run-state-changed` events and the store's safety-net poll. */
readonly runState: RunState | null
/** Latest backend pending-failure snapshot; toasts/dialogs dedupe on sequence. */
readonly pendingFailures: readonly PendingFailure[]
readonly sidecarFailure: SidecarFailureSnapshot | null
readonly profileUpdates: ProfileUpdatesState
/** Bumped once per test-all request; test items run their delay test per bump. */
readonly testAllCounter: number
}
export const initialAppStoreState: AppStoreState = {
runState: null,
pendingFailures: [],
sidecarFailure: null,
profileUpdates: {
loading: new Set(),
updateRevisions: new Map(),
timerRevisions: new Map(),
},
testAllCounter: 0,
}
export type AppStoreAction =
| { type: 'runState/loaded'; runState: RunState }
| { type: 'pendingFailures/loaded'; failures: readonly PendingFailure[] }
| { type: 'sidecarFailure/loaded'; snapshot: SidecarFailureSnapshot }
| { type: 'profileUpdate/started'; uid: string }
| { type: 'profileUpdate/completed'; uid: string }
| { type: 'profileUpdate/timerTick'; uid: string }
| { type: 'profileLoading/set'; uids: readonly string[]; loading: boolean }
| { type: 'testAll/requested' }
const bumpRevision = (
map: ReadonlyMap<string, number>,
uid: string,
): ReadonlyMap<string, number> => {
const next = new Map(map)
next.set(uid, (next.get(uid) ?? 0) + 1)
return next
}
const withLoading = (
state: AppStoreState,
uids: readonly string[],
loading: boolean,
): AppStoreState => {
const next = new Set(state.profileUpdates.loading)
for (const uid of uids) {
if (loading) {
next.add(uid)
} else {
next.delete(uid)
}
}
return {
...state,
profileUpdates: { ...state.profileUpdates, loading: next },
}
}
export function appStoreReducer(
state: AppStoreState,
action: AppStoreAction,
): AppStoreState {
switch (action.type) {
case 'runState/loaded':
return { ...state, runState: action.runState }
case 'pendingFailures/loaded':
return { ...state, pendingFailures: action.failures }
case 'sidecarFailure/loaded':
if (
state.sidecarFailure &&
action.snapshot.revision <= state.sidecarFailure.revision
) {
return state
}
return { ...state, sidecarFailure: action.snapshot }
case 'profileUpdate/started':
if (state.profileUpdates.loading.has(action.uid)) return state
return withLoading(state, [action.uid], true)
case 'profileUpdate/completed': {
const cleared = withLoading(state, [action.uid], false)
return {
...cleared,
profileUpdates: {
...cleared.profileUpdates,
updateRevisions: bumpRevision(
state.profileUpdates.updateRevisions,
action.uid,
),
},
}
}
case 'profileUpdate/timerTick':
return {
...state,
profileUpdates: {
...state.profileUpdates,
timerRevisions: bumpRevision(
state.profileUpdates.timerRevisions,
action.uid,
),
},
}
case 'profileLoading/set':
return withLoading(state, action.uids, action.loading)
case 'testAll/requested':
return { ...state, testAllCounter: state.testAllCounter + 1 }
}
}
+60
View File
@@ -0,0 +1,60 @@
import { createContext, useCallback, use } from 'react'
import type {
AppStoreAction,
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 => {
const state = use(AppStateContext)
if (!state) throw new Error('app store is missing its provider')
return select(state)
}
export const useAppDispatch = (): AppStoreActionDispatcher => {
const dispatch = use(AppDispatchContext)
if (!dispatch) throw new Error('app store is missing its provider')
return dispatch
}
export const useRunState = () => useSelector((s) => s.runState)
export const usePendingFailureList = () => useSelector((s) => s.pendingFailures)
export const useSidecarFailure = () => useSelector((s) => s.sidecarFailure)
export const useProfileUpdates = (): ProfileUpdatesState =>
useSelector((s) => s.profileUpdates)
export const useProfileLoadingCache = (): ReadonlySet<string> =>
useSelector((s) => s.profileUpdates.loading)
/** Mark profiles as updating outside backend events (update-all, per-item buttons). */
export const useSetProfileLoading = () => {
const dispatch = useAppDispatch()
return useCallback(
(uids: readonly string[], loading: boolean) =>
dispatch({ type: 'profileLoading/set', uids, loading }),
[dispatch],
)
}
export const useTestAllCounter = () => useSelector((s) => s.testAllCounter)
+76
View File
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useMemo, useReducer } from 'react'
import type { ReactNode } from 'react'
import {
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
* handlers write through pure reducer actions; the effect below is the store's
* own data seeding (initial read plus the poll/focus safety net the old
* run-state query provided).
*/
export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
const [state, dispatch] = useReducer(appStoreReducer, initialAppStoreState)
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 = () => {
void reads.readRunState().catch(() => {})
}
const { readPendingFailures } = reads
readRunState()
readPendingFailures()
// Transitions normally arrive by event; the visible poll is the run-state
// safety net, and regaining focus re-reads both snapshots.
const pollRunState = () => {
if (document.visibilityState !== 'visible') return
readRunState()
}
const readWhenFocused = () => {
if (document.visibilityState !== 'visible') return
readRunState()
readPendingFailures()
}
window.addEventListener('focus', readWhenFocused)
document.addEventListener('visibilitychange', readWhenFocused)
const timer = window.setInterval(pollRunState, 30000)
return () => {
window.removeEventListener('focus', readWhenFocused)
document.removeEventListener('visibilitychange', readWhenFocused)
window.clearInterval(timer)
reads.invalidate()
}
}, [reads])
return (
<AppStateContext value={state}>
<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
},
}
}
-5
View File
@@ -905,7 +905,6 @@ export const translationKeys = [
'shared.feedback.notifications.common.refreshFailed',
'shared.feedback.validation.config.failed',
'shared.feedback.validation.config.bootFailed',
'shared.feedback.validation.config.coreChangeFailed',
'shared.feedback.validation.config.processTerminated',
'shared.feedback.validation.script.syntaxError',
'shared.feedback.validation.script.missingMain',
@@ -914,12 +913,8 @@ export const translationKeys = [
'shared.feedback.validation.yaml.syntaxError',
'shared.feedback.validation.yaml.readError',
'shared.feedback.validation.yaml.mappingError',
'shared.feedback.validation.yaml.keyError',
'shared.feedback.validation.yaml.generalError',
'shared.feedback.validation.merge.syntaxError',
'shared.feedback.validation.merge.mappingError',
'shared.feedback.validation.merge.keyError',
'shared.feedback.validation.merge.generalError',
'shared.filters.logLevels.all',
'shared.filters.logLevels.debug',
'shared.filters.logLevels.info',
-5
View File
@@ -1469,13 +1469,10 @@ export interface TranslationResources {
validation: {
config: {
bootFailed: string
coreChangeFailed: string
failed: string
processTerminated: string
}
merge: {
generalError: string
keyError: string
mappingError: string
syntaxError: string
}
@@ -1486,8 +1483,6 @@ export interface TranslationResources {
syntaxError: string
}
yaml: {
generalError: string
keyError: string
mappingError: string
readError: string
syntaxError: string