mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 19:22:44 +08:00
feat: add launch guard and secure multi-user service ownership (#7569)
* feat(service): isolate macOS launch and owner runtime * ci(windows): run owner security tests * chore(deps): use kode-bridge v0.4.1 tag * fix(macos): classify installed service state * fix(macos): classify marked unavailable service * fix(macos): gate core startup on service migration * fix(macos): scope service init to supported platforms * feat(macos): prompt before migrating installed service * fix(macos): shorten sidecar IPC path * build(service): pin helper protocol and artifacts * fix(core): require confirmed service and sidecar readiness * fix(macos): harden launch roots and singleton state * fix(ui): refresh migration state before restart * fix(macos): linearize service migration lifecycle * fix(ui): keep service migration state fresh * feat(service): track authenticated owner sessions * fix(service): preserve newer owner sessions * feat(macos): route service proxy changes through helper * fix(macos): serialize service proxy guard * fix(macos): close service proxy owner lifecycle * fix(macos): close remaining proxy stop races * fix(app): do not cancel controlled service stop * fix(lifecycle): keep controlled updates cancellation-safe * fix(macos): stage dev core for service * test(macos): exercise staged core cleanup * fix(dev): await controlled macOS exit * chore: update changelog * fix(macos): recover sidecar after service install failure Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state. * fix(macos): stage dev service tools before elevation * fix(macos): localize launch guard prompts * fix(service): address cross-platform integration issues * feat: support seamless service migration * fix: pass Windows service installer path safely * fix(prebuild): derive service release from Cargo dependency * fix(dev): preserve service installation state * fix: resolve clippy warnings in service lifecycle
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { Alert } from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BaseDialog } from '@/components/base'
|
||||
import { fetchSystemState } from '@/hooks/use-system-state'
|
||||
import { useVisibility } from '@/hooks/use-visibility'
|
||||
import {
|
||||
continueWithSidecar,
|
||||
getRunningMode,
|
||||
getServiceInstallState,
|
||||
installService,
|
||||
reinstallService,
|
||||
repairService,
|
||||
restartCore,
|
||||
type ServiceInstallState,
|
||||
} from '@/services/cmds'
|
||||
import { showNotice } from '@/services/notice-service'
|
||||
import { setCacheDataAsync, useQuery } from '@/services/query-client'
|
||||
const installStateQueryKey = ['getServiceInstallState'] as const
|
||||
|
||||
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: state } = useQuery({
|
||||
queryKey: installStateQueryKey,
|
||||
queryFn: getServiceInstallState,
|
||||
enabled: true,
|
||||
retry: 1,
|
||||
refetchInterval: pageVisible ? 2000 : false,
|
||||
})
|
||||
const dialogState = stateRefreshFailed ? 'unavailable' : state
|
||||
const open =
|
||||
loading ||
|
||||
workflowIncomplete ||
|
||||
dialogState === 'installRequired' ||
|
||||
dialogState === 'needsReinstall' ||
|
||||
dialogState === 'unavailable'
|
||||
const showCheckingMessage =
|
||||
loading ||
|
||||
(dialogState !== 'installRequired' &&
|
||||
dialogState !== 'needsReinstall' &&
|
||||
dialogState !== 'unavailable')
|
||||
|
||||
const refreshInstallState = async () => {
|
||||
try {
|
||||
const data = await getServiceInstallState()
|
||||
await setCacheDataAsync<ServiceInstallState>(installStateQueryKey, data)
|
||||
setStateRefreshFailed(false)
|
||||
return data
|
||||
} catch (error) {
|
||||
setStateRefreshFailed(true)
|
||||
await setCacheDataAsync<ServiceInstallState>(
|
||||
installStateQueryKey,
|
||||
'unavailable',
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSystemAndRunning = async () => {
|
||||
const [systemState, runningMode] = await Promise.all([
|
||||
fetchSystemState(),
|
||||
getRunningMode(),
|
||||
])
|
||||
await Promise.all([
|
||||
setCacheDataAsync(['getSystemState'], { ...systemState, runningMode }),
|
||||
setCacheDataAsync(['getRunningMode'], runningMode),
|
||||
])
|
||||
}
|
||||
|
||||
const handleServiceAction = async () => {
|
||||
setLoading(true)
|
||||
setWorkflowIncomplete(true)
|
||||
let actionSucceeded = false
|
||||
try {
|
||||
if (dialogState === 'installRequired') {
|
||||
await installService()
|
||||
} else if (dialogState === 'unavailable') {
|
||||
await repairService()
|
||||
} else {
|
||||
await reinstallService()
|
||||
}
|
||||
actionSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
}
|
||||
|
||||
let initialRefreshSucceeded = false
|
||||
try {
|
||||
await refreshInstallState()
|
||||
initialRefreshSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
}
|
||||
if (!actionSucceeded || !initialRefreshSucceeded) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let restartSucceeded = false
|
||||
try {
|
||||
await restartCore()
|
||||
restartSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
}
|
||||
|
||||
let finalRefreshSucceeded = false
|
||||
try {
|
||||
await refreshInstallState()
|
||||
finalRefreshSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
}
|
||||
let revalidationSucceeded = false
|
||||
try {
|
||||
await refreshSystemAndRunning()
|
||||
revalidationSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
}
|
||||
if (restartSucceeded && finalRefreshSucceeded && revalidationSucceeded) {
|
||||
setWorkflowIncomplete(false)
|
||||
showNotice.success('layout.components.serviceMigration.success')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleContinue = async () => {
|
||||
setLoading(true)
|
||||
setWorkflowIncomplete(true)
|
||||
let startupError: unknown
|
||||
try {
|
||||
await continueWithSidecar()
|
||||
} catch (error) {
|
||||
startupError = error
|
||||
}
|
||||
|
||||
let installRefreshSucceeded = false
|
||||
try {
|
||||
await refreshInstallState()
|
||||
installRefreshSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
}
|
||||
let revalidationSucceeded = false
|
||||
try {
|
||||
await refreshSystemAndRunning()
|
||||
revalidationSucceeded = true
|
||||
} catch (error) {
|
||||
showNotice.error(error)
|
||||
}
|
||||
if (startupError) {
|
||||
showNotice.error(startupError)
|
||||
} else if (installRefreshSucceeded && revalidationSucceeded) {
|
||||
setWorkflowIncomplete(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t('layout.components.serviceMigration.title')}
|
||||
okBtn={t(
|
||||
dialogState === 'installRequired'
|
||||
? 'settings.sections.proxyControl.actions.installService'
|
||||
: dialogState === 'unavailable'
|
||||
? 'layout.components.serviceMigration.repair'
|
||||
: 'layout.components.serviceMigration.reinstall',
|
||||
)}
|
||||
cancelBtn={t('layout.components.serviceMigration.continueSidecar')}
|
||||
disableOk={loading}
|
||||
disableCancel={loading}
|
||||
loading={loading}
|
||||
onOk={() => void handleServiceAction()}
|
||||
onCancel={() => void handleContinue()}
|
||||
>
|
||||
<Alert severity="warning">
|
||||
{t(
|
||||
showCheckingMessage
|
||||
? 'layout.components.serviceMigration.checkingMessage'
|
||||
: dialogState === 'installRequired' || dialogState === 'unavailable'
|
||||
? 'layout.components.serviceMigration.unavailableMessage'
|
||||
: 'layout.components.serviceMigration.message',
|
||||
)}
|
||||
</Alert>
|
||||
</BaseDialog>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { useVerge } from '@/hooks/use-verge'
|
||||
import { useClashConfigData, useSystemData } from '@/providers/app-data-context'
|
||||
import {
|
||||
getAutotemProxy,
|
||||
getEmbeddedServerPort,
|
||||
getNetworkInterfacesInfo,
|
||||
getSystemHostname,
|
||||
getSystemProxy,
|
||||
@@ -134,6 +135,13 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
pac_content: pac_file_content ?? DEFAULT_PAC,
|
||||
proxy_host: proxy_host ?? '127.0.0.1',
|
||||
})
|
||||
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
getEmbeddedServerPort().then(setEmbeddedServerPort).catch(console.error)
|
||||
}, [])
|
||||
|
||||
const separator = useMemo(() => (isWindows ? ';' : ','), [isWindows])
|
||||
|
||||
@@ -203,10 +211,10 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||
])
|
||||
const getCurrentPacUrl = useMemo(() => {
|
||||
const host = value.proxy_host || '127.0.0.1'
|
||||
// 根据环境判断PAC端口
|
||||
const port = import.meta.env.DEV ? 11233 : 33331
|
||||
return `http://${host}:${port}/commands/pac`
|
||||
}, [value.proxy_host])
|
||||
return embeddedServerPort
|
||||
? `http://${host}:${embeddedServerPort}/commands/pac`
|
||||
: '-'
|
||||
}, [embeddedServerPort, value.proxy_host])
|
||||
|
||||
const bypassError =
|
||||
value.enable_bypass_check &&
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { alpha, Box, Button, LinearProgress } from '@mui/material'
|
||||
import { relaunch } from '@tauri-apps/plugin-process'
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell'
|
||||
import type { DownloadEvent } from '@tauri-apps/plugin-updater'
|
||||
import { useLockFn } from 'ahooks'
|
||||
@@ -17,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 { showNotice } from '@/services/notice-service'
|
||||
import { useSetUpdateState, useUpdateState } from '@/services/states'
|
||||
|
||||
@@ -197,7 +197,7 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
|
||||
try {
|
||||
await updateInfo.downloadAndInstall(onDownloadEvent)
|
||||
await relaunch()
|
||||
await restartApp()
|
||||
} catch (err: any) {
|
||||
showNotice.error(err)
|
||||
} finally {
|
||||
|
||||
@@ -132,7 +132,7 @@ const ProxyControlSwitches = ({
|
||||
const { t } = useTranslation()
|
||||
const { verge, mutateVerge, patchVerge } = useVerge()
|
||||
const { installServiceAndRestartCore } = useServiceInstaller()
|
||||
const { uninstallServiceAndRestartCore } = useServiceUninstaller()
|
||||
const { uninstallServiceAndStartSidecar } = useServiceUninstaller()
|
||||
const { indicator: systemProxyIndicator, toggleSystemProxy } =
|
||||
useSystemProxyState()
|
||||
const { isServiceOk, isTunModeAvailable, mutateSystemState } =
|
||||
@@ -169,11 +169,7 @@ const ProxyControlSwitches = ({
|
||||
|
||||
const onUninstallService = useLockFn(async () => {
|
||||
try {
|
||||
if (verge?.enable_tun_mode) {
|
||||
await handleTunToggle(false)
|
||||
}
|
||||
await uninstallServiceAndRestartCore()
|
||||
await mutateSystemState()
|
||||
await uninstallServiceAndStartSidecar()
|
||||
} catch (err) {
|
||||
showNotice.error(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user