mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-24 20:52:15 +08:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<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 +519,9 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
config = parsedConfig as Record<string, any>
|
||||
}
|
||||
|
||||
await invoke('save_dns_config', { dnsConfig: config })
|
||||
await saveDnsConfig(config)
|
||||
|
||||
const validation = await invoke<ValidationOutcome>(
|
||||
'validate_dns_config',
|
||||
{},
|
||||
)
|
||||
const validation = await validateDnsConfig()
|
||||
|
||||
if (validation.status !== 'valid') {
|
||||
const errorMsg =
|
||||
@@ -559,7 +558,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
}
|
||||
|
||||
if (dnsEnabled) {
|
||||
await invoke('apply_dns_config', { apply: true })
|
||||
await applyDnsConfig(true)
|
||||
mutateClash()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IConfigData>) => {
|
||||
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'])
|
||||
|
||||
@@ -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<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 ?? { status: 'busy' }
|
||||
}
|
||||
const outcome = result.value
|
||||
if (outcome.status !== 'valid' && outcome.status !== 'busy') {
|
||||
await mutateProfiles()
|
||||
}
|
||||
|
||||
return outcome
|
||||
} catch (error) {
|
||||
await mutateProfiles()
|
||||
|
||||
@@ -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<IVergeConfig>) => {
|
||||
await patchVergeConfig(value)
|
||||
await refetch()
|
||||
},
|
||||
[refetch],
|
||||
)
|
||||
const patchVerge = useCallback(async (value: Partial<IVergeConfig>) => {
|
||||
await mutate(() => patchVergeConfig(value), {
|
||||
id: 'patch-verge-config',
|
||||
revalidate: [['getVergeConfig']],
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
verge,
|
||||
|
||||
+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,
|
||||
)
|
||||
|
||||
+36
-1
@@ -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<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')
|
||||
}
|
||||
@@ -540,3 +556,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,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<typeof showNotice.error>[0]
|
||||
|
||||
export interface MutateOptions<T> {
|
||||
/** 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<void>
|
||||
/** 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<T> =
|
||||
| { 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<string>()
|
||||
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<T>(
|
||||
invoke: () => Promise<T>,
|
||||
options: MutateOptions<T>,
|
||||
): Promise<MutateResult<T>> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
}
|
||||
|
||||
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<string, number>,
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user