diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 150d6bc8c..113e733c3 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -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 diff --git a/knip.json b/knip.json index 99ea67020..9be0c22de 100644 --- a/knip.json +++ b/knip.json @@ -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 diff --git a/package.json b/package.json index 625876342..79c33d632 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs new file mode 100644 index 000000000..429f413a6 --- /dev/null +++ b/scripts/check-architecture.mjs @@ -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: [/(? file.startsWith('src/services/bus/'), + description: 'Tauri listen outside the event bus (src/services/bus)', + }, + emit: { + patterns: [/(? 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 ... (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') diff --git a/scripts/gen-contract.mjs b/scripts/gen-contract.mjs new file mode 100644 index 000000000..c2afba095 --- /dev/null +++ b/scripts/gen-contract.mjs @@ -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`, +) diff --git a/src-tauri/src/bin/frontend-contract.rs b/src-tauri/src/bin/frontend-contract.rs new file mode 100644 index 000000000..2ce0ecede --- /dev/null +++ b/src-tauri/src/bin/frontend-contract.rs @@ -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()); +} diff --git a/src-tauri/src/core/notify/mod.rs b/src-tauri/src/core/notify/mod.rs index 55d3a43c3..1387dbc81 100644 --- a/src-tauri/src/core/notify/mod.rs +++ b/src-tauri/src/core/notify/mod.rs @@ -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() diff --git a/src-tauri/src/core/notify/notification.rs b/src-tauri/src/core/notify/notification.rs index 97d2409de..a1ba571dd 100644 --- a/src-tauri/src/core/notify/notification.rs +++ b/src-tauri/src/core/notify/notification.rs @@ -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 }) +} diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 1b261e1cb..057f493c0 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -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:#}")); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c1517882..ff276b8e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,10 @@ use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_deep_link::DeepLinkExt as _; pub static APP_HANDLE: OnceCell = 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::*; diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 000000000..51a3b125d --- /dev/null +++ b/src/AGENTS.md @@ -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. diff --git a/src/components/home/clash-mode-card.tsx b/src/components/home/clash-mode-card.tsx index 0ecaa1c7e..bbd942949 100644 --- a/src/components/home/clash-mode-card.tsx +++ b/src/components/home/clash-mode-card.tsx @@ -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) diff --git a/src/components/home/home-profile-card.tsx b/src/components/home/home-profile-card.tsx index 5b196fb4c..ad9b4bb82 100644 --- a/src/components/home/home-profile-card.tsx +++ b/src/components/home/home-profile-card.tsx @@ -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?.() // 刷新首页数据 diff --git a/src/components/home/test-card.tsx b/src/components/home/test-card.tsx index 571c648ed..5081c477a 100644 --- a/src/components/home/test-card.tsx +++ b/src/components/home/test-card.tsx @@ -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(() => { diff --git a/src/components/layout/service-migration-dialog.test.ts b/src/components/layout/service-migration-dialog.test.ts index 752fbb069..8c292864d 100644 --- a/src/components/layout/service-migration-dialog.test.ts +++ b/src/components/layout/service-migration-dialog.test.ts @@ -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[])( '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) + 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[])( '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) }, ) diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index 5af87cba9..756ceff24 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -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(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 } diff --git a/src/components/layout/sysproxy-privilege-dialog.tsx b/src/components/layout/sysproxy-privilege-dialog.tsx index c52316662..fb493157c 100644 --- a/src/components/layout/sysproxy-privilege-dialog.tsx +++ b/src/components/layout/sysproxy-privilege-dialog.tsx @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next' import { BaseDialog } from '@/components/base' import { useDialogFailure } from '@/pages/_layout/hooks' import { - getRuntimeState, installService, patchVergeConfig, reinstallService, @@ -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( diff --git a/src/components/profile/groups-editor-viewer.tsx b/src/components/profile/groups-editor-viewer.tsx index 66fc3d836..ddacd7971 100644 --- a/src/components/profile/groups-editor-viewer.tsx +++ b/src/components/profile/groups-editor-viewer.tsx @@ -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 diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 6c0a0a3cc..ed3c430ca 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -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(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 } diff --git a/src/components/profile/profile-more.tsx b/src/components/profile/profile-more.tsx index dc5ce8799..eb4934067 100644 --- a/src/components/profile/profile-more.tsx +++ b/src/components/profile/profile-more.tsx @@ -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 } diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 1a75e725f..e40b9f4d5 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -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( diff --git a/src/components/profile/proxies-editor-viewer.tsx b/src/components/profile/proxies-editor-viewer.tsx index 9d3666752..23fc0398e 100644 --- a/src/components/profile/proxies-editor-viewer.tsx +++ b/src/components/profile/proxies-editor-viewer.tsx @@ -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 diff --git a/src/components/profile/rules-editor-viewer.tsx b/src/components/profile/rules-editor-viewer.tsx index 0c4756474..540981203 100644 --- a/src/components/profile/rules-editor-viewer.tsx +++ b/src/components/profile/rules-editor-viewer.tsx @@ -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 diff --git a/src/components/proxy/provider-button.tsx b/src/components/proxy/provider-button.tsx index f622f8a43..8c86ae8c2 100644 --- a/src/components/proxy/provider-button.tsx +++ b/src/components/proxy/provider-button.tsx @@ -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) { diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index 878453bd1..69e718486 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -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 = ({ { - 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') diff --git a/src/components/proxy/proxy-empty-state.tsx b/src/components/proxy/proxy-empty-state.tsx index 03b1b663e..e4ca2dbaf 100644 --- a/src/components/proxy/proxy-empty-state.tsx +++ b/src/components/proxy/proxy-empty-state.tsx @@ -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) } diff --git a/src/components/proxy/proxy-groups-chain.tsx b/src/components/proxy/proxy-groups-chain.tsx index f60c0447d..a52435091 100644 --- a/src/components/proxy/proxy-groups-chain.tsx +++ b/src/components/proxy/proxy-groups-chain.tsx @@ -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') diff --git a/src/components/rule/provider-button.tsx b/src/components/rule/provider-button.tsx index ad11cc464..c394e6569 100644 --- a/src/components/rule/provider-button.tsx +++ b/src/components/rule/provider-button.tsx @@ -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) { diff --git a/src/components/setting/mods/backup-config-viewer.tsx b/src/components/setting/mods/backup-config-viewer.tsx index babc1b122..0dbce6954 100644 --- a/src/components/setting/mods/backup-config-viewer.tsx +++ b/src/components/setting/mods/backup-config-viewer.tsx @@ -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() }) diff --git a/src/components/setting/mods/backup-history-viewer.tsx b/src/components/setting/mods/backup-history-viewer.tsx index 2a0d97628..6147ab4be 100644 --- a/src/components/setting/mods/backup-history-viewer.tsx +++ b/src/components/setting/mods/backup-history-viewer.tsx @@ -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', diff --git a/src/components/setting/mods/backup-viewer.tsx b/src/components/setting/mods/backup-viewer.tsx index 8261723ef..bad2790af 100644 --- a/src/components/setting/mods/backup-viewer.tsx +++ b/src/components/setting/mods/backup-viewer.tsx @@ -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 }) { 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 }) { 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) diff --git a/src/components/setting/mods/clash-core-viewer.tsx b/src/components/setting/mods/clash-core-viewer.tsx index 0fe761763..0b365b9f1 100644 --- a/src/components/setting/mods/clash-core-viewer.tsx +++ b/src/components/setting/mods/clash-core-viewer.tsx @@ -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 }) { 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 }) { 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) } diff --git a/src/components/setting/mods/clash-port-viewer.tsx b/src/components/setting/mods/clash-port-viewer.tsx index e0ffbf6f7..e0d6ef109 100644 --- a/src/components/setting/mods/clash-port-viewer.tsx +++ b/src/components/setting/mods/clash-port-viewer.tsx @@ -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((_, 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: () => { diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 2ecdf5c46..15493da24 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -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 }) { const initDnsConfig = useCallback(async () => { try { - const dnsConfig = await invoke( - '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 }) { config = parsedConfig as Record } - await invoke('save_dns_config', { dnsConfig: config }) + await mutate(() => saveDnsConfig(config), { + id: 'save-dns-config', + errorNotice: false, + }) - const validation = await invoke( - 'validate_dns_config', - {}, - ) + const validation = await validateDnsConfig() if (validation.status !== 'valid') { const errorMsg = @@ -559,7 +562,10 @@ export function DnsViewer({ ref }: { ref?: Ref }) { } if (dnsEnabled) { - await invoke('apply_dns_config', { apply: true }) + await mutate(() => applyDnsConfig(true), { + id: 'apply-dns-config', + errorNotice: false, + }) mutateClash() } diff --git a/src/components/setting/mods/external-controller-cors.tsx b/src/components/setting/mods/external-controller-cors.tsx index 7efddc1aa..eb37a8bfc 100644 --- a/src/components/setting/mods/external-controller-cors.tsx +++ b/src/components/setting/mods/external-controller-cors.tsx @@ -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( ), }, }) - await restartCore() + await mutate(() => restartCore(), { + id: 'restart-core', + errorNotice: false, + }) await mutateClash() }, { diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index df13cc45c..f818d61de 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -22,6 +22,7 @@ import { DEFAULT_HOVER_DELAY } from '@/components/proxy/proxy-group-navigator' import { useVerge } from '@/hooks/use-verge' import { useWindowDecorations } from '@/hooks/use-window' import { copyIconFile, getAppDir } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import getSystem from '@/utils/get-system' @@ -536,7 +537,9 @@ export const LayoutViewer = forwardRef((_, ref) => { }) if (selected) { - await copyIconFile(`${selected}`, 'common') + await mutate(() => copyIconFile(`${selected}`, 'common'), { + id: 'copy-icon:common', + }) await initIconPath() onChangeData({ common_tray_icon: true }) patchVerge({ common_tray_icon: true }) @@ -588,7 +591,10 @@ export const LayoutViewer = forwardRef((_, ref) => { ], }) if (selected) { - await copyIconFile(`${selected}`, 'sysproxy') + await mutate( + () => copyIconFile(`${selected}`, 'sysproxy'), + { id: 'copy-icon:sysproxy' }, + ) await initIconPath() onChangeData({ sysproxy_tray_icon: true }) patchVerge({ sysproxy_tray_icon: true }) @@ -636,7 +642,9 @@ export const LayoutViewer = forwardRef((_, ref) => { ], }) if (selected) { - await copyIconFile(`${selected}`, 'tun') + await mutate(() => copyIconFile(`${selected}`, 'tun'), { + id: 'copy-icon:tun', + }) await initIconPath() onChangeData({ tun_tray_icon: true }) patchVerge({ tun_tray_icon: true }) diff --git a/src/components/setting/mods/lite-mode-viewer.tsx b/src/components/setting/mods/lite-mode-viewer.tsx index 02b38660c..90301668c 100644 --- a/src/components/setting/mods/lite-mode-viewer.tsx +++ b/src/components/setting/mods/lite-mode-viewer.tsx @@ -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 }) { @@ -72,7 +73,12 @@ export function LiteModeViewer({ ref }: { ref?: Ref }) { 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')} diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 9545d8dd6..e96a96bc4 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -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((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((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((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() diff --git a/src/components/setting/mods/tun-viewer.tsx b/src/components/setting/mods/tun-viewer.tsx index b9be3caf4..b00355162 100644 --- a/src/components/setting/mods/tun-viewer.tsx +++ b/src/components/setting/mods/tun-viewer.tsx @@ -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 }) { ) 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) { diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index 537e00dd6..6704f93a9 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -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 }) { } 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 }) { 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) }} diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index 80b27111a..c4ab0f458 100644 --- a/src/components/setting/setting-clash.tsx +++ b/src/components/setting/setting-clash.tsx @@ -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 { diff --git a/src/components/setting/setting-verge-advanced.tsx b/src/components/setting/setting-verge-advanced.tsx index e5049534d..8a2e51800 100644 --- a/src/components/setting/setting-verge-advanced.tsx +++ b/src/components/setting/setting-verge-advanced.tsx @@ -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) => { /> { + 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) => { { - exitApp() + void mutate(() => exitApp(), { + id: 'exit-app', + errorNotice: false, + }).catch(() => {}) }} label={t('settings.components.verge.advanced.fields.exit')} /> diff --git a/src/components/test/test-item.tsx b/src/components/test/test-item.tsx index e014ca542..27e11dcbf 100644 --- a/src/components/test/test-item.tsx +++ b/src/components/test/test-item.tsx @@ -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 ( diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index 82dbb29d5..20475103d 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -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) => { - 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']) diff --git a/src/hooks/use-listen.ts b/src/hooks/use-listen.ts deleted file mode 100644 index dcabcc087..000000000 --- a/src/hooks/use-listen.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { listen, EventCallback } from '@tauri-apps/api/event' -import { useCallback } from 'react' - -export const useListen = () => { - const addListener = useCallback( - async (eventName: string, handler: EventCallback) => { - return await listen(eventName, handler) - }, - [], - ) - - return { - addListener, - } -} diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index ece68cd6f..306013afa 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -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) => { try { - const outcome = await patchProfilesConfig(value) + const result = await mutate(() => patchProfilesConfig(value), { + id: 'patch-profiles-config', + onFulfilled: (outcome) => { + if (outcome.status === 'valid') { + void setCacheData(profilesQueryKey, (current) => + current ? { ...current, ...value } : current, + ) + } + }, + }) - if (outcome.status === 'valid') { - await setCacheData(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) => { if (profiles?.current) { - await patchProfile(profiles.current, value) + const uid = profiles.current + await mutate(() => patchProfile(uid, value), { + id: `patch-profile:${uid}`, + errorNotice: false, + }) void mutateProfiles() } }, diff --git a/src/hooks/use-proxy-selection.test.ts b/src/hooks/use-proxy-selection.test.ts new file mode 100644 index 000000000..d176baf66 --- /dev/null +++ b/src/hooks/use-proxy-selection.test.ts @@ -0,0 +1,51 @@ +import { selectNodeForGroup } from 'tauri-plugin-mihomo-api' +import { expect, it, vi } from 'vitest' + +import { useProxySelection } from './use-proxy-selection' + +const record = vi.hoisted(() => vi.fn()) +vi.mock('react', () => ({ + useCallback: (callback: unknown) => callback, + useRef: (current: unknown) => ({ current }), +})) +vi.mock('@/hooks/use-record-selection', () => ({ + useRecordSelection: () => record, + useForgetSelection: () => vi.fn(), +})) +vi.mock('@/hooks/use-verge', () => ({ useVerge: () => ({ verge: {} }) })) +vi.mock('tauri-plugin-mihomo-api', () => ({ + selectNodeForGroup: vi.fn(), + unfixedProxy: vi.fn(), +})) +vi.mock('@/services/cmds', () => ({ syncTrayProxySelection: vi.fn() })) +vi.mock('@/services/notice-service', () => ({ showNotice: { error: vi.fn() } })) +vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() })) + +it('applies and records independent group selections while another group is pending', async () => { + let finishFirst!: () => void + vi.mocked(selectNodeForGroup) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve + }), + ) + .mockResolvedValueOnce(undefined) + const firstSuccess = vi.fn() + const secondSuccess = vi.fn() + const first = useProxySelection({ onSuccess: firstSuccess }) + const second = useProxySelection({ onSuccess: secondSuccess }) + first.changeProxy('Group A', 'Node A') + second.changeProxy('Group B', 'Node B') + await vi.waitFor(() => + expect(record).toHaveBeenCalledWith('Group B', 'Node B'), + ) + expect(selectNodeForGroup).toHaveBeenCalledTimes(2) + expect(firstSuccess).not.toHaveBeenCalled() + expect(secondSuccess).toHaveBeenCalledOnce() + finishFirst() + await vi.waitFor(() => + expect(record).toHaveBeenCalledWith('Group A', 'Node A'), + ) + expect(firstSuccess).toHaveBeenCalledOnce() +}) diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index fe9d98400..e3a1d40e6 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -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) { diff --git a/src/hooks/use-record-selection.ts b/src/hooks/use-record-selection.ts index bb2ed36f2..66e5736a8 100644 --- a/src/hooks/use-record-selection.ts +++ b/src/hooks/use-record-selection.ts @@ -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) } diff --git a/src/hooks/use-service-installer.ts b/src/hooks/use-service-installer.ts index 6a92ed287..b242224e3 100644 --- a/src/hooks/use-service-installer.ts +++ b/src/hooks/use-service-installer.ts @@ -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 ( @@ -23,14 +24,19 @@ const executeWithErrorHandling = async ( 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', ) diff --git a/src/hooks/use-service-uninstaller.ts b/src/hooks/use-service-uninstaller.ts index 769c47c2a..543d1e8ae 100644 --- a/src/hooks/use-service-uninstaller.ts +++ b/src/hooks/use-service-uninstaller.ts @@ -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 } diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 7c3c7bba5..f16d1c106 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -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) { diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index a1a0faa59..a2079c882 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -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, } } diff --git a/src/hooks/use-verge.ts b/src/hooks/use-verge.ts index 0e0e4247c..119b4ce1f 100644 --- a/src/hooks/use-verge.ts +++ b/src/hooks/use-verge.ts @@ -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) => { - 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) => { + await mutate(() => patchVergeConfig(value), { + id: 'patch-verge-config', + revalidate: [['getVergeConfig']], + errorNotice: false, + }) + }, []) return { verge, diff --git a/src/hooks/use-visibility.ts b/src/hooks/use-visibility.ts index fc1285bfb..ffadb051c 100644 --- a/src/hooks/use-visibility.ts +++ b/src/hooks/use-visibility.ts @@ -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 () => { diff --git a/src/locales/ar/shared.json b/src/locales/ar/shared.json index 294747042..89105eccd 100644 --- a/src/locales/ar/shared.json +++ b/src/locales/ar/shared.json @@ -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" } } }, diff --git a/src/locales/de/shared.json b/src/locales/de/shared.json index ac9b3e837..691751ec9 100644 --- a/src/locales/de/shared.json +++ b/src/locales/de/shared.json @@ -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." } } }, diff --git a/src/locales/en/shared.json b/src/locales/en/shared.json index 25745ea66..613e9a475 100644 --- a/src/locales/en/shared.json +++ b/src/locales/en/shared.json @@ -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" } } }, diff --git a/src/locales/es/shared.json b/src/locales/es/shared.json index 96782cdde..b4bae61c9 100644 --- a/src/locales/es/shared.json +++ b/src/locales/es/shared.json @@ -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" } } }, diff --git a/src/locales/fa/shared.json b/src/locales/fa/shared.json index f30c5a3a7..3437c0d00 100644 --- a/src/locales/fa/shared.json +++ b/src/locales/fa/shared.json @@ -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" } } }, diff --git a/src/locales/id/shared.json b/src/locales/id/shared.json index 4b1bb2c0d..6af0d6090 100644 --- a/src/locales/id/shared.json +++ b/src/locales/id/shared.json @@ -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" } } }, diff --git a/src/locales/jp/shared.json b/src/locales/jp/shared.json index b16d68b4d..a8154b965 100644 --- a/src/locales/jp/shared.json +++ b/src/locales/jp/shared.json @@ -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": "上書きファイルのマッピングエラーがあります。変更は取り消されました。" } } }, diff --git a/src/locales/ko/shared.json b/src/locales/ko/shared.json index decc0a57e..8d57a4584 100644 --- a/src/locales/ko/shared.json +++ b/src/locales/ko/shared.json @@ -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": "병합 파일 매핑 오류" } } }, diff --git a/src/locales/ru/shared.json b/src/locales/ru/shared.json index 191ded0f5..9fb945e43 100644 --- a/src/locales/ru/shared.json +++ b/src/locales/ru/shared.json @@ -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, откат изменений" } } }, diff --git a/src/locales/tr/shared.json b/src/locales/tr/shared.json index 29a5023b4..33875bf79 100644 --- a/src/locales/tr/shared.json +++ b/src/locales/tr/shared.json @@ -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ı" } } }, diff --git a/src/locales/tt/shared.json b/src/locales/tt/shared.json index 2ff59a884..d102b189d 100644 --- a/src/locales/tt/shared.json +++ b/src/locales/tt/shared.json @@ -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" } } }, diff --git a/src/locales/zh/shared.json b/src/locales/zh/shared.json index 85310b23b..940f29689 100644 --- a/src/locales/zh/shared.json +++ b/src/locales/zh/shared.json @@ -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": "覆写文件映射错误,变更已撤销" } } }, diff --git a/src/locales/zhtw/shared.json b/src/locales/zhtw/shared.json index dd8d4c50f..6eda057f3 100644 --- a/src/locales/zhtw/shared.json +++ b/src/locales/zhtw/shared.json @@ -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": "覆寫檔案映射錯誤,變更已撤銷" } } }, diff --git a/src/main.tsx b/src/main.tsx index 01a683f28..5a0f0d76d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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 = [ , - , + , , ] diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 646fd9ae5..59625514e 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -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(() => { diff --git a/src/pages/_layout/hooks/index.ts b/src/pages/_layout/hooks/index.ts index 2e6bfebe4..13f1aff3c 100644 --- a/src/pages/_layout/hooks/index.ts +++ b/src/pages/_layout/hooks/index.ts @@ -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' diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts deleted file mode 100644 index ef95262c2..000000000 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ /dev/null @@ -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(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]) -} diff --git a/src/pages/_layout/hooks/use-pending-failures.ts b/src/pages/_layout/hooks/use-pending-failures.ts index ed5ad7438..7797aa556 100644 --- a/src/pages/_layout/hooks/use-pending-failures.ts +++ b/src/pages/_layout/hooks/use-pending-failures.ts @@ -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([ @@ -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()) - 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(null) - // Dismiss locally without retiring the backend failure. + const failures = usePendingFailureList() const [dismissedSequence, setDismissedSequence] = useState( 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) diff --git a/src/pages/_layout/utils/index.ts b/src/pages/_layout/utils/index.ts index d1b854e3b..5b01eb11b 100644 --- a/src/pages/_layout/utils/index.ts +++ b/src/pages/_layout/utils/index.ts @@ -1,2 +1 @@ export { hideInitialOverlay } from './initial-loading-overlay' -export { handleNoticeMessage } from './notification-handlers' diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx index 650ee3630..9a0158226 100644 --- a/src/pages/connections.tsx +++ b/src/pages/connections.tsx @@ -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) => { diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 32e1aafa7..3937d4e57 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -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 = () => { await entry_lightweight_mode()} + onClick={async () => + await mutate(() => entry_lightweight_mode(), { + id: 'entry-lightweight-mode', + errorNotice: false, + }) + } size="small" color="inherit" > diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 2bd7484d9..85d7d78b2 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -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 - >(() => new Map()) - const [completedUpdateRevisions, setCompletedUpdateRevisions] = useState< - Map - >(() => new Map()) const [batchMode, setBatchMode] = useState(false) const [selectedProfiles, setSelectedProfiles] = useState>( @@ -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 } diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index 89093d8f5..ac70a1c61 100644 --- a/src/pages/proxies.tsx +++ b/src/pages/proxies.tsx @@ -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) diff --git a/src/pages/unlock.tsx b/src/pages/unlock.tsx index cbf482c63..254994bc1 100644 --- a/src/pages/unlock.tsx +++ b/src/pages/unlock.tsx @@ -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 = { @@ -119,7 +118,7 @@ const UnlockPage = () => { void (async () => { try { - const defaultItems = await invoke('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('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('check_media_unlock_item', { - name, - }) + const result = await checkMediaUnlockItem(name) const updatedItems = unlockItemsRef.current.map((item) => item.name === name ? result : item, ) diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index fe2dfb6bc..731d7ea20 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -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(), diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/services/bus/create-bus-handlers.test.ts similarity index 57% rename from src/pages/_layout/hooks/use-layout-events.test.ts rename to src/services/bus/create-bus-handlers.test.ts index b1fc2f0b6..33bd6d602 100644 --- a/src/pages/_layout/hooks/use-layout-events.test.ts +++ b/src/services/bus/create-bus-handlers.test.ts @@ -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', diff --git a/src/services/bus/create-bus-handlers.ts b/src/services/bus/create-bus-handlers.ts new file mode 100644 index 000000000..957589ea7 --- /dev/null +++ b/src/services/bus/create-bus-handlers.ts @@ -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 } +} diff --git a/src/services/bus/index.ts b/src/services/bus/index.ts new file mode 100644 index 000000000..9648128ff --- /dev/null +++ b/src/services/bus/index.ts @@ -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) +} diff --git a/src/services/bus/use-event-bus.test.ts b/src/services/bus/use-event-bus.test.ts new file mode 100644 index 000000000..106503d02 --- /dev/null +++ b/src/services/bus/use-event-bus.test.ts @@ -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() + 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 }, + }) +}) diff --git a/src/services/bus/use-event-bus.ts b/src/services/bus/use-event-bus.ts new file mode 100644 index 000000000..ea64c0100 --- /dev/null +++ b/src/services/bus/use-event-bus.ts @@ -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 + ).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]) +} diff --git a/src/services/cmds.ts b/src/services/cmds.ts index c748e64bb..c5d7c1197 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -183,6 +183,22 @@ export async function setDnsOverride( >('set_dns_override', { profileUid, enabled, confirmation }) } +export async function getDnsConfigContent() { + return invoke('get_dns_config_content') +} + +export async function saveDnsConfig(dnsConfig: Record) { + return invoke('save_dns_config', { dnsConfig }) +} + +export async function validateDnsConfig() { + return invoke('validate_dns_config') +} + +export async function applyDnsConfig(apply: boolean) { + return invoke('apply_dns_config', { apply }) +} + export async function takeDnsOverrideNotice() { return invoke('take_dns_override_notice') } @@ -568,3 +584,22 @@ export const probeListener = async (request: ListenerProbe) => { export const saveProxyPorts = async (settings: ProxyPortSettings) => { return invoke('save_proxy_ports', { settings }) } + +export interface UnlockItem { + name: string + status: string + region?: string | null + check_time?: string | null +} + +export async function getUnlockItems() { + return invoke('get_unlock_items') +} + +export async function checkMediaUnlock(onComplete: Channel) { + return invoke('check_media_unlock', { onComplete }) +} + +export async function checkMediaUnlockItem(name: string) { + return invoke('check_media_unlock_item', { name }) +} diff --git a/src/services/contract.ts b/src/services/contract.ts new file mode 100644 index 000000000..093d9fcd8 --- /dev/null +++ b/src/services/contract.ts @@ -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 } +} diff --git a/src/services/events.test.ts b/src/services/events.test.ts deleted file mode 100644 index f14294481..000000000 --- a/src/services/events.test.ts +++ /dev/null @@ -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 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' }) - }) -}) diff --git a/src/services/events.ts b/src/services/events.ts deleted file mode 100644 index 6228d35ca..000000000 --- a/src/services/events.ts +++ /dev/null @@ -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) - } - } - } -} diff --git a/src/services/mutate.test.ts b/src/services/mutate.test.ts new file mode 100644 index 000000000..ce6299340 --- /dev/null +++ b/src/services/mutate.test.ts @@ -0,0 +1,33 @@ +import { expect, it, vi } from 'vitest' + +import { mutate } from './mutate' + +vi.mock('@/services/notice-service', () => ({ + showNotice: { error: vi.fn(), warning: vi.fn(), success: vi.fn() }, +})) +vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() })) + +it('executes every queued setting write even when its predecessor fails', async () => { + let reject!: (error: Error) => void + const first = mutate( + () => + new Promise((_resolve, rejectFirst) => { + reject = rejectFirst + }), + { id: 'patch-verge-config', errorNotice: false }, + ).catch((error) => error) + const writeSecond = vi.fn().mockResolvedValue(undefined) + const onFulfilled = vi.fn() + const second = mutate(writeSecond, { + id: 'patch-verge-config', + onFulfilled, + errorNotice: false, + }) + expect(writeSecond).not.toHaveBeenCalled() + const error = new Error('first write failed') + reject(error) + expect(await first).toBe(error) + expect(await second).toEqual({ ok: true, value: undefined }) + expect(writeSecond).toHaveBeenCalledOnce() + expect(onFulfilled).toHaveBeenCalledOnce() +}) diff --git a/src/services/mutate.ts b/src/services/mutate.ts new file mode 100644 index 000000000..decf95bf9 --- /dev/null +++ b/src/services/mutate.ts @@ -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[0] + +export interface MutateOptions { + /** 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 = { 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>() +export async function mutate( + invoke: () => Promise, + options: MutateOptions, +): Promise> { + const { id } = options + const previous = inFlight.get(id) + let release!: () => void + const settled = new Promise((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) + } +} diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/services/notice-handlers.ts similarity index 91% rename from src/pages/_layout/utils/notification-handlers.ts rename to src/services/notice-handlers.ts index 9cec1418e..c87c5a9f2 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/services/notice-handlers.ts @@ -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 void> = { + const handlers: Record 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 { diff --git a/src/services/states.ts b/src/services/states.ts index 3770c09aa..b48bcfa3c 100644 --- a/src/services/states.ts +++ b/src/services/states.ts @@ -4,10 +4,6 @@ const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState< 'light' | 'dark' >() -// save the state of each profile item loading -const [LoadingCacheProvider, useLoadingCache, useSetLoadingCache] = - createContextState>(new Set()) - // save update state const [UpdateStateProvider, useUpdateState, useSetUpdateState] = createContextState(false) @@ -16,9 +12,6 @@ export { ThemeModeProvider, useThemeMode, useSetThemeMode, - LoadingCacheProvider, - useLoadingCache, - useSetLoadingCache, UpdateStateProvider, useUpdateState, useSetUpdateState, diff --git a/src/store/app-state.ts b/src/store/app-state.ts new file mode 100644 index 000000000..c0e143308 --- /dev/null +++ b/src/store/app-state.ts @@ -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 + readonly updateRevisions: ReadonlyMap + readonly timerRevisions: ReadonlyMap +} + +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, + uid: string, +): ReadonlyMap => { + 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 } + } +} diff --git a/src/store/app-store-context.ts b/src/store/app-store-context.ts new file mode 100644 index 000000000..aa2ca7351 --- /dev/null +++ b/src/store/app-store-context.ts @@ -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(null) +export const AppDispatchContext = + createContext(null) + +export const AppReadsContext = createContext | null>(null) + +export const useAppReads = () => { + const reads = use(AppReadsContext) + if (!reads) throw new Error('app store is missing its provider') + return reads +} + +export type AppStoreActionDispatcher = (action: AppStoreAction) => void + +const useSelector = (select: (state: AppStoreState) => T): T => { + 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 => + 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) diff --git a/src/store/app-store.tsx b/src/store/app-store.tsx new file mode 100644 index 000000000..b84f404c1 --- /dev/null +++ b/src/store/app-store.tsx @@ -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 ( + + + + {children} + + + + ) +} diff --git a/src/store/store-reads.test.ts b/src/store/store-reads.test.ts new file mode 100644 index 000000000..c8eebd51e --- /dev/null +++ b/src/store/store-reads.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +import { + getPendingFailures, + getRuntimeState, + type RunState, +} from '@/services/cmds' + +import { appStoreReducer, initialAppStoreState } from './app-state' +import { createStoreReads } from './store-reads' + +vi.mock('@/services/cmds', () => ({ + getRuntimeState: vi.fn(), + getPendingFailures: vi.fn().mockResolvedValue([]), + getSidecarFailure: vi.fn().mockResolvedValue({ revision: 0, detail: null }), +})) + +beforeEach(() => vi.clearAllMocks()) + +it('keeps a newer run-state event when an older snapshot returns', async () => { + let resolve!: (state: RunState) => void + vi.mocked(getRuntimeState).mockImplementationOnce( + () => + new Promise((r) => { + resolve = r + }), + ) + let state = initialAppStoreState + const reads = createStoreReads((action) => { + state = appStoreReducer(state, action) + }) + const pending = reads.readRunState() + const running = { mode: 'Service' } as RunState + reads.acceptRunState(running) + resolve({ mode: 'NotRunning' } as RunState) + expect(await pending).toBe(running) + expect(state.runState).toBe(running) +}) + +it('keeps the newer read when run-state replies arrive in reverse order', async () => { + let resolveFirst!: (state: RunState) => void + vi.mocked(getRuntimeState) + .mockImplementationOnce( + () => + new Promise((r) => { + resolveFirst = r + }), + ) + .mockResolvedValueOnce({ mode: 'Service' } as RunState) + let state = initialAppStoreState + const reads = createStoreReads((action) => { + state = appStoreReducer(state, action) + }) + const first = reads.readRunState() + const second = reads.readRunState() + await second + resolveFirst({ mode: 'NotRunning' } as RunState) + expect((await first).mode).toBe('Service') + expect(state.runState?.mode).toBe('Service') +}) + +it('does not restore cleared pending failures from an older read', async () => { + let resolveFirst!: ( + failures: Awaited>, + ) => void + vi.mocked(getPendingFailures) + .mockImplementationOnce( + () => + new Promise((r) => { + resolveFirst = r + }), + ) + .mockResolvedValueOnce([]) + let state = initialAppStoreState + const reads = createStoreReads((action) => { + state = appStoreReducer(state, action) + }) + reads.readPendingFailures() + reads.readPendingFailures() + await Promise.resolve() + resolveFirst([ + { + code: 'proxy', + detail: 'old', + operation: 'systemProxyEnable', + sequence: 1, + }, + ]) + await Promise.resolve() + expect(state.pendingFailures).toEqual([]) +}) diff --git a/src/store/store-reads.ts b/src/store/store-reads.ts new file mode 100644 index 000000000..3af3e1a03 --- /dev/null +++ b/src/store/store-reads.ts @@ -0,0 +1,71 @@ +import { + getPendingFailures, + getRuntimeState, + getSidecarFailure, + type RunState, +} from '@/services/cmds' + +import type { AppStoreAction } from './app-state' + +export const createStoreReads = ( + dispatch: (action: AppStoreAction) => void, +) => { + let runRevision = 0 + let failureRevision = 0 + let current: RunState | null = null + let latestRead: Promise | null = null + + const acceptRunState = (runState: RunState) => { + runRevision += 1 + latestRead = null + current = runState + dispatch({ type: 'runState/loaded', runState }) + } + + const readRunState = (): Promise => { + const revision = ++runRevision + const read: Promise = getRuntimeState().then((runState) => { + if (revision === runRevision) { + current = runState + dispatch({ type: 'runState/loaded', runState }) + } + if (latestRead && latestRead !== read) return latestRead + return current ?? runState + }) + latestRead = read + return read + } + + const readPendingFailures = () => { + const revision = ++failureRevision + getSidecarFailure() + .then((snapshot) => { + if (revision === failureRevision) { + dispatch({ type: 'sidecarFailure/loaded', snapshot }) + } + }) + .catch((error) => { + console.warn('[app-store] Sidecar failure could not be read:', error) + }) + getPendingFailures() + .then((failures) => { + if (revision === failureRevision) { + dispatch({ type: 'pendingFailures/loaded', failures }) + } + }) + .catch((error) => { + console.warn('[app-store] pending failures could not be read:', error) + }) + } + + return { + acceptRunState, + readRunState, + readPendingFailures, + invalidate: () => { + runRevision += 1 + failureRevision += 1 + latestRead = null + }, + } +} diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 8a7b52465..4e566dce4 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -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', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index f238585f1..87aba3645 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -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