mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-10 04:12:08 +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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user