From 69b76cc6a9e4d521d42d9241e3ddc09f405b39c8 Mon Sep 17 00:00:00 2001 From: Tunglies Date: Wed, 7 Oct 2026 07:38:55 +0000 Subject: [PATCH] refactor(frontend): contract flow (#8131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(contract): export wire contract from backend and generate frontend types Adds a zero-dependency `frontend-contract` bin that serializes the real NoticeStatus variants and FrontendEvent payloads, scripts/gen-contract.mjs which emits the committed src/services/contract.ts, and a CI job that regenerates it and fails on a non-empty diff. events.ts now derives its event map from the generated contract instead of hand-typed shapes. * feat(events): route all backend events through a single event bus and store Adds src/services/bus (the only listen/emit site: one handler per contract event plus the frontend-internal test-all event) and the React context/reducer app store. Migrates the five scattered subscriptions (layout events, pending failures, proxy refresh, profile update/timer, test-all), deletes the old glue (events.ts, use-listen, use-layout-events, LoadingCacheProvider), moves run state into the store, and adds an architecture gate script wired into CI. Behavior is unchanged; the run-state query's poll/focus safety net is preserved by the store provider. * refactor(notice): make the notice table exhaustive and drop dead wire entries D5 cleanup, frontend and backend in one commit: removes the six config_validate::* statuses that never receive a backend send and the set_config::ok send (enum variants, golden list 41→34, send point in restart_clash_core), their frontend handlers, and the five i18n keys only those handlers used. The notice table is now typed as a Record over the generated NoticeStatus union, so a status without a frontend entry fails to compile. The info status (sent by the silent updater for breaking change notices, previously dropped with a console warning) now shows an info toast — the one deliberate behavior delta. * feat(mutate): single funnel for user-intent commands and invoke gate Adds src/services/mutate.ts: one path for optimistic apply, services-layer invoke, Busy handling, failure rollback, and the error toast, with busy bookkeeping mirrored into the app store. The patch hooks (verge, clash, profiles) now route through it. Moves the remaining direct invokes (unlock ×3, DNS ×4) into cmds.ts and turns on the invoke-only-in-services architecture gate in CI. * refactor(settings): route settings-domain commands through the mutate funnel Ports port saving, webdav/local backup operations, core change/restart/ upgrade, service install/uninstall/repair flows, the system-proxy toggle queue, the DNS override toggle, and app lifecycle commands onto the single funnel. Bespoke flows keep their own error handling via errorNotice:false; simple flows gain the funnel's default error toast and revalidation. * refactor(profiles): route profile operations through the mutate funnel Ports profile create/import/reorder/delete/update/patch/save-file and enhance calls onto the funnel; retry and editor flows keep their own error handling with errorNotice:false. Per-uid ids keep the update worker pool concurrent under the funnel's in-flight dedupe. * refactor(proxies): route proxy/home/misc commands through the mutate funnel Ports mode switching (page and home card), chain runtime updates, node selection with tray sync, selection recording, provider updates and syncs, and lightweight-mode entry onto the funnel; every site keeps its existing optimistic/revert and error-handling behavior. * docs: add frontend architecture guardrails for src Layering rules for the contract flow: generated contract as the only wire-typing source, event bus as the only listen/emit site, pure-reducer store for event-derived state, the mutate funnel for user intent, and invoke only in the services layer — each backed by the CI gates. * fix(events): close run-state race and restore single error toasts Self-review findings: the bus now re-reads the run-state snapshot in onSubscribed so an event racing the store's initial read cannot leave a stale snapshot (the ordering the old layout listener guaranteed), and the patch hooks pass errorNotice:false so GuardState/page handlers remain the only error toast, matching the pre-migration behavior. * fix(frontend): preserve queued intents and fresh runtime snapshots * refactor(frontend): trim unused mutation and busy state APIs * docs(frontend): focus guardrails on architecture ownership --- .github/workflows/frontend-check.yml | 79 ++++++++ knip.json | 3 +- package.json | 2 + scripts/check-architecture.mjs | 84 ++++++++ scripts/gen-contract.mjs | 91 +++++++++ src-tauri/src/bin/frontend-contract.rs | 8 + src-tauri/src/core/notify/mod.rs | 122 ++++------- src-tauri/src/core/notify/notification.rs | 151 ++++++++------ src-tauri/src/feat/clash.rs | 1 - src-tauri/src/lib.rs | 4 + src/AGENTS.md | 23 +++ src/components/home/clash-mode-card.tsx | 6 +- src/components/home/home-profile-card.tsx | 6 +- src/components/home/test-card.tsx | 4 +- .../layout/service-migration-dialog.test.ts | 32 +-- .../layout/service-migration-dialog.tsx | 52 +++-- .../layout/sysproxy-privilege-dialog.tsx | 28 ++- .../profile/groups-editor-viewer.tsx | 7 +- src/components/profile/profile-item.tsx | 53 +++-- src/components/profile/profile-more.tsx | 12 +- src/components/profile/profile-viewer.tsx | 42 +++- .../profile/proxies-editor-viewer.tsx | 7 +- .../profile/rules-editor-viewer.tsx | 7 +- src/components/proxy/provider-button.tsx | 21 +- src/components/proxy/proxy-chain.tsx | 43 +++- src/components/proxy/proxy-empty-state.tsx | 14 +- src/components/proxy/proxy-groups-chain.tsx | 6 +- src/components/rule/provider-button.tsx | 21 +- .../setting/mods/backup-config-viewer.tsx | 14 +- .../setting/mods/backup-history-viewer.tsx | 33 ++- src/components/setting/mods/backup-viewer.tsx | 24 ++- .../setting/mods/clash-core-viewer.tsx | 46 +++-- .../setting/mods/clash-port-viewer.tsx | 40 ++-- src/components/setting/mods/dns-viewer.tsx | 28 ++- .../setting/mods/external-controller-cors.tsx | 6 +- src/components/setting/mods/layout-viewer.tsx | 14 +- .../setting/mods/lite-mode-viewer.tsx | 8 +- .../setting/mods/sysproxy-viewer.tsx | 45 +---- src/components/setting/mods/tun-viewer.tsx | 6 +- src/components/setting/mods/update-viewer.tsx | 20 +- src/components/setting/setting-clash.tsx | 16 +- .../setting/setting-verge-advanced.tsx | 18 +- src/components/test/test-item.tsx | 21 +- src/hooks/use-clash.ts | 16 +- src/hooks/use-listen.ts | 15 -- src/hooks/use-profiles.ts | 39 ++-- src/hooks/use-proxy-selection.test.ts | 51 +++++ src/hooks/use-proxy-selection.ts | 19 +- src/hooks/use-record-selection.ts | 11 +- src/hooks/use-service-installer.ts | 20 +- src/hooks/use-service-uninstaller.ts | 14 +- src/hooks/use-system-proxy-state.ts | 11 +- src/hooks/use-system-state.ts | 32 +-- src/hooks/use-verge.ts | 17 +- src/hooks/use-visibility.ts | 12 +- src/locales/ar/shared.json | 9 +- src/locales/de/shared.json | 9 +- src/locales/en/shared.json | 9 +- src/locales/es/shared.json | 9 +- src/locales/fa/shared.json | 9 +- src/locales/id/shared.json | 9 +- src/locales/jp/shared.json | 9 +- src/locales/ko/shared.json | 9 +- src/locales/ru/shared.json | 9 +- src/locales/tr/shared.json | 9 +- src/locales/tt/shared.json | 9 +- src/locales/zh/shared.json | 9 +- src/locales/zhtw/shared.json | 9 +- src/main.tsx | 9 +- src/pages/_layout.tsx | 6 +- src/pages/_layout/hooks/index.ts | 1 - src/pages/_layout/hooks/use-layout-events.ts | 86 -------- .../_layout/hooks/use-pending-failures.ts | 120 +++-------- src/pages/_layout/utils/index.ts | 1 - src/pages/connections.tsx | 8 +- src/pages/home.tsx | 8 +- src/pages/profiles.tsx | 190 ++++++++---------- src/pages/proxies.tsx | 11 +- src/pages/unlock.tsx | 25 +-- src/providers/app-data-provider.tsx | 32 +-- .../bus/create-bus-handlers.test.ts} | 132 ++++++------ src/services/bus/create-bus-handlers.ts | 120 +++++++++++ src/services/bus/index.ts | 10 + src/services/bus/use-event-bus.test.ts | 76 +++++++ src/services/bus/use-event-bus.ts | 70 +++++++ src/services/cmds.ts | 35 ++++ src/services/contract.ts | 56 ++++++ src/services/events.test.ts | 58 ------ src/services/events.ts | 75 ------- src/services/mutate.test.ts | 33 +++ src/services/mutate.ts | 64 ++++++ .../notice-handlers.ts} | 28 +-- src/services/states.ts | 7 - src/store/app-state.ts | 124 ++++++++++++ src/store/app-store-context.ts | 60 ++++++ src/store/app-store.tsx | 76 +++++++ src/store/store-reads.test.ts | 91 +++++++++ src/store/store-reads.ts | 71 +++++++ src/types/generated/i18n-keys.ts | 5 - src/types/generated/i18n-resources.ts | 5 - 100 files changed, 2208 insertions(+), 1127 deletions(-) create mode 100644 scripts/check-architecture.mjs create mode 100644 scripts/gen-contract.mjs create mode 100644 src-tauri/src/bin/frontend-contract.rs create mode 100644 src/AGENTS.md delete mode 100644 src/hooks/use-listen.ts create mode 100644 src/hooks/use-proxy-selection.test.ts delete mode 100644 src/pages/_layout/hooks/use-layout-events.ts rename src/{pages/_layout/hooks/use-layout-events.test.ts => services/bus/create-bus-handlers.test.ts} (57%) create mode 100644 src/services/bus/create-bus-handlers.ts create mode 100644 src/services/bus/index.ts create mode 100644 src/services/bus/use-event-bus.test.ts create mode 100644 src/services/bus/use-event-bus.ts create mode 100644 src/services/contract.ts delete mode 100644 src/services/events.test.ts delete mode 100644 src/services/events.ts create mode 100644 src/services/mutate.test.ts create mode 100644 src/services/mutate.ts rename src/{pages/_layout/utils/notification-handlers.ts => services/notice-handlers.ts} (91%) create mode 100644 src/store/app-state.ts create mode 100644 src/store/app-store-context.ts create mode 100644 src/store/app-store.tsx create mode 100644 src/store/store-reads.test.ts create mode 100644 src/store/store-reads.ts 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