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.
This commit is contained in:
Tunglies
2026-10-01 04:20:26 +08:00
parent 24a3a18758
commit 4e62db44f8
15 changed files with 232 additions and 80 deletions
@@ -12,6 +12,7 @@ import {
restartCore,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useAppDispatch, useRunState } from '@/store/app-store-context'
@@ -74,11 +75,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(
@@ -114,8 +127,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',
@@ -145,7 +161,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
}
@@ -14,6 +14,7 @@ import {
type PendingFailure,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import {
clearServiceRequest,
@@ -103,10 +104,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(
@@ -118,7 +127,10 @@ export const SysproxyPrivilegeDialog = () => {
return
}
setStep('restarting')
await restartCore()
await mutate(() => restartCore(), {
id: 'restart-core',
errorNotice: false,
})
const runState = await getRuntimeState()
const usingAdminFallback =
@@ -131,7 +143,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(
+4 -4
View File
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router'
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 { mutate } from '@/services/mutate'
import type { ProxyEmptyStateReason } from './proxy-empty-state-model'
@@ -25,10 +25,10 @@ export const ProxyEmptyState = ({ reason }: Props) => {
const handleRestart = useLockFn(async () => {
setIsRestarting(true)
try {
await restartCore()
await mutate(() => restartCore(), { id: 'restart-core' })
await Promise.all([refreshProxy(), mutateSystemState()])
} catch (error) {
showNotice.error(error)
} catch {
// the funnel already notified
} finally {
setIsRestarting(false)
}
@@ -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,17 +229,29 @@ 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')
@@ -265,8 +278,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',
+17 -7
View File
@@ -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)
}
@@ -15,7 +15,8 @@ import { useTranslation } from 'react-i18next'
import { BaseDialog, Switch } from '@/components/base'
import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
import { useVerge } from '@/hooks/use-verge'
import { saveProxyPorts } from '@/services/cmds'
import { saveProxyPorts, type ProxyPortSettings } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import getSystem from '@/utils/get-system'
@@ -57,22 +58,31 @@ export const ClashPortViewer = forwardRef<ClashPortViewerRef>((_, ref) => {
)
// 添加保存请求,防止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: () => {
@@ -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()
},
{
@@ -16,6 +16,7 @@ import type { Options as ReactMarkdownOptions } from 'react-markdown'
import { BaseDialog, DialogRef } from '@/components/base'
import { useUpdate } from '@/hooks/use-update'
import { 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'
@@ -270,8 +271,14 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
try {
await updateInfo.downloadAndInstall(onDownloadEvent)
await restartApp()
await mutate(() => updateInfo.downloadAndInstall(onDownloadEvent), {
id: 'install-app-update',
errorNotice: false,
})
await mutate(() => restartApp(), {
id: 'restart-app',
errorNotice: false,
})
} catch (err: any) {
showNotice.error(err)
} finally {
+14 -2
View File
@@ -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')}
/>
+15 -5
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { installService, restartCore } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
const executeWithErrorHandling = async <T>(
@@ -23,14 +24,19 @@ const executeWithErrorHandling = async <T>(
export const useServiceInstaller = () => {
const installServiceAndRestartCore = useCallback(async () => {
const outcome = await executeWithErrorHandling(
() => installService(),
const installResult = await executeWithErrorHandling(
() =>
mutate(() => installService(), {
id: 'install-service',
errorNotice: false,
}),
'settings.statuses.clashService.installing',
)
if (outcome.status === 'sidecar') {
if (!installResult.ok) return
if (installResult.value.status === 'sidecar') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
{ reason: installResult.value.reason },
0,
)
return
@@ -40,7 +46,11 @@ export const useServiceInstaller = () => {
)
await executeWithErrorHandling(
() => restartCore(),
() =>
mutate(() => restartCore(), {
id: 'restart-core',
errorNotice: false,
}),
'settings.statuses.clash.restarting',
'settings.feedback.notifications.clash.restartSuccess',
)
+10 -4
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { uninstallService } from '@/services/cmds'
import { mutate } from '@/services/mutate'
import { showNotice } from '@/services/notice-service'
import { useSystemState } from './use-system-state'
@@ -12,10 +13,15 @@ export const useServiceUninstaller = () => {
let uninstallError: unknown
showNotice.info('settings.statuses.clashService.uninstalling')
try {
await uninstallService()
showNotice.success(
'settings.feedback.notifications.clashService.uninstallSuccess',
)
const result = await mutate(() => uninstallService(), {
id: 'uninstall-service',
errorNotice: false,
})
if (result.ok) {
showNotice.success(
'settings.feedback.notifications.clashService.uninstallSuccess',
)
}
} catch (error) {
uninstallError = error
}
+9 -2
View File
@@ -9,6 +9,7 @@ import {
getEmbeddedServerPort,
patchVergeConfig,
} from '@/services/cmds'
import { mutate } from '@/services/mutate'
import {
removeCacheData,
revalidateQueries,
@@ -64,10 +65,16 @@ export const useSystemProxyState = () => {
while (pendingRef.current !== null) {
const target = pendingRef.current
pendingRef.current = null
await patchVergeConfig({ enable_system_proxy: target })
await mutate(() => patchVergeConfig({ enable_system_proxy: target }), {
id: 'toggle-system-proxy',
errorNotice: false,
})
confirmed = target
if (!target && verge?.auto_close_connection) {
await closeAllConnections().catch(() => {})
await mutate(() => closeAllConnections(), {
id: 'close-all-connections',
errorNotice: false,
}).catch(() => {})
}
}
} catch (error) {