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:
wonfen
2026-07-24 09:01:49 +08:00
committed by GitHub
parent c079da6c99
commit 23dda47641
90 changed files with 9711 additions and 900 deletions
@@ -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)
}