From 763338dadeb265e9b60e12cd7b1002cad28360e1 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:49:14 +0800 Subject: [PATCH] feat(mutate): single funnel for user-intent commands and invoke gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/frontend-check.yml | 4 +- src/components/setting/mods/dns-viewer.tsx | 21 +++-- src/hooks/use-clash.ts | 14 ++-- src/hooks/use-profiles.ts | 24 ++++-- src/hooks/use-verge.ts | 14 ++-- src/pages/unlock.tsx | 25 +++--- src/services/cmds.ts | 37 ++++++++- src/services/mutate.ts | 90 ++++++++++++++++++++++ src/store/app-state.ts | 17 ++++ src/store/app-store.tsx | 6 ++ 10 files changed, 204 insertions(+), 48 deletions(-) create mode 100644 src/services/mutate.ts 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()