diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index b1e02e164..113e733c3 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -86,9 +86,9 @@ jobs: if: steps.check_frontend.outputs.frontend == 'true' run: pnpm knip:check - - name: Architecture gates (listen/emit only in the event bus) + - 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 + run: node scripts/check-architecture.mjs listen emit invoke contract-parity: runs-on: ubuntu-22.04 diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 2ecdf5c46..0e9a2bb6b 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -13,7 +13,6 @@ import { TextField, Typography, } from '@mui/material' -import { invoke } from '@tauri-apps/api/core' import { useLockFn } from 'ahooks' import * as yaml from 'js-yaml' import type { Ref } from 'react' @@ -36,6 +35,12 @@ 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 { showNotice } from '@/services/notice-service' import { useThemeMode } from '@/services/states' import type { MonacoEditorInstance } from '@/types/monaco' @@ -465,10 +470,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { const initDnsConfig = useCallback(async () => { try { - const dnsConfig = await invoke( - 'get_dns_config_content', - {}, - ) + const dnsConfig = await getDnsConfigContent() if (dnsConfig !== null) { const config = yaml.load(dnsConfig) as any @@ -517,12 +519,9 @@ export function DnsViewer({ ref }: { ref?: Ref }) { config = parsedConfig as Record } - await invoke('save_dns_config', { dnsConfig: config }) + await saveDnsConfig(config) - const validation = await invoke( - 'validate_dns_config', - {}, - ) + const validation = await validateDnsConfig() if (validation.status !== 'valid') { const errorMsg = @@ -559,7 +558,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { } if (dnsEnabled) { - await invoke('apply_dns_config', { apply: true }) + await applyDnsConfig(true) mutateClash() } diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index 82dbb29d5..f07b2bdd2 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -8,6 +8,7 @@ import { getRuntimeConfig, patchClashConfig, } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { revalidateQuery, setCacheData, @@ -104,8 +105,10 @@ export const useClash = () => { } const patchClash = useLockFn(async (patch: Partial) => { - await patchClashConfig(patch) - mutateClash() + await mutate(() => patchClashConfig(patch), { + id: 'patch-clash-config', + revalidate: [['getRuntimeConfig']], + }) }) const version = versionData?.meta @@ -132,9 +135,10 @@ export const useClashInfo = () => { validatePorts(patch) - await patchClashConfig(patch) - mutateInfo() - revalidateQuery(['getClashConfig']) + await mutate(() => patchClashConfig(patch), { + id: 'patch-clash-info', + revalidate: [['getClashInfo'], ['getClashConfig']], + }) }) const invalidateClashConfig = () => revalidateQuery(['getClashConfig']) diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 22cee06ce..8b8e08dc3 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from 'react' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client' import { debugLog } from '@/utils/debug' @@ -42,16 +43,25 @@ export const useProfiles = () => { const patchProfiles = useCallback( async (value: Partial) => { try { - const outcome = await patchProfilesConfig(value) + const result = await mutate(() => patchProfilesConfig(value), { + id: 'patch-profiles-config', + onFulfilled: (outcome) => { + if (outcome.status === 'valid') { + void setCacheData(profilesQueryKey, (current) => + current ? { ...current, ...value } : current, + ) + } + }, + }) - if (outcome.status === 'valid') { - await setCacheData(profilesQueryKey, (current) => - current ? { ...current, ...value } : current, - ) - } else if (outcome.status !== 'busy') { + if (!result.ok) { + // Backend Busy keeps local state untouched, as before. + return result.value ?? { status: 'busy' } + } + const outcome = result.value + if (outcome.status !== 'valid' && outcome.status !== 'busy') { await mutateProfiles() } - return outcome } catch (error) { await mutateProfiles() diff --git a/src/hooks/use-verge.ts b/src/hooks/use-verge.ts index 0e0e4247c..0b03a83c2 100644 --- a/src/hooks/use-verge.ts +++ b/src/hooks/use-verge.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import { getVergeConfig, patchVergeConfig } from '@/services/cmds' +import { mutate } from '@/services/mutate' import { getPreloadConfig, setPreloadConfig } from '@/services/preload' import { setCacheData, useQuery } from '@/services/query-client' @@ -38,13 +39,12 @@ export const useVerge = () => { ) } - const patchVerge = useCallback( - async (value: Partial) => { - await patchVergeConfig(value) - await refetch() - }, - [refetch], - ) + const patchVerge = useCallback(async (value: Partial) => { + await mutate(() => patchVergeConfig(value), { + id: 'patch-verge-config', + revalidate: [['getVergeConfig']], + }) + }, []) return { verge, diff --git a/src/pages/unlock.tsx b/src/pages/unlock.tsx index cbf482c63..254994bc1 100644 --- a/src/pages/unlock.tsx +++ b/src/pages/unlock.tsx @@ -19,21 +19,20 @@ import { alpha, useTheme, } from '@mui/material' -import { Channel, invoke } from '@tauri-apps/api/core' +import { Channel } from '@tauri-apps/api/core' import { useLockFn } from 'ahooks' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { BaseEmpty, BasePage } from '@/components/base' +import { + checkMediaUnlock, + checkMediaUnlockItem, + getUnlockItems, + type UnlockItem, +} from '@/services/cmds' import { showNotice } from '@/services/notice-service' -interface UnlockItem { - name: string - status: string - region?: string | null - check_time?: string | null -} - const UNLOCK_RESULTS_STORAGE_KEY = 'clash_verge_unlock_results' const STATUS_LABEL_KEYS: Record = { @@ -119,7 +118,7 @@ const UnlockPage = () => { void (async () => { try { - const defaultItems = await invoke('get_unlock_items') + const defaultItems = await getUnlockItems() const existingMap = new Map( storedItems.map((item) => [normalizeUnlockName(item.name), item]), ) @@ -153,9 +152,7 @@ const UnlockPage = () => { try { setIsCheckingAll(true) setLoadingItems(unlockItems.map((item) => item.name)) - const result = await invoke('check_media_unlock', { - onComplete, - }) + const result = await checkMediaUnlock(onComplete) const sortedItems = result.sort((a, b) => a.name.localeCompare(b.name)) unlockItemsRef.current = sortedItems @@ -174,9 +171,7 @@ const UnlockPage = () => { const checkSingleMedia = async (name: string) => { setLoadingItems((items) => [...items, name]) try { - const result = await invoke('check_media_unlock_item', { - name, - }) + const result = await checkMediaUnlockItem(name) const updatedItems = unlockItemsRef.current.map((item) => item.name === name ? result : item, ) diff --git a/src/services/cmds.ts b/src/services/cmds.ts index b2a057913..d28c48b66 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core' +import { Channel, invoke } from '@tauri-apps/api/core' import dayjs from 'dayjs' import type { CommandFailure } from '@/services/notice-service' @@ -182,6 +182,22 @@ export async function setDnsOverride( >('set_dns_override', { profileUid, enabled, confirmation }) } +export async function getDnsConfigContent() { + return invoke('get_dns_config_content') +} + +export async function saveDnsConfig(dnsConfig: Record) { + return invoke('save_dns_config', { dnsConfig }) +} + +export async function validateDnsConfig() { + return invoke('validate_dns_config') +} + +export async function applyDnsConfig(apply: boolean) { + return invoke('apply_dns_config', { apply }) +} + export async function takeDnsOverrideNotice() { return invoke('take_dns_override_notice') } @@ -540,3 +556,22 @@ export const probeListener = async (request: ListenerProbe) => { export const saveProxyPorts = async (settings: ProxyPortSettings) => { return invoke('save_proxy_ports', { settings }) } + +export interface UnlockItem { + name: string + status: string + region?: string | null + check_time?: string | null +} + +export async function getUnlockItems() { + return invoke('get_unlock_items') +} + +export async function checkMediaUnlock(onComplete: Channel) { + return invoke('check_media_unlock', { onComplete }) +} + +export async function checkMediaUnlockItem(name: string) { + return invoke('check_media_unlock_item', { name }) +} diff --git a/src/services/mutate.ts b/src/services/mutate.ts new file mode 100644 index 000000000..546a62056 --- /dev/null +++ b/src/services/mutate.ts @@ -0,0 +1,90 @@ +import { showNotice } from '@/services/notice-service' +import { revalidateQueries } from '@/services/query-client' +import type { AppStoreAction } from '@/store/app-state' + +/** Anything a notice call accepts: translation key, ReactNode, or failure payload. */ +type MutateNoticeInput = Parameters[0] + +export interface MutateOptions { + /** Busy/in-flight identity; a second intent with the same id resolves `{ busy: true }`. */ + id: string + /** Applied before the command runs; undone by `rollback` on failure. */ + optimistic?: () => void + /** Restores pre-optimistic state when the command rejects. */ + rollback?: () => void | Promise + /** Runs after a fulfilled (non-busy) result, e.g. cache merges. */ + onFulfilled?: (value: T) => void + /** SWR keys revalidated after a fulfilled result. */ + revalidate?: readonly (readonly unknown[] | string)[] + /** Shown after a fulfilled result (most flows leave this to backend notices). */ + successNotice?: MutateNoticeInput + /** Shown when the command resolves with `{ status: 'busy' }`. */ + busyNotice?: MutateNoticeInput + /** `false` suppresses the default error toast. */ + errorNotice?: false | MutateNoticeInput +} + +export type MutateResult = + | { ok: true; value: T } + | { ok: false; busy: true; 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 Set() +let storeDispatch: ((action: AppStoreAction) => void) | null = null + +/** The app store registers its dispatcher so the funnel can record busy state. */ +export const bindStoreDispatch = ( + dispatch: ((action: AppStoreAction) => void) | null, +) => { + storeDispatch = dispatch +} + +/** + * The single funnel for "user intent → backend command": optimistic apply, + * invoke through the services layer, Busy handling, failure rollback, and the + * error toast, in that order, for every mutating call site. + */ +export async function mutate( + invoke: () => Promise, + options: MutateOptions, +): Promise> { + const { id } = options + if (inFlight.has(id)) return { ok: false, busy: true } + inFlight.add(id) + storeDispatch?.({ type: 'busy/started', id }) + + options.optimistic?.() + try { + const value = await invoke() + if (isBusyOutcome(value)) { + if (options.busyNotice !== undefined) { + showNotice.warning(options.busyNotice) + } + return { ok: false, busy: true, value } + } + options.onFulfilled?.(value) + if (options.revalidate?.length) { + await revalidateQueries([...options.revalidate]) + } + if (options.successNotice !== undefined) { + showNotice.success(options.successNotice) + } + return { ok: true, value } + } catch (error) { + await options.rollback?.() + if (options.errorNotice !== false) { + showNotice.error( + options.errorNotice !== undefined ? options.errorNotice : error, + ) + } + throw error + } finally { + inFlight.delete(id) + storeDispatch?.({ type: 'busy/settled', id }) + } +} diff --git a/src/store/app-state.ts b/src/store/app-state.ts index a6158bd7a..e436f29ea 100644 --- a/src/store/app-state.ts +++ b/src/store/app-state.ts @@ -15,6 +15,8 @@ export interface AppStoreState { readonly profileUpdates: ProfileUpdatesState /** Bumped once per test-all request; test items run their delay test per bump. */ readonly testAllCounter: number + /** Mutate-funnel bookkeeping: in-flight intent ids. */ + readonly busy: ReadonlySet } export const initialAppStoreState: AppStoreState = { @@ -26,6 +28,7 @@ export const initialAppStoreState: AppStoreState = { timerRevisions: new Map(), }, testAllCounter: 0, + busy: new Set(), } export type AppStoreAction = @@ -36,6 +39,8 @@ export type AppStoreAction = | { type: 'profileUpdate/timerTick'; uid: string } | { type: 'profileLoading/set'; uids: readonly string[]; loading: boolean } | { type: 'testAll/requested' } + | { type: 'busy/started'; id: string } + | { type: 'busy/settled'; id: string } const bumpRevision = ( map: ReadonlyMap, @@ -105,5 +110,17 @@ export function appStoreReducer( return withLoading(state, action.uids, action.loading) case 'testAll/requested': return { ...state, testAllCounter: state.testAllCounter + 1 } + case 'busy/started': { + if (state.busy.has(action.id)) return state + const busy = new Set(state.busy) + busy.add(action.id) + return { ...state, busy } + } + case 'busy/settled': { + if (!state.busy.has(action.id)) return state + const busy = new Set(state.busy) + busy.delete(action.id) + return { ...state, busy } + } } } diff --git a/src/store/app-store.tsx b/src/store/app-store.tsx index c246f3052..8bf360d2d 100644 --- a/src/store/app-store.tsx +++ b/src/store/app-store.tsx @@ -2,6 +2,7 @@ import { useEffect, useReducer } from 'react' import type { ReactNode } from 'react' import { getPendingFailures, getRuntimeState } from '@/services/cmds' +import { bindStoreDispatch } from '@/services/mutate' import { appStoreReducer, initialAppStoreState } from './app-state' import { AppDispatchContext, AppStateContext } from './app-store-context' @@ -15,6 +16,11 @@ import { AppDispatchContext, AppStateContext } from './app-store-context' export const AppStoreProvider = ({ children }: { children?: ReactNode }) => { const [state, dispatch] = useReducer(appStoreReducer, initialAppStoreState) + useEffect(() => { + bindStoreDispatch(dispatch) + return () => bindStoreDispatch(null) + }, []) + useEffect(() => { const readRunState = () => { getRuntimeState()