Files
clash-verge-rev/src/components/layout/service-migration-dialog.tsx
T
Tunglies 4e62db44f8 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.
2026-10-01 04:20:26 +08:00

223 lines
6.7 KiB
TypeScript

import { Alert } from '@mui/material'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseDialog } from '@/components/base'
import {
continueWithSidecar,
getRuntimeState,
installService,
reinstallService,
repairService,
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'
export const ServiceMigrationDialog = () => {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [stateRefreshFailed, setStateRefreshFailed] = useState(false)
const [workflowIncomplete, setWorkflowIncomplete] = useState(false)
const dispatch = useAppDispatch()
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 =
stateRefreshFailed || Boolean(runState?.serviceNeedsAttention)
// Treat refresh failures as unreachable; an absent Service still needs install after a failed Sidecar attempt.
const remedy: 'install' | 'repair' | 'reinstall' =
runState?.pendingAction === 'install'
? 'install'
: stateRefreshFailed || runState?.service === 'unavailable'
? 'repair'
: runState?.service === 'notInstalled'
? 'install'
: 'reinstall'
const open = loading || workflowIncomplete || needsDecision
const checking =
loading ||
!runState ||
runState.opInFlight ||
runState.service === 'unknown'
const showCheckingMessage = checking || !needsDecision
const canContinue = Boolean(
runState &&
!stateRefreshFailed &&
(runState.pendingAction === 'install'
? runState.mode === 'NotRunning' || runState.mode === 'Sidecar'
: !runState.pendingAction &&
!runState.sidecarAllowed &&
runState.mode === 'NotRunning' &&
(runState.service === 'notInstalled' ||
runState.service === 'versionMismatch' ||
runState.service === 'unavailable')),
)
// One store entry to refresh, so there is nothing left to keep coherent by hand.
const refreshRunState = async () => {
try {
const data = await getRuntimeState()
dispatch({ type: 'runState/loaded', runState: data })
setStateRefreshFailed(false)
return data
} catch (error) {
setStateRefreshFailed(true)
throw error
}
}
const handleServiceAction = async () => {
setLoading(true)
setWorkflowIncomplete(true)
let outcome: ServiceInstallOutcome | undefined
try {
if (remedy === 'install') {
const result = await mutate(() => installService(), {
id: 'install-service',
errorNotice: false,
})
if (result.ok) outcome = result.value
} else if (remedy === 'repair') {
const result = await mutate(() => repairService(), {
id: 'repair-service',
errorNotice: false,
})
if (result.ok) outcome = result.value
} else {
const result = await mutate(() => reinstallService(), {
id: 'reinstall-service',
errorNotice: false,
})
if (result.ok) outcome = result.value
}
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.actionFailed',
error,
)
}
let initialRefreshSucceeded = false
try {
await refreshRunState()
initialRefreshSucceeded = true
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.stateRefreshFailed',
error,
)
}
if (!outcome || !initialRefreshSucceeded) {
setLoading(false)
return
}
if (outcome.status === 'sidecar') {
setWorkflowIncomplete(false)
setLoading(false)
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
0,
)
return
}
let restartSucceeded = false
try {
const result = await mutate(() => restartCore(), {
id: 'restart-core',
errorNotice: false,
})
restartSucceeded = result.ok
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.restartFailed',
error,
)
}
let finalRefreshSucceeded = false
try {
await refreshRunState()
finalRefreshSucceeded = true
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.stateRefreshFailed',
error,
)
}
if (restartSucceeded && finalRefreshSucceeded) {
setWorkflowIncomplete(false)
showNotice.success('layout.components.serviceMigration.success')
}
setLoading(false)
}
const handleContinue = async () => {
setLoading(true)
setWorkflowIncomplete(true)
let startupError: unknown
try {
await mutate(() => continueWithSidecar(), {
id: 'continue-with-sidecar',
errorNotice: false,
})
} catch (error) {
startupError = error
}
let installRefreshSucceeded = false
try {
await refreshRunState()
installRefreshSucceeded = true
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.stateRefreshFailed',
error,
)
}
if (startupError) {
showNotice.error(
'layout.components.serviceMigration.errors.sidecarFailed',
startupError,
)
} else if (installRefreshSucceeded) {
setWorkflowIncomplete(false)
}
setLoading(false)
}
return (
<BaseDialog
open={open}
title={t('layout.components.serviceMigration.title')}
okBtn={t(
remedy === 'install'
? 'settings.sections.proxyControl.actions.installService'
: remedy === 'repair'
? 'layout.components.serviceMigration.repair'
: 'layout.components.serviceMigration.reinstall',
)}
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
disableOk={checking}
disableCancel={checking || !canContinue}
loading={loading}
onOk={() => void handleServiceAction()}
onCancel={() => void handleContinue()}
>
<Alert severity="warning">
{t(
showCheckingMessage
? 'layout.components.serviceMigration.checkingMessage'
: remedy === 'reinstall'
? 'layout.components.serviceMigration.message'
: 'layout.components.serviceMigration.unavailableMessage',
)}
</Alert>
</BaseDialog>
)
}