mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
refactor(frontend): contract flow (#8131)
* feat(contract): export wire contract from backend and generate frontend types Adds a zero-dependency `frontend-contract` bin that serializes the real NoticeStatus variants and FrontendEvent payloads, scripts/gen-contract.mjs which emits the committed src/services/contract.ts, and a CI job that regenerates it and fails on a non-empty diff. events.ts now derives its event map from the generated contract instead of hand-typed shapes. * feat(events): route all backend events through a single event bus and store Adds src/services/bus (the only listen/emit site: one handler per contract event plus the frontend-internal test-all event) and the React context/reducer app store. Migrates the five scattered subscriptions (layout events, pending failures, proxy refresh, profile update/timer, test-all), deletes the old glue (events.ts, use-listen, use-layout-events, LoadingCacheProvider), moves run state into the store, and adds an architecture gate script wired into CI. Behavior is unchanged; the run-state query's poll/focus safety net is preserved by the store provider. * refactor(notice): make the notice table exhaustive and drop dead wire entries D5 cleanup, frontend and backend in one commit: removes the six config_validate::* statuses that never receive a backend send and the set_config::ok send (enum variants, golden list 41→34, send point in restart_clash_core), their frontend handlers, and the five i18n keys only those handlers used. The notice table is now typed as a Record over the generated NoticeStatus union, so a status without a frontend entry fails to compile. The info status (sent by the silent updater for breaking change notices, previously dropped with a console warning) now shows an info toast — the one deliberate behavior delta. * feat(mutate): single funnel for user-intent commands and invoke gate Adds src/services/mutate.ts: one path for optimistic apply, services-layer invoke, Busy handling, failure rollback, and the error toast, with busy bookkeeping mirrored into the app store. The patch hooks (verge, clash, profiles) now route through it. Moves the remaining direct invokes (unlock ×3, DNS ×4) into cmds.ts and turns on the invoke-only-in-services architecture gate in CI. * refactor(settings): route settings-domain commands through the mutate funnel Ports port saving, webdav/local backup operations, core change/restart/ upgrade, service install/uninstall/repair flows, the system-proxy toggle queue, the DNS override toggle, and app lifecycle commands onto the single funnel. Bespoke flows keep their own error handling via errorNotice:false; simple flows gain the funnel's default error toast and revalidation. * refactor(profiles): route profile operations through the mutate funnel Ports profile create/import/reorder/delete/update/patch/save-file and enhance calls onto the funnel; retry and editor flows keep their own error handling with errorNotice:false. Per-uid ids keep the update worker pool concurrent under the funnel's in-flight dedupe. * refactor(proxies): route proxy/home/misc commands through the mutate funnel Ports mode switching (page and home card), chain runtime updates, node selection with tray sync, selection recording, provider updates and syncs, and lightweight-mode entry onto the funnel; every site keeps its existing optimistic/revert and error-handling behavior. * docs: add frontend architecture guardrails for src Layering rules for the contract flow: generated contract as the only wire-typing source, event bus as the only listen/emit site, pure-reducer store for event-derived state, the mutate funnel for user intent, and invoke only in the services layer — each backed by the CI gates. * fix(events): close run-state race and restore single error toasts Self-review findings: the bus now re-reads the run-state snapshot in onSubscribed so an event racing the store's initial read cannot leave a stale snapshot (the ordering the old layout listener guaranteed), and the patch hooks pass errorNotice:false so GuardState/page handlers remain the only error toast, matching the pre-migration behavior. * fix(frontend): preserve queued intents and fresh runtime snapshots * refactor(frontend): trim unused mutation and busy state APIs * docs(frontend): focus guardrails on architecture ownership
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Architecture grep gates for the frontend contract flow. Select gates via
|
||||
* argv:
|
||||
*
|
||||
* node scripts/check-architecture.mjs listen emit invoke
|
||||
*
|
||||
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
|
||||
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
|
||||
* like `this.emit(` are unrelated class methods) or as importing the name
|
||||
* from '@tauri-apps/api/event'.
|
||||
* - invoke: `invoke(` calls may exist only under src/services/**.
|
||||
*/
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const SRC = path.join(ROOT, 'src')
|
||||
|
||||
const tauriEventImport = (name) =>
|
||||
new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
|
||||
)
|
||||
|
||||
const GATES = {
|
||||
listen: {
|
||||
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
|
||||
allowed: (file) => file.startsWith('src/services/bus/'),
|
||||
description: 'Tauri listen outside the event bus (src/services/bus)',
|
||||
},
|
||||
emit: {
|
||||
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
|
||||
allowed: (file) => file.startsWith('src/services/bus/'),
|
||||
description: 'Tauri emit outside the event bus (src/services/bus)',
|
||||
},
|
||||
invoke: {
|
||||
patterns: [/\binvoke\(/],
|
||||
allowed: (file) => file.startsWith('src/services/'),
|
||||
description: 'invoke( outside the services layer (src/services)',
|
||||
},
|
||||
}
|
||||
|
||||
const walk = (dir) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
return entries.flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) return walk(full)
|
||||
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
|
||||
})
|
||||
}
|
||||
|
||||
const selected = process.argv.slice(2)
|
||||
if (selected.length === 0) {
|
||||
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let violations = 0
|
||||
for (const gate of selected) {
|
||||
const rule = GATES[gate]
|
||||
if (!rule) {
|
||||
console.error(`unknown gate: ${gate}`)
|
||||
process.exit(2)
|
||||
}
|
||||
for (const file of walk(SRC)) {
|
||||
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
|
||||
if (rule.allowed(relative)) continue
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n')
|
||||
lines.forEach((line, index) => {
|
||||
if (rule.patterns.some((pattern) => pattern.test(line))) {
|
||||
violations += 1
|
||||
console.error(
|
||||
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (violations > 0) {
|
||||
console.error(`[arch] ${violations} violation(s) found`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[arch] no violations')
|
||||
@@ -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`,
|
||||
)
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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:#}"));
|
||||
|
||||
@@ -25,6 +25,10 @@ use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_deep_link::DeepLinkExt as _;
|
||||
|
||||
pub static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
|
||||
|
||||
// Re-exported for src/bin/frontend-contract.rs; the wire contract stays owned
|
||||
// by core::notify.
|
||||
pub use crate::core::notify::frontend_wire_contract;
|
||||
/// Application initialization helper functions
|
||||
mod app_init {
|
||||
use super::*;
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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?.()
|
||||
|
||||
// 刷新首页数据
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { isSortable } from '@dnd-kit/react/sortable'
|
||||
import { Add, NetworkCheck } from '@mui/icons-material'
|
||||
import { Box, IconButton, Tooltip, alpha, styled } from '@mui/material'
|
||||
import { emit } from '@tauri-apps/api/event'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -20,6 +19,7 @@ import { SortableItem } from '@/components/base'
|
||||
import { TestItem } from '@/components/test/test-item'
|
||||
import { TestViewer, type TestViewerRef } from '@/components/test/test-viewer'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { emitTestAll } from '@/services/bus'
|
||||
|
||||
import { EnhancedCard } from './enhanced-card'
|
||||
|
||||
@@ -178,7 +178,7 @@ export const TestCard = () => {
|
||||
)
|
||||
|
||||
const handleTestAll = useCallback(() => {
|
||||
emit('verge://test-all')
|
||||
emitTestAll()
|
||||
}, [])
|
||||
|
||||
const handleCreateTest = useCallback(() => {
|
||||
|
||||
@@ -8,18 +8,24 @@ import {
|
||||
type RunState,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useQuery } from '@/services/query-client'
|
||||
|
||||
import { ServiceMigrationDialog } from './service-migration-dialog'
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
runState: null as RunState | null,
|
||||
}))
|
||||
|
||||
vi.mock('react', () => ({ useState: vi.fn() }))
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
vi.mock('@mui/material', () => ({ Alert: 'div' }))
|
||||
vi.mock('@/components/base', () => ({ BaseDialog: 'dialog' }))
|
||||
vi.mock('@/hooks/use-visibility', () => ({ useVisibility: () => true }))
|
||||
vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] }))
|
||||
vi.mock('@/store/app-store-context', () => ({
|
||||
useAppReads: () => ({ readRunState: getRuntimeState }),
|
||||
useRunState: () => store.runState,
|
||||
}))
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
getRuntimeState: vi.fn(),
|
||||
installService: vi.fn(),
|
||||
@@ -28,13 +34,10 @@ vi.mock('@/services/cmds', () => ({
|
||||
vi.mock('@/services/notice-service', () => ({
|
||||
showNotice: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/services/query-client', () => ({
|
||||
useQuery: vi.fn(),
|
||||
setCacheData: vi.fn(),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.runState = null
|
||||
vi.mocked(useState)
|
||||
.mockReturnValueOnce([false, vi.fn()])
|
||||
.mockReturnValueOnce([false, vi.fn()])
|
||||
@@ -50,9 +53,7 @@ it.each([
|
||||
] satisfies Partial<RunState>[])(
|
||||
'does not offer Sidecar continuation from %j',
|
||||
(state) => {
|
||||
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
|
||||
typeof useQuery
|
||||
>)
|
||||
store.runState = state as unknown as RunState
|
||||
const dialog = ServiceMigrationDialog()
|
||||
expect(dialog.props.open).toBe(true)
|
||||
expect(dialog.props.disableCancel).toBe(true)
|
||||
@@ -60,9 +61,10 @@ it.each([
|
||||
)
|
||||
|
||||
it('reports the permission refusal and finishes after installation falls back to Sidecar', async () => {
|
||||
vi.mocked(useQuery).mockReturnValue({
|
||||
data: { service: 'notInstalled', mode: 'NotRunning' },
|
||||
} as ReturnType<typeof useQuery>)
|
||||
store.runState = {
|
||||
service: 'notInstalled',
|
||||
mode: 'NotRunning',
|
||||
} as unknown as RunState
|
||||
const reason = 'core path C:\\ is writable by Everyone'
|
||||
vi.mocked(installService).mockResolvedValue({ status: 'sidecar', reason })
|
||||
vi.mocked(getRuntimeState).mockResolvedValue({
|
||||
@@ -98,9 +100,7 @@ it.each([
|
||||
] satisfies Partial<RunState>[])(
|
||||
'offers Sidecar continuation from %j',
|
||||
(state) => {
|
||||
vi.mocked(useQuery).mockReturnValue({ data: state } as ReturnType<
|
||||
typeof useQuery
|
||||
>)
|
||||
store.runState = state as unknown as RunState
|
||||
expect(ServiceMigrationDialog().props.disableCancel).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,34 +3,25 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BaseDialog } from '@/components/base'
|
||||
import { runStateQueryKey } from '@/hooks/use-system-state'
|
||||
import { useVisibility } from '@/hooks/use-visibility'
|
||||
import {
|
||||
continueWithSidecar,
|
||||
getRuntimeState,
|
||||
installService,
|
||||
reinstallService,
|
||||
repairService,
|
||||
restartCore,
|
||||
type RunState,
|
||||
type ServiceInstallOutcome,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { setCacheData, useQuery } from '@/services/query-client'
|
||||
import { useAppReads, useRunState } from '@/store/app-store-context'
|
||||
|
||||
export const ServiceMigrationDialog = () => {
|
||||
const { t } = useTranslation()
|
||||
const pageVisible = useVisibility()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stateRefreshFailed, setStateRefreshFailed] = useState(false)
|
||||
const [workflowIncomplete, setWorkflowIncomplete] = useState(false)
|
||||
const { data: runState } = useQuery({
|
||||
queryKey: runStateQueryKey,
|
||||
queryFn: getRuntimeState,
|
||||
enabled: true,
|
||||
retry: 1,
|
||||
refetchInterval: pageVisible ? 30000 : false,
|
||||
})
|
||||
const { readRunState } = useAppReads()
|
||||
const runState = useRunState()
|
||||
// Whether the service needs a decision is derived once, in Rust, and travels with the
|
||||
// snapshot; a failed refresh is treated as needing one, since we cannot tell otherwise.
|
||||
const needsDecision =
|
||||
@@ -64,11 +55,10 @@ export const ServiceMigrationDialog = () => {
|
||||
runState.service === 'unavailable')),
|
||||
)
|
||||
|
||||
// One cache entry to refresh, so there is nothing left to keep coherent by hand.
|
||||
// One store entry to refresh, so there is nothing left to keep coherent by hand.
|
||||
const refreshRunState = async () => {
|
||||
try {
|
||||
const data = await getRuntimeState()
|
||||
await setCacheData<RunState>(runStateQueryKey, data)
|
||||
const data = await readRunState()
|
||||
setStateRefreshFailed(false)
|
||||
return data
|
||||
} catch (error) {
|
||||
@@ -83,11 +73,23 @@ export const ServiceMigrationDialog = () => {
|
||||
let outcome: ServiceInstallOutcome | undefined
|
||||
try {
|
||||
if (remedy === 'install') {
|
||||
outcome = await installService()
|
||||
const result = await mutate(() => installService(), {
|
||||
id: 'install-service',
|
||||
errorNotice: false,
|
||||
})
|
||||
if (result.ok) outcome = result.value
|
||||
} else if (remedy === 'repair') {
|
||||
outcome = await repairService()
|
||||
const result = await mutate(() => repairService(), {
|
||||
id: 'repair-service',
|
||||
errorNotice: false,
|
||||
})
|
||||
if (result.ok) outcome = result.value
|
||||
} else {
|
||||
outcome = await reinstallService()
|
||||
const result = await mutate(() => reinstallService(), {
|
||||
id: 'reinstall-service',
|
||||
errorNotice: false,
|
||||
})
|
||||
if (result.ok) outcome = result.value
|
||||
}
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
@@ -123,8 +125,11 @@ export const ServiceMigrationDialog = () => {
|
||||
|
||||
let restartSucceeded = false
|
||||
try {
|
||||
await restartCore()
|
||||
restartSucceeded = true
|
||||
const result = await mutate(() => restartCore(), {
|
||||
id: 'restart-core',
|
||||
errorNotice: false,
|
||||
})
|
||||
restartSucceeded = result.ok
|
||||
} catch (error) {
|
||||
showNotice.error(
|
||||
'layout.components.serviceMigration.errors.restartFailed',
|
||||
@@ -154,7 +159,10 @@ export const ServiceMigrationDialog = () => {
|
||||
setWorkflowIncomplete(true)
|
||||
let startupError: unknown
|
||||
try {
|
||||
await continueWithSidecar()
|
||||
await mutate(() => continueWithSidecar(), {
|
||||
id: 'continue-with-sidecar',
|
||||
errorNotice: false,
|
||||
})
|
||||
} catch (error) {
|
||||
startupError = error
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseDialog } from '@/components/base'
|
||||
import { useDialogFailure } from '@/pages/_layout/hooks'
|
||||
import {
|
||||
getRuntimeState,
|
||||
installService,
|
||||
patchVergeConfig,
|
||||
reinstallService,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
type PendingFailure,
|
||||
type ServiceInstallOutcome,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import {
|
||||
clearServiceRequest,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ServiceRequest,
|
||||
type ServiceRequestReason,
|
||||
} from '@/services/service-request'
|
||||
import { useAppReads } from '@/store/app-store-context'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
|
||||
@@ -83,6 +84,7 @@ const STEP_MESSAGE = {
|
||||
} as const
|
||||
|
||||
export const SysproxyPrivilegeDialog = () => {
|
||||
const { readRunState } = useAppReads()
|
||||
const { t } = useTranslation()
|
||||
const { failure, dismiss } = useDialogFailure()
|
||||
const asked = useSyncExternalStore(subscribeServiceRequest, getServiceRequest)
|
||||
@@ -106,10 +108,18 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
let outcome: ServiceInstallOutcome | undefined
|
||||
if (remedy === 'reinstallAndRestart') {
|
||||
setStep('installing')
|
||||
outcome = await reinstallService()
|
||||
const result = await mutate(() => reinstallService(), {
|
||||
id: 'reinstall-service',
|
||||
errorNotice: false,
|
||||
})
|
||||
if (result.ok) outcome = result.value
|
||||
} else if (remedy === 'installAndRestart') {
|
||||
setStep('installing')
|
||||
outcome = await installService()
|
||||
const result = await mutate(() => installService(), {
|
||||
id: 'install-service',
|
||||
errorNotice: false,
|
||||
})
|
||||
if (result.ok) outcome = result.value
|
||||
}
|
||||
if (outcome?.status === 'sidecar') {
|
||||
showNotice.warning(
|
||||
@@ -121,9 +131,12 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
return
|
||||
}
|
||||
setStep('restarting')
|
||||
await restartCore()
|
||||
await mutate(() => restartCore(), {
|
||||
id: 'restart-core',
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
const runState = await getRuntimeState()
|
||||
const runState = await readRunState()
|
||||
const usingAdminFallback =
|
||||
remedy !== 'reinstallAndRestart' &&
|
||||
getSystem() === 'windows' &&
|
||||
@@ -134,7 +147,10 @@ export const SysproxyPrivilegeDialog = () => {
|
||||
if (runState.mode === 'Service' || usingAdminFallback) {
|
||||
if (restoring !== undefined) {
|
||||
setStep('applying')
|
||||
await patchVergeConfig(restoring)
|
||||
await mutate(() => patchVergeConfig(restoring), {
|
||||
id: 'sysproxy-privilege-restore',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
if (!usingAdminFallback) {
|
||||
showNotice.success(
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
readProfileFile,
|
||||
saveProfileFile,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||
@@ -472,7 +473,11 @@ export const GroupsEditorViewer = (props: Props) => {
|
||||
setCurrData(nextData)
|
||||
}
|
||||
|
||||
if (!(await saveProfileFile(property, nextData))) {
|
||||
const result = await mutate(() => saveProfileFile(property, nextData), {
|
||||
id: `save-profile-file:${property}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
if (!(result.ok && result.value === true)) {
|
||||
await fetchContent()
|
||||
onClose()
|
||||
return
|
||||
|
||||
@@ -39,8 +39,12 @@ import {
|
||||
updateProfile,
|
||||
viewProfile,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useLoadingCache, useSetLoadingCache } from '@/services/states'
|
||||
import {
|
||||
useProfileLoadingCache,
|
||||
useSetProfileLoading,
|
||||
} from '@/store/app-store-context'
|
||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
@@ -105,8 +109,8 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||
const [position, setPosition] = useState({ left: 0, top: 0 })
|
||||
const loadingCache = useLoadingCache()
|
||||
const setLoadingCache = useSetLoadingCache()
|
||||
const loadingCache = useProfileLoadingCache()
|
||||
const setProfileLoading = useSetProfileLoading()
|
||||
|
||||
const [showNextUpdate, setShowNextUpdate] = useState(false)
|
||||
const showNextUpdateRef = useRef(false)
|
||||
@@ -116,17 +120,9 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
)
|
||||
const setLoading = useCallback(
|
||||
(loading: boolean) => {
|
||||
setLoadingCache((cache) => {
|
||||
const next = new Set(cache)
|
||||
if (loading) {
|
||||
next.add(itemData.uid)
|
||||
} else {
|
||||
next.delete(itemData.uid)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setProfileLoading([itemData.uid], loading)
|
||||
},
|
||||
[itemData.uid, setLoadingCache],
|
||||
[itemData.uid, setProfileLoading],
|
||||
)
|
||||
|
||||
const { uid, name = 'Profile', extra, updated = 0, option } = itemData
|
||||
@@ -374,7 +370,10 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
const onOpenFile = useLockFn(async () => {
|
||||
setAnchorEl(null)
|
||||
try {
|
||||
await viewProfile(itemData.uid)
|
||||
await mutate(() => viewProfile(itemData.uid), {
|
||||
id: 'view-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
} catch (err) {
|
||||
showNotice.error(err)
|
||||
}
|
||||
@@ -403,7 +402,10 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
|
||||
try {
|
||||
const payload = Object.keys(option).length > 0 ? option : undefined
|
||||
await updateProfile(itemData.uid, payload)
|
||||
await mutate(() => updateProfile(itemData.uid, payload), {
|
||||
id: `update-profile:${itemData.uid}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
void mutateProfiles()
|
||||
} catch {
|
||||
@@ -591,7 +593,11 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
|
||||
const handleSaveProfileDocument = useLockFn(async () => {
|
||||
const currentValue = profileDocument.value
|
||||
if (!(await saveProfileFile(uid, currentValue))) {
|
||||
const result = await mutate(() => saveProfileFile(uid, currentValue), {
|
||||
id: `save-profile-file:${uid}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
if (!(result.ok && result.value === true)) {
|
||||
await profileDocument.reload()
|
||||
return
|
||||
}
|
||||
@@ -602,7 +608,11 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
const handleSaveMergeDocument = useLockFn(async () => {
|
||||
const mergeUid = option?.merge ?? ''
|
||||
const currentValue = mergeDocument.value
|
||||
if (!(await saveProfileFile(mergeUid, currentValue))) {
|
||||
const result = await mutate(() => saveProfileFile(mergeUid, currentValue), {
|
||||
id: `save-profile-file:${mergeUid}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
if (!(result.ok && result.value === true)) {
|
||||
await mergeDocument.reload()
|
||||
return
|
||||
}
|
||||
@@ -613,7 +623,14 @@ const ProfileItemBase = (props: ProfileItemProps) => {
|
||||
const handleSaveScriptDocument = useLockFn(async () => {
|
||||
const scriptUid = option?.script ?? ''
|
||||
const currentValue = scriptDocument.value
|
||||
if (!(await saveProfileFile(scriptUid, currentValue))) {
|
||||
const result = await mutate(
|
||||
() => saveProfileFile(scriptUid, currentValue),
|
||||
{
|
||||
id: `save-profile-file:${scriptUid}`,
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
if (!(result.ok && result.value === true)) {
|
||||
await scriptDocument.reload()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseDialog, Switch } from '@/components/base'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { createProfile, patchProfile } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { version } from '@root/package.json'
|
||||
|
||||
@@ -128,26 +129,38 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
|
||||
|
||||
if (!isRemote) {
|
||||
if (openType === 'new') {
|
||||
await createProfile(item, fileDataRef.current)
|
||||
await mutate(() => createProfile(item, fileDataRef.current), {
|
||||
id: 'create-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
} else {
|
||||
if (!form.uid) {
|
||||
throw new Error(
|
||||
t('profiles.modals.profileForm.errors.uidMissing'),
|
||||
)
|
||||
}
|
||||
await patchProfile(form.uid, item)
|
||||
await mutate(() => patchProfile(form.uid, item), {
|
||||
id: 'patch-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (openType === 'new') {
|
||||
await createProfile(item, fileDataRef.current)
|
||||
await mutate(() => createProfile(item, fileDataRef.current), {
|
||||
id: 'create-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
} else {
|
||||
if (!form.uid) {
|
||||
throw new Error(
|
||||
t('profiles.modals.profileForm.errors.uidMissing'),
|
||||
)
|
||||
}
|
||||
await patchProfile(form.uid, item)
|
||||
await mutate(() => patchProfile(form.uid, item), {
|
||||
id: 'patch-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
showNotice.info(
|
||||
@@ -164,16 +177,31 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
|
||||
}
|
||||
|
||||
if (openType === 'new') {
|
||||
await createProfile(retryItem, fileDataRef.current)
|
||||
await mutate(
|
||||
() => createProfile(retryItem, fileDataRef.current),
|
||||
{
|
||||
id: 'create-profile',
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (!form.uid) {
|
||||
throw new Error(
|
||||
t('profiles.modals.profileForm.errors.uidMissing'),
|
||||
)
|
||||
}
|
||||
await patchProfile(form.uid, retryItem)
|
||||
await mutate(() => patchProfile(form.uid, retryItem), {
|
||||
id: 'patch-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
await patchProfile(form.uid, { option: originalOptions })
|
||||
await mutate(
|
||||
() => patchProfile(form.uid, { option: originalOptions }),
|
||||
{
|
||||
id: 'patch-profile',
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
showNotice.success(
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseSearchBox, MonacoEditor } from '@/components/base'
|
||||
import { ProxyItem } from '@/components/profile/proxy-item'
|
||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import type { MonacoEditorInstance } from '@/types/monaco'
|
||||
@@ -329,7 +330,11 @@ export const ProxiesEditorViewer = (props: Props) => {
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
try {
|
||||
if (!(await saveProfileFile(property, currData))) {
|
||||
const result = await mutate(() => saveProfileFile(property, currData), {
|
||||
id: `save-profile-file:${property}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
if (!(result.ok && result.value === true)) {
|
||||
await fetchContent()
|
||||
onClose()
|
||||
return
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
|
||||
import { RuleItem } from '@/components/profile/rule-item'
|
||||
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||
@@ -570,7 +571,11 @@ export const RulesEditorViewer = (props: Props) => {
|
||||
|
||||
const handleSave = useLockFn(async () => {
|
||||
try {
|
||||
if (!(await saveProfileFile(property, currData))) {
|
||||
const result = await mutate(() => saveProfileFile(property, currData), {
|
||||
id: `save-profile-file:${property}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
if (!(result.ok && result.value === true)) {
|
||||
await fetchContent()
|
||||
onClose()
|
||||
return
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useRuntimeConfig } from '@/hooks/use-clash'
|
||||
import { useRecordSelection } from '@/hooks/use-record-selection'
|
||||
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
|
||||
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import {
|
||||
selectGlobalChainNodes,
|
||||
selectRuleChainMembers,
|
||||
@@ -444,7 +445,10 @@ export const ProxyChain = ({
|
||||
if (isConnected) {
|
||||
setIsConnecting(true)
|
||||
try {
|
||||
await updateProxyChainConfigInRuntime(null)
|
||||
await mutate(() => updateProxyChainConfigInRuntime(null), {
|
||||
id: 'update-proxy-chain-runtime',
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
const targetGroup =
|
||||
mode === 'global'
|
||||
@@ -453,12 +457,22 @@ export const ProxyChain = ({
|
||||
|
||||
if (targetGroup) {
|
||||
try {
|
||||
await selectNodeForGroup(targetGroup, 'DIRECT')
|
||||
await mutate(() => selectNodeForGroup(targetGroup, 'DIRECT'), {
|
||||
id: `select-proxy-node:${targetGroup}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
recordSelection(targetGroup, 'DIRECT')
|
||||
} catch {
|
||||
if (currentProxyChain.length >= 1) {
|
||||
try {
|
||||
await selectNodeForGroup(targetGroup, currentProxyChain[0].name)
|
||||
await mutate(
|
||||
() =>
|
||||
selectNodeForGroup(targetGroup, currentProxyChain[0].name),
|
||||
{
|
||||
id: `select-proxy-node:${targetGroup}`,
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
recordSelection(targetGroup, currentProxyChain[0].name)
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -471,7 +485,10 @@ export const ProxyChain = ({
|
||||
localStorage.removeItem('proxy-chain-exit-node')
|
||||
localStorage.removeItem('proxy-chain-items')
|
||||
|
||||
await closeAllConnections()
|
||||
await mutate(() => closeAllConnections(), {
|
||||
id: 'close-all-connections',
|
||||
errorNotice: false,
|
||||
})
|
||||
await refreshProxy()
|
||||
|
||||
onUpdateChain([])
|
||||
@@ -502,7 +519,10 @@ export const ProxyChain = ({
|
||||
// 第一步:保存链式代理配置
|
||||
const chainProxies = currentProxyChain.map((node) => node.name)
|
||||
debugLog('Saving chain config:', chainProxies)
|
||||
await updateProxyChainConfigInRuntime(chainProxies)
|
||||
await mutate(() => updateProxyChainConfigInRuntime(chainProxies), {
|
||||
id: 'update-proxy-chain-runtime',
|
||||
errorNotice: false,
|
||||
})
|
||||
debugLog('Chain configuration saved successfully')
|
||||
|
||||
// 第二步:连接到代理链的最后一个节点
|
||||
@@ -516,7 +536,13 @@ export const ProxyChain = ({
|
||||
|
||||
const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup
|
||||
|
||||
await selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name)
|
||||
await mutate(
|
||||
() => selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name),
|
||||
{
|
||||
id: `select-proxy-node:${targetGroup || 'GLOBAL'}`,
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
// The chain moves the group like any other selection, so the profile has to learn about
|
||||
// it: what the profile holds is what gets re-applied the next time the core starts.
|
||||
recordSelection(targetGroup || 'GLOBAL', lastNode.name)
|
||||
@@ -593,7 +619,10 @@ export const ProxyChain = ({
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
updateProxyChainConfigInRuntime(null)
|
||||
mutate(() => updateProxyChainConfigInRuntime(null), {
|
||||
id: 'update-proxy-chain-runtime',
|
||||
errorNotice: false,
|
||||
})
|
||||
localStorage.removeItem('proxy-chain-group')
|
||||
localStorage.removeItem('proxy-chain-exit-node')
|
||||
localStorage.removeItem('proxy-chain-items')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -23,6 +23,7 @@ import { updateRuleProvider } from 'tauri-plugin-mihomo-api'
|
||||
|
||||
import { useAppRefreshers, useRulesData } from '@/providers/app-data-context'
|
||||
import { syncRuntimeProviders } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
|
||||
@@ -50,11 +51,17 @@ export const ProviderButton = () => {
|
||||
try {
|
||||
setUpdating((prev) => ({ ...prev, [name]: true }))
|
||||
|
||||
await updateRuleProvider(name)
|
||||
await mutate(() => updateRuleProvider(name), {
|
||||
id: `update-rule-provider-${name}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
await refreshRules()
|
||||
await refreshRuleProviders()
|
||||
void syncRuntimeProviders()
|
||||
void mutate(() => syncRuntimeProviders(), {
|
||||
id: 'sync-runtime-providers',
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
showNotice.success(
|
||||
'rules.feedback.notifications.provider.updateSuccess',
|
||||
@@ -91,7 +98,10 @@ export const ProviderButton = () => {
|
||||
|
||||
for (const name of allProviders) {
|
||||
try {
|
||||
await updateRuleProvider(name)
|
||||
await mutate(() => updateRuleProvider(name), {
|
||||
id: `update-rule-provider-${name}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
setUpdating((prev) => ({ ...prev, [name]: false }))
|
||||
} catch (err) {
|
||||
console.error(`更新 ${name} 失败`, err)
|
||||
@@ -100,7 +110,10 @@ export const ProviderButton = () => {
|
||||
|
||||
await refreshRules()
|
||||
await refreshRuleProviders()
|
||||
void syncRuntimeProviders()
|
||||
void mutate(() => syncRuntimeProviders(), {
|
||||
id: 'sync-runtime-providers',
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
showNotice.success('rules.feedback.notifications.provider.allUpdated')
|
||||
} catch (err) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { saveWebdavConfig, createWebdavBackup } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { errorDetail, showNotice } from '@/services/notice-service'
|
||||
import {
|
||||
buildWebdavSignature,
|
||||
@@ -119,7 +120,13 @@ export const BackupConfigViewer = memo(
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await saveWebdavConfig(trimmedUrl, trimmedUsername, data.password)
|
||||
await mutate(
|
||||
() => saveWebdavConfig(trimmedUrl, trimmedUsername, data.password),
|
||||
{
|
||||
id: 'save-webdav-config',
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
await mutateVerge(
|
||||
(current) =>
|
||||
current
|
||||
@@ -156,7 +163,10 @@ export const BackupConfigViewer = memo(
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await createWebdavBackup().then(async () => {
|
||||
await mutate(() => createWebdavBackup(), {
|
||||
id: 'create-webdav-backup',
|
||||
errorNotice: false,
|
||||
}).then(async () => {
|
||||
showNotice.success('settings.modals.backup.messages.backupCreated')
|
||||
await onBackupSuccess()
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
restoreLocalBackup,
|
||||
restoreWebDavBackup,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import {
|
||||
buildWebdavSignature,
|
||||
@@ -228,23 +229,38 @@ export const BackupHistoryViewer = ({
|
||||
try {
|
||||
if (action === 'delete') {
|
||||
if (actionIsLocal) {
|
||||
await deleteLocalBackup(filename)
|
||||
await mutate(() => deleteLocalBackup(filename), {
|
||||
id: 'delete-local-backup',
|
||||
errorNotice: false,
|
||||
})
|
||||
} else {
|
||||
await deleteWebdavBackup(filename)
|
||||
await mutate(() => deleteWebdavBackup(filename), {
|
||||
id: 'delete-webdav-backup',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
setPendingConfirmation(null)
|
||||
await fetchRows()
|
||||
} else {
|
||||
if (actionIsLocal) {
|
||||
await restoreLocalBackup(filename)
|
||||
await mutate(() => restoreLocalBackup(filename), {
|
||||
id: 'restore-local-backup',
|
||||
errorNotice: false,
|
||||
})
|
||||
} else {
|
||||
await restoreWebDavBackup(filename)
|
||||
await mutate(() => restoreWebDavBackup(filename), {
|
||||
id: 'restore-webdav-backup',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
setPendingConfirmation(null)
|
||||
showNotice.success('settings.modals.backup.messages.restoreSuccess')
|
||||
setIsRestarting(true)
|
||||
window.setTimeout(() => {
|
||||
void restartApp().catch((err: unknown) => {
|
||||
void mutate(() => restartApp(), {
|
||||
id: 'restart-app',
|
||||
errorNotice: false,
|
||||
}).catch((err: unknown) => {
|
||||
setIsRestarting(false)
|
||||
showNotice.error(err)
|
||||
})
|
||||
@@ -265,8 +281,11 @@ export const BackupHistoryViewer = ({
|
||||
const savePath = await save({ defaultPath: filename })
|
||||
if (!savePath || Array.isArray(savePath)) return
|
||||
try {
|
||||
await exportLocalBackup(filename, savePath)
|
||||
showNotice.success('settings.modals.backup.messages.localBackupExported')
|
||||
await mutate(() => exportLocalBackup(filename, savePath), {
|
||||
id: 'export-local-backup',
|
||||
errorNotice: false,
|
||||
successNotice: 'settings.modals.backup.messages.localBackupExported',
|
||||
})
|
||||
} catch (ignoreError: unknown) {
|
||||
showNotice.error(
|
||||
'settings.modals.backup.messages.localBackupExportFailed',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createWebdavBackup,
|
||||
importLocalBackup,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { errorDetail, showNotice } from '@/services/notice-service'
|
||||
import { buildWebdavSignature, setWebdavStatus } from '@/services/webdav-status'
|
||||
|
||||
@@ -55,12 +56,18 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
try {
|
||||
setBusyAction(target)
|
||||
if (target === 'local') {
|
||||
await createLocalBackup()
|
||||
showNotice.success('settings.modals.backup.messages.localBackupCreated')
|
||||
await mutate(() => createLocalBackup(), {
|
||||
id: 'create-local-backup',
|
||||
errorNotice: false,
|
||||
successNotice: 'settings.modals.backup.messages.localBackupCreated',
|
||||
})
|
||||
} else {
|
||||
await createWebdavBackup()
|
||||
showNotice.success('settings.modals.backup.messages.backupCreated')
|
||||
setWebdavStatus(webdavSignature, 'ready')
|
||||
await mutate(() => createWebdavBackup(), {
|
||||
id: 'create-webdav-backup',
|
||||
errorNotice: false,
|
||||
successNotice: 'settings.modals.backup.messages.backupCreated',
|
||||
onFulfilled: () => setWebdavStatus(webdavSignature, 'ready'),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -86,8 +93,11 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
try {
|
||||
setLocalImporting(true)
|
||||
await importLocalBackup(selected)
|
||||
showNotice.success('settings.modals.backup.messages.localBackupImported')
|
||||
await mutate(() => importLocalBackup(selected), {
|
||||
id: 'import-local-backup',
|
||||
errorNotice: false,
|
||||
successNotice: 'settings.modals.backup.messages.localBackupImported',
|
||||
})
|
||||
openHistory('local')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { BaseDialog, DialogRef } from '@/components/base'
|
||||
import { useClash, useClashInfo } from '@/hooks/use-clash'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { changeClashCore, restartCore, upgradeClashCore } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
const VALID_CORE = [
|
||||
@@ -60,8 +61,16 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
|
||||
try {
|
||||
setChangingCore(core)
|
||||
closeAllConnections()
|
||||
const errorMsg = await changeClashCore(core)
|
||||
void mutate(() => closeAllConnections(), {
|
||||
id: 'close-all-connections',
|
||||
errorNotice: false,
|
||||
})
|
||||
const result = await mutate(() => changeClashCore(core), {
|
||||
id: 'change-clash-core',
|
||||
errorNotice: false,
|
||||
})
|
||||
if (!result.ok) return
|
||||
const errorMsg = result.value
|
||||
|
||||
if (errorMsg) {
|
||||
showNotice.error(errorMsg)
|
||||
@@ -83,28 +92,33 @@ export function ClashCoreViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
const onRestart = useLockFn(async () => {
|
||||
try {
|
||||
setRestarting(true)
|
||||
await restartCore()
|
||||
showNotice.success(
|
||||
t('settings.feedback.notifications.clash.restartSuccess'),
|
||||
)
|
||||
await mutate(() => restartCore(), {
|
||||
id: 'restart-core',
|
||||
successNotice: t(
|
||||
'settings.feedback.notifications.clash.restartSuccess',
|
||||
),
|
||||
})
|
||||
setRestarting(false)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setRestarting(false)
|
||||
showNotice.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
const onUpgrade = useLockFn(async () => {
|
||||
try {
|
||||
setUpgrading(true)
|
||||
const report = await upgradeClashCore()
|
||||
showNotice.success(
|
||||
report.upgraded
|
||||
? t('settings.feedback.notifications.clash.versionUpdated')
|
||||
: t('settings.feedback.notifications.clash.alreadyLatestVersion'),
|
||||
)
|
||||
} catch (err) {
|
||||
showNotice.error(err)
|
||||
await mutate(() => upgradeClashCore(), {
|
||||
id: 'upgrade-clash-core',
|
||||
onFulfilled: (report) => {
|
||||
showNotice.success(
|
||||
report.upgraded
|
||||
? t('settings.feedback.notifications.clash.versionUpdated')
|
||||
: t('settings.feedback.notifications.clash.alreadyLatestVersion'),
|
||||
)
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// the funnel already notified
|
||||
} finally {
|
||||
setUpgrading(false)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useClashInfo } from '@/hooks/use-clash'
|
||||
import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { type ProxyPortSettings, saveProxyPorts } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
||||
import getSystem from '@/utils/get-system'
|
||||
@@ -165,22 +166,31 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
|
||||
) => setPorts((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }))
|
||||
|
||||
// 添加保存请求,防止GUI卡死
|
||||
const { loading, runAsync: saveSettings } = useRequest(saveProxyPorts, {
|
||||
manual: true,
|
||||
onSuccess: (outcome) => {
|
||||
if (outcome.status === 'conflict') {
|
||||
showNotice.error('settings.modals.clashPort.messages.portInUse', {
|
||||
port: outcome.port,
|
||||
})
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
showNotice.success('settings.modals.clashPort.messages.saved')
|
||||
const { loading, runAsync: saveSettings } = useRequest(
|
||||
async (settings: ProxyPortSettings) =>
|
||||
mutate(() => saveProxyPorts(settings), {
|
||||
id: 'save-proxy-ports',
|
||||
errorNotice: false,
|
||||
}),
|
||||
{
|
||||
manual: true,
|
||||
onSuccess: (result) => {
|
||||
if (!result.ok) return
|
||||
const outcome = result.value
|
||||
if (outcome.status === 'conflict') {
|
||||
showNotice.error('settings.modals.clashPort.messages.portInUse', {
|
||||
port: outcome.port,
|
||||
})
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
showNotice.success('settings.modals.clashPort.messages.saved')
|
||||
},
|
||||
onError: (error) => {
|
||||
showNotice.error('settings.modals.clashPort.messages.saveFailed', error)
|
||||
},
|
||||
},
|
||||
onError: (error) => {
|
||||
showNotice.error('settings.modals.clashPort.messages.saveFailed', error)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import * as yaml from 'js-yaml'
|
||||
import type { Ref } from 'react'
|
||||
@@ -36,6 +35,13 @@ import {
|
||||
import { useClash } from '@/hooks/use-clash'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import {
|
||||
applyDnsConfig,
|
||||
getDnsConfigContent,
|
||||
saveDnsConfig,
|
||||
validateDnsConfig,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import type { MonacoEditorInstance } from '@/types/monaco'
|
||||
@@ -465,10 +471,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
|
||||
const initDnsConfig = useCallback(async () => {
|
||||
try {
|
||||
const dnsConfig = await invoke<string | null>(
|
||||
'get_dns_config_content',
|
||||
{},
|
||||
)
|
||||
const dnsConfig = await getDnsConfigContent()
|
||||
|
||||
if (dnsConfig !== null) {
|
||||
const config = yaml.load(dnsConfig) as any
|
||||
@@ -517,12 +520,12 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
config = parsedConfig as Record<string, any>
|
||||
}
|
||||
|
||||
await invoke('save_dns_config', { dnsConfig: config })
|
||||
await mutate(() => saveDnsConfig(config), {
|
||||
id: 'save-dns-config',
|
||||
errorNotice: false,
|
||||
})
|
||||
|
||||
const validation = await invoke<ValidationOutcome>(
|
||||
'validate_dns_config',
|
||||
{},
|
||||
)
|
||||
const validation = await validateDnsConfig()
|
||||
|
||||
if (validation.status !== 'valid') {
|
||||
const errorMsg =
|
||||
@@ -559,7 +562,10 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
}
|
||||
|
||||
if (dnsEnabled) {
|
||||
await invoke('apply_dns_config', { apply: true })
|
||||
await mutate(() => applyDnsConfig(true), {
|
||||
id: 'apply-dns-config',
|
||||
errorNotice: false,
|
||||
})
|
||||
mutateClash()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseDialog, Switch } from '@/components/base'
|
||||
import { useClash } from '@/hooks/use-clash'
|
||||
import { restartCore } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
// Development origins must never be persisted into production configuration.
|
||||
@@ -135,7 +136,10 @@ export const HeaderConfiguration = forwardRef<ClashHeaderConfigingRef>(
|
||||
),
|
||||
},
|
||||
})
|
||||
await restartCore()
|
||||
await mutate(() => restartCore(), {
|
||||
id: 'restart-core',
|
||||
errorNotice: false,
|
||||
})
|
||||
await mutateClash()
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DEFAULT_HOVER_DELAY } from '@/components/proxy/proxy-group-navigator'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useWindowDecorations } from '@/hooks/use-window'
|
||||
import { copyIconFile, getAppDir } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
@@ -536,7 +537,9 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
||||
})
|
||||
|
||||
if (selected) {
|
||||
await copyIconFile(`${selected}`, 'common')
|
||||
await mutate(() => copyIconFile(`${selected}`, 'common'), {
|
||||
id: 'copy-icon:common',
|
||||
})
|
||||
await initIconPath()
|
||||
onChangeData({ common_tray_icon: true })
|
||||
patchVerge({ common_tray_icon: true })
|
||||
@@ -588,7 +591,10 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
||||
],
|
||||
})
|
||||
if (selected) {
|
||||
await copyIconFile(`${selected}`, 'sysproxy')
|
||||
await mutate(
|
||||
() => copyIconFile(`${selected}`, 'sysproxy'),
|
||||
{ id: 'copy-icon:sysproxy' },
|
||||
)
|
||||
await initIconPath()
|
||||
onChangeData({ sysproxy_tray_icon: true })
|
||||
patchVerge({ sysproxy_tray_icon: true })
|
||||
@@ -636,7 +642,9 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
||||
],
|
||||
})
|
||||
if (selected) {
|
||||
await copyIconFile(`${selected}`, 'tun')
|
||||
await mutate(() => copyIconFile(`${selected}`, 'tun'), {
|
||||
id: 'copy-icon:tun',
|
||||
})
|
||||
await initIconPath()
|
||||
onChangeData({ tun_tray_icon: true })
|
||||
patchVerge({ tun_tray_icon: true })
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { entry_lightweight_mode } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
@@ -72,7 +73,12 @@ export function LiteModeViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
color: 'primary.main',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
onClick={async () => await entry_lightweight_mode()}
|
||||
onClick={async () =>
|
||||
await mutate(() => entry_lightweight_mode(), {
|
||||
id: 'entry-lightweight-mode',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('shared.actions.enable')}
|
||||
</Typography>
|
||||
|
||||
@@ -36,22 +36,14 @@ import { useSystemProxyState } from '@/hooks/use-system-proxy-state'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useSystemData } from '@/providers/app-data-context'
|
||||
import {
|
||||
getAutotemProxy,
|
||||
getEmbeddedServerPort,
|
||||
getNetworkInterfacesInfo,
|
||||
getSystemHostname,
|
||||
getSystemProxy,
|
||||
patchVergeConfig,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
|
||||
const DEFAULT_PAC = `function FindProxyForURL(url, host) {
|
||||
return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;";
|
||||
}`
|
||||
@@ -344,13 +336,6 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
patch.proxy_host = proxyHost
|
||||
}
|
||||
|
||||
const needResetProxy =
|
||||
value.pac !== proxy_auto_config ||
|
||||
proxyHost !== proxy_host ||
|
||||
pacContent !== pac_file_content ||
|
||||
value.bypass !== system_proxy_bypass ||
|
||||
value.use_default !== use_default_bypass
|
||||
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
if (Object.keys(patch).length > 0) {
|
||||
@@ -359,31 +344,11 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await patchVerge(patch)
|
||||
}
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await invalidateProxyState()
|
||||
|
||||
if (needResetProxy && enabled) {
|
||||
const [currentSysProxy, currentAutoProxy] = await Promise.all([
|
||||
getSystemProxy(),
|
||||
getAutotemProxy(),
|
||||
])
|
||||
|
||||
const isProxyActive = value.pac
|
||||
? currentAutoProxy?.enable
|
||||
: currentSysProxy?.enable
|
||||
|
||||
if (isProxyActive) {
|
||||
await patchVergeConfig({ enable_system_proxy: false })
|
||||
await sleep(50)
|
||||
await patchVergeConfig({ enable_system_proxy: true })
|
||||
await invalidateProxyState()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('代理状态更新失败:', err)
|
||||
}
|
||||
}, 50)
|
||||
try {
|
||||
await invalidateProxyState()
|
||||
} catch (err) {
|
||||
console.warn('代理状态更新失败:', err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('配置保存失败:', err)
|
||||
mutateVerge()
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@/components/base'
|
||||
import { useClash } from '@/hooks/use-clash'
|
||||
import { enhanceProfiles } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import getSystem from '@/utils/get-system'
|
||||
import { areValidIpCidrs } from '@/utils/network'
|
||||
@@ -129,7 +130,10 @@ export function TunViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
)
|
||||
setOpen(false)
|
||||
showNotice.success('settings.modals.tun.messages.applied')
|
||||
void enhanceProfiles().catch((err: any) => {
|
||||
void mutate(() => enhanceProfiles(), {
|
||||
id: 'enhance-profiles',
|
||||
errorNotice: false,
|
||||
}).catch((err: any) => {
|
||||
showNotice.error(err)
|
||||
})
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
installUpdate,
|
||||
restartApp,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useSetUpdateState, useUpdateState } from '@/services/states'
|
||||
import { openExternalUrl } from '@/utils/open-external-url'
|
||||
@@ -274,8 +275,18 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
}
|
||||
|
||||
try {
|
||||
if (await installUpdate(updateInfo.version, onDownloadEvent)) {
|
||||
await restartApp()
|
||||
const result = await mutate(
|
||||
() => installUpdate(updateInfo.version, onDownloadEvent),
|
||||
{
|
||||
id: 'install-app-update',
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
if (result.ok && result.value) {
|
||||
await mutate(() => restartApp(), {
|
||||
id: 'restart-app',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
showNotice.error(err)
|
||||
@@ -341,7 +352,10 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
onClose={() => setOpen(false)}
|
||||
onCancel={() => {
|
||||
if (updateState) {
|
||||
void cancelUpdateDownload().catch(showNotice.error)
|
||||
void mutate(() => cancelUpdateDownload(), {
|
||||
id: 'cancel-update-download',
|
||||
errorNotice: false,
|
||||
}).catch(showNotice.error)
|
||||
}
|
||||
setOpen(false)
|
||||
}}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { invoke_uwp_tool, setDnsOverride } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
@@ -74,7 +75,10 @@ const SettingClash = ({ onError }: Props) => {
|
||||
}
|
||||
const onUpdateGeo = async () => {
|
||||
try {
|
||||
await updateGeo()
|
||||
await mutate(() => updateGeo(), {
|
||||
id: 'update-geo',
|
||||
errorNotice: false,
|
||||
})
|
||||
showNotice.success('settings.feedback.notifications.clash.geoDataUpdated')
|
||||
} catch (err: any) {
|
||||
showNotice.error(err)
|
||||
@@ -90,7 +94,15 @@ const SettingClash = ({ onError }: Props) => {
|
||||
if (!profileUid) return
|
||||
setDnsUpdating(true)
|
||||
try {
|
||||
const outcome = await setDnsOverride(profileUid, enable, confirmation)
|
||||
const result = await mutate(
|
||||
() => setDnsOverride(profileUid, enable, confirmation),
|
||||
{
|
||||
id: 'set-dns-override',
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
if (!result.ok) return
|
||||
const outcome = result.value
|
||||
if (outcome.status === 'confirmation_required') {
|
||||
setDnsConfirmation({ profileUid, source: outcome.source })
|
||||
} else {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
openDevTools,
|
||||
openLogsDir,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { checkUpdateSafe as checkUpdate } from '@/services/update'
|
||||
import { version } from '@root/package.json'
|
||||
@@ -61,7 +62,10 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
|
||||
}
|
||||
|
||||
const onExportDiagnosticInfo = useCallback(async () => {
|
||||
await exportDiagnosticInfo()
|
||||
await mutate(() => exportDiagnosticInfo(), {
|
||||
id: 'export-diagnostic-info',
|
||||
errorNotice: false,
|
||||
})
|
||||
showNotice.success('shared.feedback.notifications.common.copySuccess', 1000)
|
||||
}, [])
|
||||
|
||||
@@ -128,7 +132,12 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
|
||||
/>
|
||||
|
||||
<SettingItem
|
||||
onClick={openDevTools}
|
||||
onClick={() => {
|
||||
void mutate(() => openDevTools(), {
|
||||
id: 'open-devtools',
|
||||
errorNotice: false,
|
||||
}).catch(() => {})
|
||||
}}
|
||||
label={t('settings.components.verge.advanced.fields.openDevTools')}
|
||||
/>
|
||||
|
||||
@@ -145,7 +154,10 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => {
|
||||
|
||||
<SettingItem
|
||||
onClick={() => {
|
||||
exitApp()
|
||||
void mutate(() => exitApp(), {
|
||||
id: 'exit-app',
|
||||
errorNotice: false,
|
||||
}).catch(() => {})
|
||||
}}
|
||||
label={t('settings.components.verge.advanced.fields.exit')}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { LanguageRounded } from '@mui/icons-material'
|
||||
import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BaseLoading } from '@/components/base'
|
||||
import { useIconCache } from '@/hooks/use-icon-cache'
|
||||
import { cmdTestDelay } from '@/services/cmds'
|
||||
import delayManager from '@/services/delay'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { useTestAllCounter } from '@/store/app-store-context'
|
||||
|
||||
import { TestBox } from './test-box'
|
||||
|
||||
@@ -52,10 +52,19 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => {
|
||||
{ label: t('shared.actions.delete'), handler: onDelete },
|
||||
]
|
||||
|
||||
useEffect(
|
||||
() => subscribeVergeEvents({ 'verge://test-all': () => onDelay() }),
|
||||
[url, onDelay],
|
||||
)
|
||||
// Store bump replaces the per-item event subscription; skip the initial
|
||||
// mount value and call through a ref so url changes do not re-trigger tests.
|
||||
const testAllCounter = useTestAllCounter()
|
||||
const onDelayRef = useRef(onDelay)
|
||||
onDelayRef.current = onDelay
|
||||
const mountedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true
|
||||
return
|
||||
}
|
||||
void onDelayRef.current()
|
||||
}, [testAllCounter])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
|
||||
+11
-5
@@ -8,6 +8,7 @@ import {
|
||||
getRuntimeConfig,
|
||||
patchClashConfig,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import {
|
||||
revalidateQuery,
|
||||
setCacheData,
|
||||
@@ -104,8 +105,11 @@ export const useClash = () => {
|
||||
}
|
||||
|
||||
const patchClash = useLockFn(async (patch: Partial<IConfigData>) => {
|
||||
await patchClashConfig(patch)
|
||||
mutateClash()
|
||||
await mutate(() => patchClashConfig(patch), {
|
||||
id: 'patch-clash-config',
|
||||
revalidate: [['getRuntimeConfig']],
|
||||
errorNotice: false,
|
||||
})
|
||||
})
|
||||
|
||||
const version = versionData?.meta
|
||||
@@ -132,9 +136,11 @@ export const useClashInfo = () => {
|
||||
|
||||
validatePorts(patch)
|
||||
|
||||
await patchClashConfig(patch)
|
||||
mutateInfo()
|
||||
revalidateQuery(['getClashConfig'])
|
||||
await mutate(() => patchClashConfig(patch), {
|
||||
id: 'patch-clash-info',
|
||||
revalidate: [['getClashInfo'], ['getClashConfig']],
|
||||
errorNotice: false,
|
||||
})
|
||||
})
|
||||
|
||||
const invalidateClashConfig = () => revalidateQuery(['getClashConfig'])
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { listen, EventCallback } from '@tauri-apps/api/event'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export const useListen = () => {
|
||||
const addListener = useCallback(
|
||||
async <T>(eventName: string, handler: EventCallback<T>) => {
|
||||
return await listen(eventName, handler)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return {
|
||||
addListener,
|
||||
}
|
||||
}
|
||||
+23
-16
@@ -1,18 +1,12 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
|
||||
import {
|
||||
fetchCacheData,
|
||||
revalidateQuery,
|
||||
setCacheData,
|
||||
useQuery,
|
||||
} from '@/services/query-client'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
const profilesQueryKey = ['getProfiles'] as const
|
||||
|
||||
export const revalidateProfiles = () => revalidateQuery(profilesQueryKey)
|
||||
|
||||
export const fetchProfilesIntoCache = () =>
|
||||
fetchCacheData(profilesQueryKey, getProfiles)
|
||||
|
||||
@@ -49,16 +43,25 @@ export const useProfiles = () => {
|
||||
const patchProfiles = useCallback(
|
||||
async (value: Partial<IProfilesConfig>) => {
|
||||
try {
|
||||
const outcome = await patchProfilesConfig(value)
|
||||
const result = await mutate(() => patchProfilesConfig(value), {
|
||||
id: 'patch-profiles-config',
|
||||
onFulfilled: (outcome) => {
|
||||
if (outcome.status === 'valid') {
|
||||
void setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
|
||||
current ? { ...current, ...value } : current,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (outcome.status === 'valid') {
|
||||
await setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
|
||||
current ? { ...current, ...value } : current,
|
||||
)
|
||||
} else if (outcome.status !== 'busy') {
|
||||
if (!result.ok) {
|
||||
// Backend Busy keeps local state untouched, as before.
|
||||
return result.value
|
||||
}
|
||||
const outcome = result.value
|
||||
if (outcome.status !== 'valid' && outcome.status !== 'busy') {
|
||||
await mutateProfiles()
|
||||
}
|
||||
|
||||
return outcome
|
||||
} catch (error) {
|
||||
await mutateProfiles()
|
||||
@@ -71,7 +74,11 @@ export const useProfiles = () => {
|
||||
const patchCurrent = useCallback(
|
||||
async (value: Partial<IProfileItem>) => {
|
||||
if (profiles?.current) {
|
||||
await patchProfile(profiles.current, value)
|
||||
const uid = profiles.current
|
||||
await mutate(() => patchProfile(uid, value), {
|
||||
id: `patch-profile:${uid}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
void mutateProfiles()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { installService, restartCore } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
const executeWithErrorHandling = async <T>(
|
||||
@@ -23,14 +24,19 @@ const executeWithErrorHandling = async <T>(
|
||||
|
||||
export const useServiceInstaller = () => {
|
||||
const installServiceAndRestartCore = useCallback(async () => {
|
||||
const outcome = await executeWithErrorHandling(
|
||||
() => installService(),
|
||||
const installResult = await executeWithErrorHandling(
|
||||
() =>
|
||||
mutate(() => installService(), {
|
||||
id: 'install-service',
|
||||
errorNotice: false,
|
||||
}),
|
||||
'settings.statuses.clashService.installing',
|
||||
)
|
||||
if (outcome.status === 'sidecar') {
|
||||
if (!installResult.ok) return
|
||||
if (installResult.value.status === 'sidecar') {
|
||||
showNotice.warning(
|
||||
'settings.feedback.notifications.clashService.permissionFallback',
|
||||
{ reason: outcome.reason },
|
||||
{ reason: installResult.value.reason },
|
||||
0,
|
||||
)
|
||||
return
|
||||
@@ -40,7 +46,11 @@ export const useServiceInstaller = () => {
|
||||
)
|
||||
|
||||
await executeWithErrorHandling(
|
||||
() => restartCore(),
|
||||
() =>
|
||||
mutate(() => restartCore(), {
|
||||
id: 'restart-core',
|
||||
errorNotice: false,
|
||||
}),
|
||||
'settings.statuses.clash.restarting',
|
||||
'settings.feedback.notifications.clash.restartSuccess',
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { uninstallService } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
import { useSystemState } from './use-system-state'
|
||||
@@ -12,10 +13,15 @@ export const useServiceUninstaller = () => {
|
||||
let uninstallError: unknown
|
||||
showNotice.info('settings.statuses.clashService.uninstalling')
|
||||
try {
|
||||
await uninstallService()
|
||||
showNotice.success(
|
||||
'settings.feedback.notifications.clashService.uninstallSuccess',
|
||||
)
|
||||
const result = await mutate(() => uninstallService(), {
|
||||
id: 'uninstall-service',
|
||||
errorNotice: false,
|
||||
})
|
||||
if (result.ok) {
|
||||
showNotice.success(
|
||||
'settings.feedback.notifications.clashService.uninstallSuccess',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
uninstallError = error
|
||||
}
|
||||
|
||||
@@ -9,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) {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import {
|
||||
getAppUptime,
|
||||
getRuntimeState,
|
||||
type RunState,
|
||||
type RunningMode,
|
||||
} from '@/services/cmds'
|
||||
import { getAppUptime, type RunState, type RunningMode } from '@/services/cmds'
|
||||
import { useQuery } from '@/services/query-client'
|
||||
import { useAppReads, useRunState } from '@/store/app-store-context'
|
||||
|
||||
import { useVisibility } from './use-visibility'
|
||||
|
||||
export const runStateQueryKey = ['getRuntimeState'] as const
|
||||
const appUptimeQueryKey = ['appUptime'] as const
|
||||
|
||||
/** Fail closed until the first snapshot so TUN never flashes as available. */
|
||||
@@ -27,20 +20,9 @@ const unknownRunState: RunState = {
|
||||
|
||||
/** Event-driven run state; Rust owns all derived availability decisions. */
|
||||
export function useSystemState() {
|
||||
const pageVisible = useVisibility()
|
||||
|
||||
const {
|
||||
data: runState = unknownRunState,
|
||||
refetch: mutateSystemState,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: runStateQueryKey,
|
||||
queryFn: getRuntimeState,
|
||||
// A safety net only; transitions normally arrive by event.
|
||||
refetchInterval: pageVisible ? 30000 : false,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
})
|
||||
const { readRunState } = useAppReads()
|
||||
const snapshot = useRunState()
|
||||
const runState = snapshot ?? unknownRunState
|
||||
|
||||
return {
|
||||
runState,
|
||||
@@ -50,8 +32,8 @@ export function useSystemState() {
|
||||
isServiceMode: runState.mode === 'Service',
|
||||
isTunModeAvailable: runState.tunCapable,
|
||||
serviceNeedsAttention: runState.serviceNeedsAttention,
|
||||
mutateSystemState,
|
||||
isLoading,
|
||||
mutateSystemState: readRunState,
|
||||
isLoading: snapshot == null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-7
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getVergeConfig, patchVergeConfig } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { getPreloadConfig, setPreloadConfig } from '@/services/preload'
|
||||
import { setCacheData, useQuery } from '@/services/query-client'
|
||||
|
||||
@@ -38,13 +39,15 @@ export const useVerge = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const patchVerge = useCallback(
|
||||
async (value: Partial<IVergeConfig>) => {
|
||||
await patchVergeConfig(value)
|
||||
await refetch()
|
||||
},
|
||||
[refetch],
|
||||
)
|
||||
// Callers own the error toast (GuardState onCatch / page onError), so the
|
||||
// funnel must not add a second one.
|
||||
const patchVerge = useCallback(async (value: Partial<IVergeConfig>) => {
|
||||
await mutate(() => patchVergeConfig(value), {
|
||||
id: 'patch-verge-config',
|
||||
revalidate: [['getVergeConfig']],
|
||||
errorNotice: false,
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
verge,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "上書きファイルのマッピングエラーがあります。変更は取り消されました。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "병합 파일 매핑 오류"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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, откат изменений"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "覆写文件映射错误,变更已撤销"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
"config": {
|
||||
"failed": "訂閱配置校驗失敗,請檢查訂閱配置文件,變更已撤銷,錯誤詳情:",
|
||||
"bootFailed": "啟動訂閱配置校驗失敗,已使用預設配置啟動;請檢查訂閱配置文件,錯誤詳情:",
|
||||
"coreChangeFailed": "切換內核時配置校驗失敗,已使用預設配置啟動;請檢查訂閱配置文件,錯誤詳情:",
|
||||
"processTerminated": "驗證程序被終止,請檢查是否有安全軟體阻止了內核執行,或電腦的 CPU/執行環境是否支援該內核程式。"
|
||||
},
|
||||
"script": {
|
||||
@@ -120,15 +119,11 @@
|
||||
"yaml": {
|
||||
"syntaxError": "YAML 語法錯誤,變更已撤銷",
|
||||
"readError": "YAML 讀取錯誤,變更已撤銷",
|
||||
"mappingError": "YAML 映射錯誤,變更已撤銷",
|
||||
"keyError": "YAML 鍵錯誤,變更已撤銷",
|
||||
"generalError": "YAML 錯誤,變更已撤銷"
|
||||
"mappingError": "YAML 映射錯誤,變更已撤銷"
|
||||
},
|
||||
"merge": {
|
||||
"syntaxError": "覆寫檔案語法錯誤,變更已撤銷",
|
||||
"mappingError": "覆寫檔案映射錯誤,變更已撤銷",
|
||||
"keyError": "覆寫檔案鍵錯誤,變更已撤銷",
|
||||
"generalError": "覆寫檔案錯誤,變更已撤銷"
|
||||
"mappingError": "覆寫檔案映射錯誤,變更已撤銷"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+3
-6
@@ -20,11 +20,8 @@ import {
|
||||
getPreloadConfig,
|
||||
} from './services/preload'
|
||||
import { swrConfig } from './services/query-client'
|
||||
import {
|
||||
LoadingCacheProvider,
|
||||
ThemeModeProvider,
|
||||
UpdateStateProvider,
|
||||
} from './services/states'
|
||||
import { ThemeModeProvider, UpdateStateProvider } from './services/states'
|
||||
import { AppStoreProvider } from './store/app-store'
|
||||
import { disableWebViewShortcuts } from './utils/disable-webview-shortcuts'
|
||||
|
||||
if (!window.ResizeObserver) {
|
||||
@@ -43,7 +40,7 @@ disableWebViewShortcuts()
|
||||
const initializeApp = (initialThemeMode: 'light' | 'dark') => {
|
||||
const contexts = [
|
||||
<ThemeModeProvider key="theme" initialState={initialThemeMode} />,
|
||||
<LoadingCacheProvider key="loading" />,
|
||||
<AppStoreProvider key="app-store" />,
|
||||
<UpdateStateProvider key="update" />,
|
||||
]
|
||||
|
||||
|
||||
@@ -17,16 +17,16 @@ import {
|
||||
import { useI18n } from '@/hooks/use-i18n'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { useWindowDecorations } from '@/hooks/use-window'
|
||||
import { useEventBus } from '@/services/bus'
|
||||
import { handleNoticeMessage } from '@/services/notice-handlers'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import getSystem from '@/utils/get-system'
|
||||
|
||||
import {
|
||||
useCustomTheme,
|
||||
useLayoutEvents,
|
||||
useLoadingOverlay,
|
||||
usePendingFailures,
|
||||
} from './_layout/hooks'
|
||||
import { handleNoticeMessage } from './_layout/utils'
|
||||
|
||||
import 'dayjs/locale/ru'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
@@ -77,7 +77,7 @@ const Layout = () => {
|
||||
[t, navigate],
|
||||
)
|
||||
|
||||
useLayoutEvents(handleNotice)
|
||||
useEventBus(handleNotice)
|
||||
usePendingFailures()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { useLayoutEvents } from './use-layout-events'
|
||||
export { usePendingFailures, useDialogFailure } from './use-pending-failures'
|
||||
export { useLoadingOverlay } from './use-loading-overlay'
|
||||
export { useNavMenuOrder } from './use-nav-menu-order'
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { revalidateProfiles } from '@/hooks/use-profiles'
|
||||
import { runStateQueryKey } from '@/hooks/use-system-state'
|
||||
import type { RunState } from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { revalidateQueries, setCacheData } from '@/services/query-client'
|
||||
|
||||
import { forgetShownStartupError } from '../utils/notification-handlers'
|
||||
|
||||
export const useLayoutEvents = (
|
||||
handleNotice: (payload: [string, string]) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
let lastProfileId: string | null = null
|
||||
let lastProfileUpdateTime = 0
|
||||
const refreshThrottle = 800
|
||||
|
||||
const revalidateKeys = (keys: readonly string[]) => {
|
||||
void revalidateQueries(keys.map((key) => [key]))
|
||||
}
|
||||
|
||||
const handleProfileChanged = (newProfileId: string) => {
|
||||
const now = Date.now()
|
||||
if (
|
||||
lastProfileId === newProfileId &&
|
||||
now - lastProfileUpdateTime < refreshThrottle
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastProfileId = newProfileId
|
||||
lastProfileUpdateTime = now
|
||||
void revalidateProfiles()
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeVergeEvents(
|
||||
{
|
||||
'profile-changed': handleProfileChanged,
|
||||
'verge://refresh-profiles': () => void revalidateProfiles(),
|
||||
'verge://refresh-clash-config': () => {
|
||||
revalidateKeys([
|
||||
'getProxyView',
|
||||
'getVersion',
|
||||
'getClashConfig',
|
||||
'getClashInfo',
|
||||
'getClashMode',
|
||||
'getRuntimeConfig',
|
||||
'getRules',
|
||||
'getRuleProviders',
|
||||
])
|
||||
},
|
||||
'verge://refresh-verge-config': () => {
|
||||
revalidateKeys([
|
||||
'getVergeConfig',
|
||||
'getSystemProxy',
|
||||
'getAutotemProxy',
|
||||
])
|
||||
},
|
||||
// Transitions carry the full run-state snapshot, so write it directly to cache.
|
||||
'verge://run-state-changed': (payload) => {
|
||||
void setCacheData<RunState>(runStateQueryKey, payload)
|
||||
if (payload.mode !== 'NotRunning') forgetShownStartupError()
|
||||
},
|
||||
'verge://notice-message': handleNotice,
|
||||
},
|
||||
// Re-read event-only state after subscribing to close the initial race window.
|
||||
() => {
|
||||
revalidateKeys(['getRuntimeState', 'getVergeConfig'])
|
||||
handleNotice(['dns_override::auto_disabled', ''])
|
||||
handleNotice(['enhance::discarded_keys', ''])
|
||||
handleNotice(['service_core::sidecar_fallback', ''])
|
||||
handleNotice(['service_core::repair_required', ''])
|
||||
handleNotice(['service_core::app_data_not_owned', ''])
|
||||
handleNotice(['core_start::error', ''])
|
||||
},
|
||||
)
|
||||
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
|
||||
if (payload) handleNotice(['core_start::error', ''])
|
||||
})
|
||||
return () => {
|
||||
unsubscribe()
|
||||
void unlistenFocus.then((unlisten) => unlisten())
|
||||
}
|
||||
}, [handleNotice])
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
getPendingFailures,
|
||||
getSidecarFailure,
|
||||
type PendingFailure,
|
||||
type SidecarFailureSnapshot,
|
||||
} from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import type { PendingFailure } from '@/services/cmds'
|
||||
import { showNotice, syncSidecarFailure } from '@/services/notice-service'
|
||||
import {
|
||||
usePendingFailureList,
|
||||
useSidecarFailure,
|
||||
} from '@/store/app-store-context'
|
||||
|
||||
/** Failures handled by the recovery dialog instead of a toast. */
|
||||
const CODES_SHOWN_AS_A_DIALOG = new Set<string>([
|
||||
@@ -31,111 +29,43 @@ const windowIsWatched = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const usePendingFailureReader = (
|
||||
onFailures: (failures: PendingFailure[]) => void,
|
||||
onSidecarFailure?: (snapshot: SidecarFailureSnapshot) => void,
|
||||
) => {
|
||||
// Avoid resubscribing when the handler changes.
|
||||
const handlerRef = useRef({ onFailures, onSidecarFailure })
|
||||
handlerRef.current = { onFailures, onSidecarFailure }
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
const read = () => {
|
||||
if (handlerRef.current.onSidecarFailure) {
|
||||
void getSidecarFailure()
|
||||
.then((snapshot) => {
|
||||
if (!disposed) handlerRef.current.onSidecarFailure?.(snapshot)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[pending-failures] Sidecar failure could not be read:',
|
||||
error,
|
||||
)
|
||||
})
|
||||
}
|
||||
void (async () => {
|
||||
let failures: PendingFailure[]
|
||||
try {
|
||||
failures = await getPendingFailures()
|
||||
} catch (error) {
|
||||
console.warn('[pending-failures] could not be read:', error)
|
||||
return
|
||||
}
|
||||
if (!disposed) handlerRef.current.onFailures(failures)
|
||||
})()
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeVergeEvents(
|
||||
{ 'verge://pending-failures-changed': read },
|
||||
read,
|
||||
)
|
||||
|
||||
const readWhenVisible = () => {
|
||||
if (document.visibilityState === 'visible') read()
|
||||
}
|
||||
document.addEventListener('visibilitychange', readWhenVisible)
|
||||
|
||||
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
|
||||
if (payload) read()
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
unsubscribe()
|
||||
document.removeEventListener('visibilitychange', readWhenVisible)
|
||||
void unlistenFocus.then((unlisten) => unlisten())
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** Show each pending failure sequence once when the window can be read. */
|
||||
export const usePendingFailures = () => {
|
||||
const failures = usePendingFailureList()
|
||||
const sidecarFailure = useSidecarFailure()
|
||||
const shownSequencesRef = useRef(new Map<string, number>())
|
||||
|
||||
const showNewFailures = useCallback(async (failures: PendingFailure[]) => {
|
||||
useEffect(() => {
|
||||
if (sidecarFailure) syncSidecarFailure(sidecarFailure)
|
||||
}, [sidecarFailure])
|
||||
|
||||
useEffect(() => {
|
||||
const asToast = failures.filter(
|
||||
(failure) => !CODES_SHOWN_AS_A_DIALOG.has(failure.code),
|
||||
)
|
||||
if (asToast.length === 0) return
|
||||
|
||||
if (!(await windowIsWatched())) return
|
||||
|
||||
for (const failure of asToast) {
|
||||
const shown = shownSequencesRef.current.get(failure.code)
|
||||
if (shown !== undefined && shown >= failure.sequence) continue
|
||||
shownSequencesRef.current.set(failure.code, failure.sequence)
|
||||
showNotice.error({ code: failure.code, detail: failure.detail })
|
||||
}
|
||||
}, [])
|
||||
|
||||
usePendingFailureReader(
|
||||
useCallback(
|
||||
(failures) => {
|
||||
void showNewFailures(failures)
|
||||
},
|
||||
[showNewFailures],
|
||||
),
|
||||
syncSidecarFailure,
|
||||
)
|
||||
void windowIsWatched().then((watched) => {
|
||||
if (!watched) return
|
||||
for (const failure of asToast) {
|
||||
const shown = shownSequencesRef.current.get(failure.code)
|
||||
if (shown !== undefined && shown >= failure.sequence) continue
|
||||
shownSequencesRef.current.set(failure.code, failure.sequence)
|
||||
showNotice.error({ code: failure.code, detail: failure.detail })
|
||||
}
|
||||
})
|
||||
}, [failures])
|
||||
}
|
||||
|
||||
/** Return the oldest undismissed failure owned by the dialog. */
|
||||
export const useDialogFailure = () => {
|
||||
const [failure, setFailure] = useState<PendingFailure | null>(null)
|
||||
// Dismiss locally without retiring the backend failure.
|
||||
const failures = usePendingFailureList()
|
||||
const [dismissedSequence, setDismissedSequence] = useState<number | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
usePendingFailureReader(
|
||||
useCallback((failures) => {
|
||||
setFailure(
|
||||
failures.find((entry) => CODES_SHOWN_AS_A_DIALOG.has(entry.code)) ??
|
||||
null,
|
||||
)
|
||||
}, []),
|
||||
)
|
||||
const failure: PendingFailure | null =
|
||||
failures.find((entry) => CODES_SHOWN_AS_A_DIALOG.has(entry.code)) ?? null
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setDismissedSequence(failure?.sequence ?? null)
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { hideInitialOverlay } from './initial-loading-overlay'
|
||||
export { handleNoticeMessage } from './notification-handlers'
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useConnectionData } from '@/hooks/use-connection-data'
|
||||
import { useConnectionSetting } from '@/hooks/use-connection-setting'
|
||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||
import { useVisibility } from '@/hooks/use-visibility'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import parseTraffic from '@/utils/parse-traffic'
|
||||
|
||||
type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[]
|
||||
@@ -152,7 +153,12 @@ const ConnectionsPage = () => {
|
||||
[connectionsType, filterConn],
|
||||
)
|
||||
|
||||
const onCloseAll = useLockFn(closeAllConnections)
|
||||
const onCloseAll = useLockFn(() =>
|
||||
mutate(() => closeAllConnections(), {
|
||||
id: 'close-all-connections',
|
||||
errorNotice: false,
|
||||
}),
|
||||
)
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(match: (content: string) => boolean, state: SearchState) => {
|
||||
|
||||
+7
-1
@@ -35,6 +35,7 @@ import { ProxyTunCard } from '@/components/home/proxy-tun-card'
|
||||
import { useProfiles } from '@/hooks/use-profiles'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
import { entry_lightweight_mode } from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { openExternalUrl } from '@/utils/open-external-url'
|
||||
|
||||
@@ -327,7 +328,12 @@ const HomePage = () => {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Tooltip title={t('home.page.tooltips.lightweightMode')} arrow>
|
||||
<IconButton
|
||||
onClick={async () => await entry_lightweight_mode()}
|
||||
onClick={async () =>
|
||||
await mutate(() => entry_lightweight_mode(), {
|
||||
id: 'entry-lightweight-mode',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
size="small"
|
||||
color="inherit"
|
||||
>
|
||||
|
||||
+85
-105
@@ -17,7 +17,7 @@ import {
|
||||
TextSnippetOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material'
|
||||
import { TauriEvent } from '@tauri-apps/api/event'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { readText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { readTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { useLockFn } from 'ahooks'
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
type ProfileViewerRef,
|
||||
} from '@/components/profile/profile-viewer'
|
||||
import { ConfigViewer } from '@/components/setting/mods/config-viewer'
|
||||
import { useListen } from '@/hooks/use-listen'
|
||||
import { fetchProfilesIntoCache, useProfiles } from '@/hooks/use-profiles'
|
||||
import {
|
||||
createProfile,
|
||||
@@ -50,14 +49,15 @@ import {
|
||||
reorderProfile,
|
||||
updateProfile,
|
||||
} from '@/services/cmds'
|
||||
import { subscribeVergeEvents } from '@/services/events'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { errorDetail, showNotice } from '@/services/notice-service'
|
||||
import { revalidateQuery, useQuery } from '@/services/query-client'
|
||||
import { useThemeMode } from '@/services/states'
|
||||
import {
|
||||
useLoadingCache,
|
||||
useSetLoadingCache,
|
||||
useThemeMode,
|
||||
} from '@/services/states'
|
||||
useProfileLoadingCache,
|
||||
useProfileUpdates,
|
||||
useSetProfileLoading,
|
||||
} from '@/store/app-store-context'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
import { isValidUrl } from '@/utils/network'
|
||||
|
||||
@@ -82,7 +82,6 @@ const debugProfileSwitch = (action: string, profile: string, extra?: any) => {
|
||||
const ProfilePage = () => {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const { addListener } = useListen()
|
||||
const [url, setUrl] = useState('')
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const [profileDndRevision, setProfileDndRevision] = useState(0)
|
||||
@@ -92,12 +91,6 @@ const ProfilePage = () => {
|
||||
string | null
|
||||
>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [timerUpdateRevisions, setTimerUpdateRevisions] = useState<
|
||||
Map<string, number>
|
||||
>(() => new Map())
|
||||
const [completedUpdateRevisions, setCompletedUpdateRevisions] = useState<
|
||||
Map<string, number>
|
||||
>(() => new Map())
|
||||
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(
|
||||
@@ -128,44 +121,42 @@ const ProfilePage = () => {
|
||||
}, [profiles])
|
||||
|
||||
useEffect(() => {
|
||||
const handleFileDrop = async () => {
|
||||
const unlisten = await addListener(
|
||||
TauriEvent.DRAG_DROP,
|
||||
async (event: any) => {
|
||||
const paths = event.payload.paths
|
||||
const unlisten = getCurrentWindow().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop') return
|
||||
const paths = event.payload.paths
|
||||
|
||||
for (const file of paths) {
|
||||
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
|
||||
showNotice.error('profiles.page.feedback.errors.onlyYaml')
|
||||
continue
|
||||
}
|
||||
const item = {
|
||||
type: 'local',
|
||||
name: file.split(/\/|\\/).pop() ?? 'New Profile',
|
||||
desc: '',
|
||||
url: '',
|
||||
option: {
|
||||
with_proxy: false,
|
||||
self_proxy: false,
|
||||
},
|
||||
} as IProfileItem
|
||||
const data = await readTextFile(file)
|
||||
await createProfile(item, data)
|
||||
await mutateProfiles()
|
||||
}
|
||||
await enhanceProfiles()
|
||||
},
|
||||
)
|
||||
|
||||
return unlisten
|
||||
}
|
||||
|
||||
const unsubscribe = handleFileDrop()
|
||||
for (const file of paths) {
|
||||
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
|
||||
showNotice.error('profiles.page.feedback.errors.onlyYaml')
|
||||
continue
|
||||
}
|
||||
const item = {
|
||||
type: 'local',
|
||||
name: file.split(/\/|\\/).pop() ?? 'New Profile',
|
||||
desc: '',
|
||||
url: '',
|
||||
option: {
|
||||
with_proxy: false,
|
||||
self_proxy: false,
|
||||
},
|
||||
} as IProfileItem
|
||||
const data = await readTextFile(file)
|
||||
await mutate(() => createProfile(item, data), {
|
||||
id: 'create-profile',
|
||||
errorNotice: false,
|
||||
revalidate: [['getProfiles']],
|
||||
})
|
||||
}
|
||||
await mutate(() => enhanceProfiles(), {
|
||||
id: 'enhance-profiles',
|
||||
errorNotice: false,
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe.then((cleanup) => cleanup())
|
||||
void unlisten.then((cleanup) => cleanup())
|
||||
}
|
||||
}, [addListener, mutateProfiles])
|
||||
}, [])
|
||||
|
||||
const onEmergencyRefresh = useLockFn(async () => {
|
||||
debugLog('[紧急刷新] 开始强制刷新所有数据')
|
||||
@@ -227,7 +218,10 @@ const ProfilePage = () => {
|
||||
await performRobustRefresh()
|
||||
}
|
||||
try {
|
||||
await importProfile(url)
|
||||
await mutate(() => importProfile(url), {
|
||||
id: 'import-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
await handleImportSuccess('shared.feedback.notifications.importSuccess')
|
||||
} catch (initialErr) {
|
||||
console.warn('[订阅导入] 首次导入失败:', initialErr)
|
||||
@@ -240,10 +234,17 @@ const ProfilePage = () => {
|
||||
|
||||
showNotice.info('profiles.page.feedback.notifications.importRetry')
|
||||
try {
|
||||
await importProfile(url, {
|
||||
with_proxy: false,
|
||||
self_proxy: true,
|
||||
})
|
||||
await mutate(
|
||||
() =>
|
||||
importProfile(url, {
|
||||
with_proxy: false,
|
||||
self_proxy: true,
|
||||
}),
|
||||
{
|
||||
id: 'import-profile',
|
||||
errorNotice: false,
|
||||
},
|
||||
)
|
||||
await handleImportSuccess(
|
||||
'shared.feedback.notifications.importWithClashProxy',
|
||||
)
|
||||
@@ -324,7 +325,10 @@ const ProfilePage = () => {
|
||||
if (activeId == null || overId == null || activeId === overId) return
|
||||
|
||||
try {
|
||||
await reorderProfile(activeId, overId)
|
||||
await mutate(() => reorderProfile(activeId, overId), {
|
||||
id: 'reorder-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
mutateProfiles()
|
||||
} catch (error) {
|
||||
setProfileDndRevision((revision) => revision + 1)
|
||||
@@ -355,7 +359,10 @@ const ProfilePage = () => {
|
||||
if (outcome.status === 'valid') {
|
||||
currentProfileRef.current = profile
|
||||
void mutateLogs().catch(() => {})
|
||||
void closeAllConnections().catch(() => {})
|
||||
void mutate(() => closeAllConnections(), {
|
||||
id: 'close-all-connections',
|
||||
errorNotice: false,
|
||||
}).catch(() => {})
|
||||
|
||||
if (
|
||||
notifySuccess &&
|
||||
@@ -481,7 +488,11 @@ const ProfilePage = () => {
|
||||
setActivatings((prev) => [...new Set([...prev, ...currentProfiles])])
|
||||
|
||||
try {
|
||||
if (!(await enhanceProfiles())) return
|
||||
const result = await mutate(() => enhanceProfiles(), {
|
||||
id: 'enhance-profiles',
|
||||
errorNotice: false,
|
||||
})
|
||||
if (!(result.ok && result.value === true)) return
|
||||
mutateLogs()
|
||||
if (notifySuccess) {
|
||||
showNotice.success(
|
||||
@@ -500,7 +511,10 @@ const ProfilePage = () => {
|
||||
const current = profiles.current === uid
|
||||
try {
|
||||
setActivatings([...(current ? currentActivatings() : []), uid])
|
||||
await deleteProfile(uid)
|
||||
await mutate(() => deleteProfile(uid), {
|
||||
id: 'delete-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
mutateProfiles()
|
||||
mutateLogs()
|
||||
if (current) {
|
||||
@@ -513,51 +527,10 @@ const ProfilePage = () => {
|
||||
}
|
||||
})
|
||||
|
||||
const loadingCache = useLoadingCache()
|
||||
const setLoadingCache = useSetLoadingCache()
|
||||
const setLoadingProfiles = useCallback(
|
||||
(uids: string[], loading: boolean) => {
|
||||
setLoadingCache((cache) => {
|
||||
const next = new Set(cache)
|
||||
for (const uid of uids) {
|
||||
if (loading) {
|
||||
next.add(uid)
|
||||
} else {
|
||||
next.delete(uid)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setLoadingCache],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeVergeEvents({
|
||||
'profile-update-started': ({ uid }) => {
|
||||
if (uid) setLoadingProfiles([uid], true)
|
||||
},
|
||||
'profile-update-completed': ({ uid }) => {
|
||||
if (!uid) return
|
||||
setLoadingProfiles([uid], false)
|
||||
setCompletedUpdateRevisions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
})
|
||||
void mutateProfiles()
|
||||
},
|
||||
'verge://timer-updated': (uid) => {
|
||||
setTimerUpdateRevisions((current) => {
|
||||
const next = new Map(current)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
})
|
||||
},
|
||||
}),
|
||||
[mutateProfiles, setLoadingProfiles],
|
||||
)
|
||||
const loadingCache = useProfileLoadingCache()
|
||||
const { updateRevisions: completedUpdateRevisions, timerRevisions } =
|
||||
useProfileUpdates()
|
||||
const setLoadingProfiles = useSetProfileLoading()
|
||||
|
||||
const runProfileUpdates = useCallback(
|
||||
async (uids: string[]) => {
|
||||
@@ -570,7 +543,11 @@ const ProfilePage = () => {
|
||||
|
||||
const updateOne = async (uid: string) => {
|
||||
try {
|
||||
await updateProfile(uid)
|
||||
// Per-uid id: the worker pool must not dedupe distinct subscriptions.
|
||||
await mutate(() => updateProfile(uid), {
|
||||
id: `update-profile:${uid}`,
|
||||
errorNotice: false,
|
||||
})
|
||||
throttleMutate()
|
||||
} catch (err: any) {
|
||||
console.error(`更新订阅 ${uid} 失败:`, err)
|
||||
@@ -664,7 +641,10 @@ const ProfilePage = () => {
|
||||
setActivatings((prev) => [...new Set([...prev, ...currentActivating])])
|
||||
|
||||
for (const uid of selectedProfiles) {
|
||||
await deleteProfile(uid)
|
||||
await mutate(() => deleteProfile(uid), {
|
||||
id: 'delete-profile',
|
||||
errorNotice: false,
|
||||
})
|
||||
}
|
||||
|
||||
await mutateProfiles()
|
||||
@@ -924,7 +904,7 @@ const ProfilePage = () => {
|
||||
visibleSwitchingProfile === item.uid
|
||||
}
|
||||
itemData={item}
|
||||
timerUpdateRevision={timerUpdateRevisions.get(item.uid) ?? 0}
|
||||
timerUpdateRevision={timerRevisions.get(item.uid) ?? 0}
|
||||
completedUpdateRevision={
|
||||
completedUpdateRevisions.get(item.uid) ?? 0
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
patchClashMode,
|
||||
updateProxyChainConfigInRuntime,
|
||||
} from '@/services/cmds'
|
||||
import { mutate } from '@/services/mutate'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { debugLog } from '@/utils/debug'
|
||||
|
||||
@@ -57,7 +58,10 @@ const ProxyPage = () => {
|
||||
const onChangeMode = useLockFn(async (mode: Mode) => {
|
||||
try {
|
||||
// patchClashMode 在后端 PATCH 失败时会 reject,需提示用户而非静默失败
|
||||
await patchClashMode(mode)
|
||||
await mutate(() => patchClashMode(mode), {
|
||||
id: 'patch-clash-mode',
|
||||
errorNotice: false,
|
||||
})
|
||||
refreshClashConfig()
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
@@ -75,7 +79,10 @@ const ProxyPage = () => {
|
||||
// 退出链式代理模式时,清除链式代理配置
|
||||
try {
|
||||
debugLog('Exiting chain mode, clearing chain configuration')
|
||||
await updateProxyChainConfigInRuntime(null)
|
||||
await mutate(() => updateProxyChainConfigInRuntime(null), {
|
||||
id: 'update-proxy-chain-runtime',
|
||||
errorNotice: false,
|
||||
})
|
||||
debugLog('Chain configuration cleared successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to clear chain configuration:', error)
|
||||
|
||||
+10
-15
@@ -19,21 +19,20 @@ import {
|
||||
alpha,
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import { Channel, invoke } from '@tauri-apps/api/core'
|
||||
import { Channel } from '@tauri-apps/api/core'
|
||||
import { useLockFn } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BaseEmpty, BasePage } from '@/components/base'
|
||||
import {
|
||||
checkMediaUnlock,
|
||||
checkMediaUnlockItem,
|
||||
getUnlockItems,
|
||||
type UnlockItem,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
|
||||
interface UnlockItem {
|
||||
name: string
|
||||
status: string
|
||||
region?: string | null
|
||||
check_time?: string | null
|
||||
}
|
||||
|
||||
const UNLOCK_RESULTS_STORAGE_KEY = 'clash_verge_unlock_results'
|
||||
|
||||
const STATUS_LABEL_KEYS: Record<string, string> = {
|
||||
@@ -119,7 +118,7 @@ const UnlockPage = () => {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const defaultItems = await invoke<UnlockItem[]>('get_unlock_items')
|
||||
const defaultItems = await getUnlockItems()
|
||||
const existingMap = new Map(
|
||||
storedItems.map((item) => [normalizeUnlockName(item.name), item]),
|
||||
)
|
||||
@@ -153,9 +152,7 @@ const UnlockPage = () => {
|
||||
try {
|
||||
setIsCheckingAll(true)
|
||||
setLoadingItems(unlockItems.map((item) => item.name))
|
||||
const result = await invoke<UnlockItem[]>('check_media_unlock', {
|
||||
onComplete,
|
||||
})
|
||||
const result = await checkMediaUnlock(onComplete)
|
||||
const sortedItems = result.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
unlockItemsRef.current = sortedItems
|
||||
@@ -174,9 +171,7 @@ const UnlockPage = () => {
|
||||
const checkSingleMedia = async (name: string) => {
|
||||
setLoadingItems((items) => [...items, name])
|
||||
try {
|
||||
const result = await invoke<UnlockItem>('check_media_unlock_item', {
|
||||
name,
|
||||
})
|
||||
const result = await checkMediaUnlockItem(name)
|
||||
const updatedItems = unlockItemsRef.current.map((item) =>
|
||||
item.name === name ? result : item,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+61
-71
@@ -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',
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createStoreReads } from '@/store/store-reads'
|
||||
|
||||
import { useEventBus } from './use-event-bus'
|
||||
|
||||
const listen = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(name: string, handler: (event: unknown) => void) => Promise<() => void>
|
||||
>(() => Promise.resolve(() => {})),
|
||||
)
|
||||
const handleNotice = vi.hoisted(() => vi.fn())
|
||||
const dispatch = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen }))
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({
|
||||
onFocusChanged: vi.fn(() => Promise.resolve(() => {})),
|
||||
}),
|
||||
}))
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>()
|
||||
return {
|
||||
...actual,
|
||||
useEffect: (effect: () => void | (() => void)) => effect(),
|
||||
}
|
||||
})
|
||||
vi.mock('@/store/app-store-context', () => ({
|
||||
useAppDispatch: () => dispatch,
|
||||
useAppReads: () => createStoreReads(dispatch),
|
||||
}))
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
getPendingFailures: vi.fn().mockResolvedValue([]),
|
||||
getSidecarFailure: vi.fn().mockResolvedValue({ revision: 0, detail: null }),
|
||||
getRuntimeState: vi.fn().mockResolvedValue({ mode: 'NotRunning' }),
|
||||
}))
|
||||
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
beforeEach(() => {
|
||||
listen.mockClear()
|
||||
handleNotice.mockClear()
|
||||
dispatch.mockClear()
|
||||
listen.mockImplementation(() => Promise.resolve(() => {}))
|
||||
})
|
||||
|
||||
it('drains event-only state only after every listener is registered', async () => {
|
||||
const registrations: Array<() => void> = []
|
||||
listen.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
registrations.push(() => resolve(() => {}))
|
||||
}),
|
||||
)
|
||||
|
||||
useEventBus(handleNotice)
|
||||
await flush()
|
||||
expect(handleNotice).not.toHaveBeenCalled()
|
||||
|
||||
for (const resolve of registrations.slice(0, -1)) resolve()
|
||||
await flush()
|
||||
expect(handleNotice).not.toHaveBeenCalled()
|
||||
|
||||
registrations.at(-1)?.()
|
||||
await flush()
|
||||
expect(handleNotice).toHaveBeenCalled()
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'pendingFailures/loaded',
|
||||
failures: [],
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'sidecarFailure/loaded',
|
||||
snapshot: { revision: 0, detail: null },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { revalidateQueries } from '@/services/query-client'
|
||||
import { useAppDispatch, useAppReads } from '@/store/app-store-context'
|
||||
|
||||
import { createEventBus } from './create-bus-handlers'
|
||||
|
||||
/**
|
||||
* Mounts the event bus exactly once (layout root): one `listen` per contract
|
||||
* event plus the frontend-internal test-all event. Replaces every scattered
|
||||
* subscription; teardown disposes late-resolving registrations.
|
||||
*/
|
||||
export const useEventBus = (
|
||||
handleNotice: (payload: [string, string]) => void,
|
||||
) => {
|
||||
const dispatch = useAppDispatch()
|
||||
const reads = useAppReads()
|
||||
|
||||
useEffect(() => {
|
||||
const revalidateKeys = (keys: readonly string[]) => {
|
||||
void revalidateQueries(keys.map((key) => [key]))
|
||||
}
|
||||
const { readPendingFailures } = reads
|
||||
const readRunState = () => {
|
||||
void reads.readRunState().catch(() => {})
|
||||
}
|
||||
|
||||
const bus = createEventBus({
|
||||
dispatch,
|
||||
handleNotice,
|
||||
revalidateKeys,
|
||||
readPendingFailures,
|
||||
readRunState,
|
||||
})
|
||||
|
||||
let disposed = false
|
||||
const unlisteners: UnlistenFn[] = []
|
||||
const registrations = (
|
||||
Object.keys(bus.handlers) as Array<keyof typeof bus.handlers>
|
||||
).map((name) =>
|
||||
listen(name, ({ payload }) => {
|
||||
;(bus.handlers[name] as (payload: unknown) => void)(payload)
|
||||
}).then((unlisten) => {
|
||||
if (disposed) {
|
||||
unlisten()
|
||||
return
|
||||
}
|
||||
unlisteners.push(unlisten)
|
||||
}),
|
||||
)
|
||||
|
||||
// Re-read event-only state after subscribing to close the initial race window.
|
||||
void Promise.all(registrations).then(() => {
|
||||
if (disposed) return
|
||||
bus.onSubscribed()
|
||||
})
|
||||
|
||||
const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => {
|
||||
if (payload) bus.onWindowFocus()
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
for (const unlisten of unlisteners.splice(0)) unlisten()
|
||||
void unlistenFocus.then((unlisten) => unlisten())
|
||||
}
|
||||
}, [dispatch, handleNotice, reads])
|
||||
}
|
||||
@@ -183,6 +183,22 @@ export async function setDnsOverride(
|
||||
>('set_dns_override', { profileUid, enabled, confirmation })
|
||||
}
|
||||
|
||||
export async function getDnsConfigContent() {
|
||||
return invoke<string | null>('get_dns_config_content')
|
||||
}
|
||||
|
||||
export async function saveDnsConfig(dnsConfig: Record<string, unknown>) {
|
||||
return invoke<void>('save_dns_config', { dnsConfig })
|
||||
}
|
||||
|
||||
export async function validateDnsConfig() {
|
||||
return invoke<ValidationOutcome>('validate_dns_config')
|
||||
}
|
||||
|
||||
export async function applyDnsConfig(apply: boolean) {
|
||||
return invoke<void>('apply_dns_config', { apply })
|
||||
}
|
||||
|
||||
export async function takeDnsOverrideNotice() {
|
||||
return invoke<boolean>('take_dns_override_notice')
|
||||
}
|
||||
@@ -568,3 +584,22 @@ export const probeListener = async (request: ListenerProbe) => {
|
||||
export const saveProxyPorts = async (settings: ProxyPortSettings) => {
|
||||
return invoke<SaveProxyPortsOutcome>('save_proxy_ports', { settings })
|
||||
}
|
||||
|
||||
export interface UnlockItem {
|
||||
name: string
|
||||
status: string
|
||||
region?: string | null
|
||||
check_time?: string | null
|
||||
}
|
||||
|
||||
export async function getUnlockItems() {
|
||||
return invoke<UnlockItem[]>('get_unlock_items')
|
||||
}
|
||||
|
||||
export async function checkMediaUnlock(onComplete: Channel<UnlockItem>) {
|
||||
return invoke<UnlockItem[]>('check_media_unlock', { onComplete })
|
||||
}
|
||||
|
||||
export async function checkMediaUnlockItem(name: string) {
|
||||
return invoke<UnlockItem>('check_media_unlock_item', { name })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { subscribeVergeEvents } from './events'
|
||||
|
||||
const listen = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tauri-apps/api/event', () => ({ listen }))
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
beforeEach(() => {
|
||||
listen.mockReset()
|
||||
})
|
||||
|
||||
describe('subscribeVergeEvents', () => {
|
||||
it('reports readiness only after every listener is registered', async () => {
|
||||
const registrations: Array<() => void> = []
|
||||
listen.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
registrations.push(() => resolve(() => {}))
|
||||
}),
|
||||
)
|
||||
const onSubscribed = vi.fn()
|
||||
|
||||
subscribeVergeEvents(
|
||||
{
|
||||
'verge://run-state-changed': () => {},
|
||||
'verge://notice-message': () => {},
|
||||
},
|
||||
onSubscribed,
|
||||
)
|
||||
|
||||
registrations[0]?.()
|
||||
await flush()
|
||||
expect(onSubscribed).not.toHaveBeenCalled()
|
||||
|
||||
registrations[1]?.()
|
||||
await flush()
|
||||
expect(onSubscribed).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('delivers a payload to its matching handler', () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>()
|
||||
listen.mockImplementation(
|
||||
(name: string, handler: (event: unknown) => void) => {
|
||||
handlers.set(name, (payload) => handler({ payload }))
|
||||
return Promise.resolve(() => {})
|
||||
},
|
||||
)
|
||||
const onRunState = vi.fn()
|
||||
|
||||
subscribeVergeEvents({ 'verge://run-state-changed': onRunState })
|
||||
handlers.get('verge://run-state-changed')?.({ mode: 'Sidecar' })
|
||||
|
||||
expect(onRunState).toHaveBeenCalledWith({ mode: 'Sidecar' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import { mutate } from './mutate'
|
||||
|
||||
vi.mock('@/services/notice-service', () => ({
|
||||
showNotice: { error: vi.fn(), warning: vi.fn(), success: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn() }))
|
||||
|
||||
it('executes every queued setting write even when its predecessor fails', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
const first = mutate(
|
||||
() =>
|
||||
new Promise<void>((_resolve, rejectFirst) => {
|
||||
reject = rejectFirst
|
||||
}),
|
||||
{ id: 'patch-verge-config', errorNotice: false },
|
||||
).catch((error) => error)
|
||||
const writeSecond = vi.fn().mockResolvedValue(undefined)
|
||||
const onFulfilled = vi.fn()
|
||||
const second = mutate(writeSecond, {
|
||||
id: 'patch-verge-config',
|
||||
onFulfilled,
|
||||
errorNotice: false,
|
||||
})
|
||||
expect(writeSecond).not.toHaveBeenCalled()
|
||||
const error = new Error('first write failed')
|
||||
reject(error)
|
||||
expect(await first).toBe(error)
|
||||
expect(await second).toEqual({ ok: true, value: undefined })
|
||||
expect(writeSecond).toHaveBeenCalledOnce()
|
||||
expect(onFulfilled).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { revalidateQueries } from '@/services/query-client'
|
||||
|
||||
/** Anything a notice call accepts: translation key, ReactNode, or failure payload. */
|
||||
type MutateNoticeInput = Parameters<typeof showNotice.error>[0]
|
||||
|
||||
export interface MutateOptions<T> {
|
||||
/** Commands with the same identity execute in request order. */
|
||||
id: string
|
||||
/** Runs after a fulfilled (non-busy) result, e.g. cache merges. */
|
||||
onFulfilled?: (value: T) => void
|
||||
/** SWR keys revalidated after a fulfilled result. */
|
||||
revalidate?: readonly (readonly unknown[] | string)[]
|
||||
/** Shown after a fulfilled result (most flows leave this to backend notices). */
|
||||
successNotice?: MutateNoticeInput
|
||||
/** `false` suppresses the default error toast. */
|
||||
errorNotice?: false
|
||||
}
|
||||
|
||||
export type MutateResult<T> = { ok: true; value: T } | { ok: false; value: T }
|
||||
|
||||
/** ValidationOutcome-shaped results report backend-side Busy rejection this way. */
|
||||
const isBusyOutcome = (value: unknown): boolean =>
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as { status?: unknown }).status === 'busy'
|
||||
|
||||
const inFlight = new Map<string, Promise<void>>()
|
||||
export async function mutate<T>(
|
||||
invoke: () => Promise<T>,
|
||||
options: MutateOptions<T>,
|
||||
): Promise<MutateResult<T>> {
|
||||
const { id } = options
|
||||
const previous = inFlight.get(id)
|
||||
let release!: () => void
|
||||
const settled = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
inFlight.set(id, settled)
|
||||
if (previous) await previous
|
||||
|
||||
try {
|
||||
const value = await invoke()
|
||||
if (isBusyOutcome(value)) {
|
||||
return { ok: false, value }
|
||||
}
|
||||
options.onFulfilled?.(value)
|
||||
if (options.revalidate?.length) {
|
||||
await revalidateQueries([...options.revalidate])
|
||||
}
|
||||
if (options.successNotice !== undefined) {
|
||||
showNotice.success(options.successNotice)
|
||||
}
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
if (options.errorNotice !== false) {
|
||||
showNotice.error(error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
if (inFlight.get(id) === settled) inFlight.delete(id)
|
||||
}
|
||||
}
|
||||
+11
-17
@@ -8,6 +8,7 @@ import {
|
||||
takeServiceOwnerNotice,
|
||||
takeServiceRepairNotice,
|
||||
} from '@/services/cmds'
|
||||
import type { NoticeStatus } from '@/services/contract'
|
||||
import { hideNotice, showNotice } from '@/services/notice-service'
|
||||
import { requestService } from '@/services/service-request'
|
||||
|
||||
@@ -26,13 +27,21 @@ export const forgetShownStartupError = () => {
|
||||
settledStartupErrorRead = ++startupErrorReads
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaustive notice table: one entry per contract `NoticeStatus`. The Record
|
||||
* type makes a backend status without a frontend entry a compile error (and
|
||||
* contract regeneration keeps the union in sync).
|
||||
*/
|
||||
export const handleNoticeMessage = (
|
||||
status: string,
|
||||
msg: string,
|
||||
t: TranslateFunction,
|
||||
navigate: NavigateFunction,
|
||||
) => {
|
||||
const handlers: Record<string, () => void> = {
|
||||
const handlers: Record<NoticeStatus, () => void> = {
|
||||
info: () => {
|
||||
if (msg) showNotice.info(msg)
|
||||
},
|
||||
'import_sub_url::ok': () => {
|
||||
// 空 msg 传入,我们不希望导致 后端-前端-后端 死循环,这里只做提醒。
|
||||
// 未来细分事件通知时,可以考虑传入订阅 ID 或其他标识符
|
||||
@@ -198,17 +207,10 @@ export const handleNoticeMessage = (
|
||||
update_failed: () => showNotice.error(msg),
|
||||
'config_validate::boot_error': () =>
|
||||
showNotice.error('shared.feedback.validation.config.bootFailed', msg),
|
||||
'config_validate::core_change': () =>
|
||||
showNotice.error(
|
||||
'shared.feedback.validation.config.coreChangeFailed',
|
||||
msg,
|
||||
),
|
||||
'config_validate::error': () =>
|
||||
showNotice.error('shared.feedback.validation.config.failed', msg),
|
||||
'config_validate::process_terminated': () =>
|
||||
showNotice.error('shared.feedback.validation.config.processTerminated'),
|
||||
'config_validate::stdout_error': () =>
|
||||
showNotice.error('shared.feedback.validation.config.failed', msg),
|
||||
'config_validate::script_error': () =>
|
||||
showNotice.error('shared.feedback.validation.script.fileError', msg),
|
||||
'config_validate::script_syntax_error': () =>
|
||||
@@ -223,18 +225,10 @@ export const handleNoticeMessage = (
|
||||
showNotice.error('shared.feedback.validation.yaml.readError', msg),
|
||||
'config_validate::yaml_mapping_error': () =>
|
||||
showNotice.error('shared.feedback.validation.yaml.mappingError', msg),
|
||||
'config_validate::yaml_key_error': () =>
|
||||
showNotice.error('shared.feedback.validation.yaml.keyError', msg),
|
||||
'config_validate::yaml_error': () =>
|
||||
showNotice.error('shared.feedback.validation.yaml.generalError', msg),
|
||||
'config_validate::merge_syntax_error': () =>
|
||||
showNotice.error('shared.feedback.validation.merge.syntaxError', msg),
|
||||
'config_validate::merge_mapping_error': () =>
|
||||
showNotice.error('shared.feedback.validation.merge.mappingError', msg),
|
||||
'config_validate::merge_key_error': () =>
|
||||
showNotice.error('shared.feedback.validation.merge.keyError', msg),
|
||||
'config_validate::merge_error': () =>
|
||||
showNotice.error('shared.feedback.validation.merge.generalError', msg),
|
||||
'config_core::change_success': () =>
|
||||
showNotice.success(
|
||||
'settings.feedback.notifications.clash.changeSuccess',
|
||||
@@ -259,7 +253,7 @@ export const handleNoticeMessage = (
|
||||
),
|
||||
}
|
||||
|
||||
const handler = handlers[status]
|
||||
const handler = handlers[status as NoticeStatus]
|
||||
if (handler) {
|
||||
handler()
|
||||
} else {
|
||||
@@ -4,10 +4,6 @@ const [ThemeModeProvider, useThemeMode, useSetThemeMode] = createContextState<
|
||||
'light' | 'dark'
|
||||
>()
|
||||
|
||||
// save the state of each profile item loading
|
||||
const [LoadingCacheProvider, useLoadingCache, useSetLoadingCache] =
|
||||
createContextState<Set<string>>(new Set())
|
||||
|
||||
// save update state
|
||||
const [UpdateStateProvider, useUpdateState, useSetUpdateState] =
|
||||
createContextState<boolean>(false)
|
||||
@@ -16,9 +12,6 @@ export {
|
||||
ThemeModeProvider,
|
||||
useThemeMode,
|
||||
useSetThemeMode,
|
||||
LoadingCacheProvider,
|
||||
useLoadingCache,
|
||||
useSetLoadingCache,
|
||||
UpdateStateProvider,
|
||||
useUpdateState,
|
||||
useSetUpdateState,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
PendingFailure,
|
||||
RunState,
|
||||
SidecarFailureSnapshot,
|
||||
} from '@/services/cmds'
|
||||
|
||||
/** Event-derived per-profile update tracking, shared by the profiles page and items. */
|
||||
export interface ProfileUpdatesState {
|
||||
readonly loading: ReadonlySet<string>
|
||||
readonly updateRevisions: ReadonlyMap<string, number>
|
||||
readonly timerRevisions: ReadonlyMap<string, number>
|
||||
}
|
||||
|
||||
export interface AppStoreState {
|
||||
/** Written by `run-state-changed` events and the store's safety-net poll. */
|
||||
readonly runState: RunState | null
|
||||
/** Latest backend pending-failure snapshot; toasts/dialogs dedupe on sequence. */
|
||||
readonly pendingFailures: readonly PendingFailure[]
|
||||
readonly sidecarFailure: SidecarFailureSnapshot | null
|
||||
readonly profileUpdates: ProfileUpdatesState
|
||||
/** Bumped once per test-all request; test items run their delay test per bump. */
|
||||
readonly testAllCounter: number
|
||||
}
|
||||
|
||||
export const initialAppStoreState: AppStoreState = {
|
||||
runState: null,
|
||||
pendingFailures: [],
|
||||
sidecarFailure: null,
|
||||
profileUpdates: {
|
||||
loading: new Set(),
|
||||
updateRevisions: new Map(),
|
||||
timerRevisions: new Map(),
|
||||
},
|
||||
testAllCounter: 0,
|
||||
}
|
||||
|
||||
export type AppStoreAction =
|
||||
| { type: 'runState/loaded'; runState: RunState }
|
||||
| { type: 'pendingFailures/loaded'; failures: readonly PendingFailure[] }
|
||||
| { type: 'sidecarFailure/loaded'; snapshot: SidecarFailureSnapshot }
|
||||
| { type: 'profileUpdate/started'; uid: string }
|
||||
| { type: 'profileUpdate/completed'; uid: string }
|
||||
| { type: 'profileUpdate/timerTick'; uid: string }
|
||||
| { type: 'profileLoading/set'; uids: readonly string[]; loading: boolean }
|
||||
| { type: 'testAll/requested' }
|
||||
|
||||
const bumpRevision = (
|
||||
map: ReadonlyMap<string, number>,
|
||||
uid: string,
|
||||
): ReadonlyMap<string, number> => {
|
||||
const next = new Map(map)
|
||||
next.set(uid, (next.get(uid) ?? 0) + 1)
|
||||
return next
|
||||
}
|
||||
|
||||
const withLoading = (
|
||||
state: AppStoreState,
|
||||
uids: readonly string[],
|
||||
loading: boolean,
|
||||
): AppStoreState => {
|
||||
const next = new Set(state.profileUpdates.loading)
|
||||
for (const uid of uids) {
|
||||
if (loading) {
|
||||
next.add(uid)
|
||||
} else {
|
||||
next.delete(uid)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
profileUpdates: { ...state.profileUpdates, loading: next },
|
||||
}
|
||||
}
|
||||
|
||||
export function appStoreReducer(
|
||||
state: AppStoreState,
|
||||
action: AppStoreAction,
|
||||
): AppStoreState {
|
||||
switch (action.type) {
|
||||
case 'runState/loaded':
|
||||
return { ...state, runState: action.runState }
|
||||
case 'pendingFailures/loaded':
|
||||
return { ...state, pendingFailures: action.failures }
|
||||
case 'sidecarFailure/loaded':
|
||||
if (
|
||||
state.sidecarFailure &&
|
||||
action.snapshot.revision <= state.sidecarFailure.revision
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return { ...state, sidecarFailure: action.snapshot }
|
||||
case 'profileUpdate/started':
|
||||
if (state.profileUpdates.loading.has(action.uid)) return state
|
||||
return withLoading(state, [action.uid], true)
|
||||
case 'profileUpdate/completed': {
|
||||
const cleared = withLoading(state, [action.uid], false)
|
||||
return {
|
||||
...cleared,
|
||||
profileUpdates: {
|
||||
...cleared.profileUpdates,
|
||||
updateRevisions: bumpRevision(
|
||||
state.profileUpdates.updateRevisions,
|
||||
action.uid,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'profileUpdate/timerTick':
|
||||
return {
|
||||
...state,
|
||||
profileUpdates: {
|
||||
...state.profileUpdates,
|
||||
timerRevisions: bumpRevision(
|
||||
state.profileUpdates.timerRevisions,
|
||||
action.uid,
|
||||
),
|
||||
},
|
||||
}
|
||||
case 'profileLoading/set':
|
||||
return withLoading(state, action.uids, action.loading)
|
||||
case 'testAll/requested':
|
||||
return { ...state, testAllCounter: state.testAllCounter + 1 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createContext, useCallback, use } from 'react'
|
||||
|
||||
import type {
|
||||
AppStoreAction,
|
||||
AppStoreState,
|
||||
ProfileUpdatesState,
|
||||
} from './app-state'
|
||||
import type { createStoreReads } from './store-reads'
|
||||
|
||||
export const AppStateContext = createContext<AppStoreState | null>(null)
|
||||
export const AppDispatchContext =
|
||||
createContext<AppStoreActionDispatcher | null>(null)
|
||||
|
||||
export const AppReadsContext = createContext<ReturnType<
|
||||
typeof createStoreReads
|
||||
> | null>(null)
|
||||
|
||||
export const useAppReads = () => {
|
||||
const reads = use(AppReadsContext)
|
||||
if (!reads) throw new Error('app store is missing its provider')
|
||||
return reads
|
||||
}
|
||||
|
||||
export type AppStoreActionDispatcher = (action: AppStoreAction) => void
|
||||
|
||||
const useSelector = <T>(select: (state: AppStoreState) => T): T => {
|
||||
const state = use(AppStateContext)
|
||||
if (!state) throw new Error('app store is missing its provider')
|
||||
return select(state)
|
||||
}
|
||||
|
||||
export const useAppDispatch = (): AppStoreActionDispatcher => {
|
||||
const dispatch = use(AppDispatchContext)
|
||||
if (!dispatch) throw new Error('app store is missing its provider')
|
||||
return dispatch
|
||||
}
|
||||
|
||||
export const useRunState = () => useSelector((s) => s.runState)
|
||||
|
||||
export const usePendingFailureList = () => useSelector((s) => s.pendingFailures)
|
||||
|
||||
export const useSidecarFailure = () => useSelector((s) => s.sidecarFailure)
|
||||
|
||||
export const useProfileUpdates = (): ProfileUpdatesState =>
|
||||
useSelector((s) => s.profileUpdates)
|
||||
|
||||
export const useProfileLoadingCache = (): ReadonlySet<string> =>
|
||||
useSelector((s) => s.profileUpdates.loading)
|
||||
|
||||
/** Mark profiles as updating outside backend events (update-all, per-item buttons). */
|
||||
export const useSetProfileLoading = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
return useCallback(
|
||||
(uids: readonly string[], loading: boolean) =>
|
||||
dispatch({ type: 'profileLoading/set', uids, loading }),
|
||||
[dispatch],
|
||||
)
|
||||
}
|
||||
|
||||
export const useTestAllCounter = () => useSelector((s) => s.testAllCounter)
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
type AppStoreAction,
|
||||
appStoreReducer,
|
||||
initialAppStoreState,
|
||||
} from './app-state'
|
||||
import {
|
||||
AppDispatchContext,
|
||||
AppReadsContext,
|
||||
AppStateContext,
|
||||
} from './app-store-context'
|
||||
import { createStoreReads } from './store-reads'
|
||||
|
||||
/**
|
||||
* Single app store (React context + reducer, per the frozen design). Event-bus
|
||||
* handlers write through pure reducer actions; the effect below is the store's
|
||||
* own data seeding (initial read plus the poll/focus safety net the old
|
||||
* run-state query provided).
|
||||
*/
|
||||
export const AppStoreProvider = ({ children }: { children?: ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(appStoreReducer, initialAppStoreState)
|
||||
|
||||
const reads = useMemo(() => createStoreReads(dispatch), [dispatch])
|
||||
const dispatchEvent = useCallback(
|
||||
(action: AppStoreAction) => {
|
||||
if (action.type === 'runState/loaded')
|
||||
reads.acceptRunState(action.runState)
|
||||
else dispatch(action)
|
||||
},
|
||||
[reads],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const readRunState = () => {
|
||||
void reads.readRunState().catch(() => {})
|
||||
}
|
||||
const { readPendingFailures } = reads
|
||||
|
||||
readRunState()
|
||||
readPendingFailures()
|
||||
|
||||
// Transitions normally arrive by event; the visible poll is the run-state
|
||||
// safety net, and regaining focus re-reads both snapshots.
|
||||
const pollRunState = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
readRunState()
|
||||
}
|
||||
const readWhenFocused = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
readRunState()
|
||||
readPendingFailures()
|
||||
}
|
||||
window.addEventListener('focus', readWhenFocused)
|
||||
document.addEventListener('visibilitychange', readWhenFocused)
|
||||
const timer = window.setInterval(pollRunState, 30000)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', readWhenFocused)
|
||||
document.removeEventListener('visibilitychange', readWhenFocused)
|
||||
window.clearInterval(timer)
|
||||
reads.invalidate()
|
||||
}
|
||||
}, [reads])
|
||||
|
||||
return (
|
||||
<AppStateContext value={state}>
|
||||
<AppReadsContext value={reads}>
|
||||
<AppDispatchContext value={dispatchEvent}>
|
||||
{children}
|
||||
</AppDispatchContext>
|
||||
</AppReadsContext>
|
||||
</AppStateContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getPendingFailures,
|
||||
getRuntimeState,
|
||||
type RunState,
|
||||
} from '@/services/cmds'
|
||||
|
||||
import { appStoreReducer, initialAppStoreState } from './app-state'
|
||||
import { createStoreReads } from './store-reads'
|
||||
|
||||
vi.mock('@/services/cmds', () => ({
|
||||
getRuntimeState: vi.fn(),
|
||||
getPendingFailures: vi.fn().mockResolvedValue([]),
|
||||
getSidecarFailure: vi.fn().mockResolvedValue({ revision: 0, detail: null }),
|
||||
}))
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps a newer run-state event when an older snapshot returns', async () => {
|
||||
let resolve!: (state: RunState) => void
|
||||
vi.mocked(getRuntimeState).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve = r
|
||||
}),
|
||||
)
|
||||
let state = initialAppStoreState
|
||||
const reads = createStoreReads((action) => {
|
||||
state = appStoreReducer(state, action)
|
||||
})
|
||||
const pending = reads.readRunState()
|
||||
const running = { mode: 'Service' } as RunState
|
||||
reads.acceptRunState(running)
|
||||
resolve({ mode: 'NotRunning' } as RunState)
|
||||
expect(await pending).toBe(running)
|
||||
expect(state.runState).toBe(running)
|
||||
})
|
||||
|
||||
it('keeps the newer read when run-state replies arrive in reverse order', async () => {
|
||||
let resolveFirst!: (state: RunState) => void
|
||||
vi.mocked(getRuntimeState)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveFirst = r
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ mode: 'Service' } as RunState)
|
||||
let state = initialAppStoreState
|
||||
const reads = createStoreReads((action) => {
|
||||
state = appStoreReducer(state, action)
|
||||
})
|
||||
const first = reads.readRunState()
|
||||
const second = reads.readRunState()
|
||||
await second
|
||||
resolveFirst({ mode: 'NotRunning' } as RunState)
|
||||
expect((await first).mode).toBe('Service')
|
||||
expect(state.runState?.mode).toBe('Service')
|
||||
})
|
||||
|
||||
it('does not restore cleared pending failures from an older read', async () => {
|
||||
let resolveFirst!: (
|
||||
failures: Awaited<ReturnType<typeof getPendingFailures>>,
|
||||
) => void
|
||||
vi.mocked(getPendingFailures)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveFirst = r
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce([])
|
||||
let state = initialAppStoreState
|
||||
const reads = createStoreReads((action) => {
|
||||
state = appStoreReducer(state, action)
|
||||
})
|
||||
reads.readPendingFailures()
|
||||
reads.readPendingFailures()
|
||||
await Promise.resolve()
|
||||
resolveFirst([
|
||||
{
|
||||
code: 'proxy',
|
||||
detail: 'old',
|
||||
operation: 'systemProxyEnable',
|
||||
sequence: 1,
|
||||
},
|
||||
])
|
||||
await Promise.resolve()
|
||||
expect(state.pendingFailures).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
getPendingFailures,
|
||||
getRuntimeState,
|
||||
getSidecarFailure,
|
||||
type RunState,
|
||||
} from '@/services/cmds'
|
||||
|
||||
import type { AppStoreAction } from './app-state'
|
||||
|
||||
export const createStoreReads = (
|
||||
dispatch: (action: AppStoreAction) => void,
|
||||
) => {
|
||||
let runRevision = 0
|
||||
let failureRevision = 0
|
||||
let current: RunState | null = null
|
||||
let latestRead: Promise<RunState> | null = null
|
||||
|
||||
const acceptRunState = (runState: RunState) => {
|
||||
runRevision += 1
|
||||
latestRead = null
|
||||
current = runState
|
||||
dispatch({ type: 'runState/loaded', runState })
|
||||
}
|
||||
|
||||
const readRunState = (): Promise<RunState> => {
|
||||
const revision = ++runRevision
|
||||
const read: Promise<RunState> = getRuntimeState().then((runState) => {
|
||||
if (revision === runRevision) {
|
||||
current = runState
|
||||
dispatch({ type: 'runState/loaded', runState })
|
||||
}
|
||||
if (latestRead && latestRead !== read) return latestRead
|
||||
return current ?? runState
|
||||
})
|
||||
latestRead = read
|
||||
return read
|
||||
}
|
||||
|
||||
const readPendingFailures = () => {
|
||||
const revision = ++failureRevision
|
||||
getSidecarFailure()
|
||||
.then((snapshot) => {
|
||||
if (revision === failureRevision) {
|
||||
dispatch({ type: 'sidecarFailure/loaded', snapshot })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[app-store] Sidecar failure could not be read:', error)
|
||||
})
|
||||
getPendingFailures()
|
||||
.then((failures) => {
|
||||
if (revision === failureRevision) {
|
||||
dispatch({ type: 'pendingFailures/loaded', failures })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[app-store] pending failures could not be read:', error)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
acceptRunState,
|
||||
readRunState,
|
||||
readPendingFailures,
|
||||
invalidate: () => {
|
||||
runRevision += 1
|
||||
failureRevision += 1
|
||||
latestRead = null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user