fix(service): fall back after installation verification rejects cores

This commit is contained in:
wonfen
2026-09-24 18:43:26 +08:00
parent 3456de1fb6
commit fbc4b516b8
26 changed files with 266 additions and 44 deletions
@@ -1,7 +1,13 @@
import { useState } from 'react'
import { beforeEach, expect, it, vi } from 'vitest'
import type { RunState } from '@/services/cmds'
import {
getRuntimeState,
installService,
restartCore,
type RunState,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useQuery } from '@/services/query-client'
import { ServiceMigrationDialog } from './service-migration-dialog'
@@ -14,8 +20,14 @@ 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('@/services/cmds', () => ({ getRuntimeState: vi.fn() }))
vi.mock('@/services/notice-service', () => ({ showNotice: {} }))
vi.mock('@/services/cmds', () => ({
getRuntimeState: vi.fn(),
installService: vi.fn(),
restartCore: vi.fn(),
}))
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(),
@@ -47,6 +59,36 @@ 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>)
const reason = 'core path C:\\ is writable by Everyone'
vi.mocked(installService).mockResolvedValue({ status: 'sidecar', reason })
vi.mocked(getRuntimeState).mockResolvedValue({
service: 'unavailable',
mode: 'Sidecar',
sidecarAllowed: true,
serviceNeedsAttention: false,
} as RunState)
const dialog = ServiceMigrationDialog()
dialog.props.onOk()
await vi.waitFor(() => {
expect(showNotice.warning).toHaveBeenCalledWith(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason },
)
})
expect(restartCore).not.toHaveBeenCalled()
expect(showNotice.error).not.toHaveBeenCalled()
expect(showNotice.success).not.toHaveBeenCalled()
expect(vi.mocked(useState).mock.results[2].value[1]).toHaveBeenLastCalledWith(
false,
)
})
it.each([
{ service: 'notInstalled', mode: 'NotRunning' },
{ service: 'versionMismatch', mode: 'NotRunning' },
@@ -13,6 +13,7 @@ import {
repairService,
restartCore,
type RunState,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { setCacheData, useQuery } from '@/services/query-client'
@@ -79,16 +80,15 @@ export const ServiceMigrationDialog = () => {
const handleServiceAction = async () => {
setLoading(true)
setWorkflowIncomplete(true)
let actionSucceeded = false
let outcome: ServiceInstallOutcome | undefined
try {
if (remedy === 'install') {
await installService()
outcome = await installService()
} else if (remedy === 'repair') {
await repairService()
outcome = await repairService()
} else {
await reinstallService()
outcome = await reinstallService()
}
actionSucceeded = true
} catch (error) {
showNotice.error(
'layout.components.serviceMigration.errors.actionFailed',
@@ -106,10 +106,19 @@ export const ServiceMigrationDialog = () => {
error,
)
}
if (!actionSucceeded || !initialRefreshSucceeded) {
if (!outcome || !initialRefreshSucceeded) {
setLoading(false)
return
}
if (outcome.status === 'sidecar') {
setWorkflowIncomplete(false)
setLoading(false)
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
)
return
}
let restartSucceeded = false
try {
@@ -12,6 +12,7 @@ import {
restartCore,
type FailedOperation,
type PendingFailure,
type ServiceInstallOutcome,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import {
@@ -99,12 +100,21 @@ export const SysproxyPrivilegeDialog = () => {
const handleFix = async () => {
try {
let outcome: ServiceInstallOutcome | undefined
if (remedy === 'reinstallAndRestart') {
setStep('installing')
await reinstallService()
outcome = await reinstallService()
} else if (remedy === 'installAndRestart') {
setStep('installing')
await installService()
outcome = await installService()
}
if (outcome?.status === 'sidecar') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
)
close()
return
}
setStep('restarting')
await restartCore()
+14 -4
View File
@@ -3,17 +3,18 @@ import { useCallback } from 'react'
import { installService, restartCore } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
const executeWithErrorHandling = async (
operation: () => Promise<void>,
const executeWithErrorHandling = async <T>(
operation: () => Promise<T>,
loadingKey: string,
successKey?: string,
) => {
try {
showNotice.info(loadingKey)
await operation()
const result = await operation()
if (successKey) {
showNotice.success(successKey)
}
return result
} catch (err) {
showNotice.error(err)
throw err
@@ -22,9 +23,18 @@ const executeWithErrorHandling = async (
export const useServiceInstaller = () => {
const installServiceAndRestartCore = useCallback(async () => {
await executeWithErrorHandling(
const outcome = await executeWithErrorHandling(
() => installService(),
'settings.statuses.clashService.installing',
)
if (outcome.status === 'sidecar') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: outcome.reason },
)
return
}
showNotice.success(
'settings.feedback.notifications.clashService.installSuccess',
)
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "تم تثبيت الخدمة بنجاح",
"permissionFallback": "لم تجتز ملفات نواة الخدمة أو مجلداتها فحص الأمان. تم التبديل إلى وضع Sidecar؛ ويتطلب TUN صلاحيات المسؤول. السبب: {{reason}}",
"sidecarFallback": "تعذّر على الخدمة تشغيل النواة. تم التبديل إلى وضع Sidecar؛ ولا تزال الخدمة غير متاحة.",
"uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "Service erfolgreich installiert",
"permissionFallback": "Die Kerndateien oder Verzeichnisse des Dienstes haben die Sicherheitsprüfung nicht bestanden. Die Anwendung läuft im Sidecar-Modus weiter; TUN benötigt Administratorrechte. Grund: {{reason}}",
"sidecarFallback": "Der Dienst konnte den Kern nicht starten. Die Anwendung läuft im Sidecar-Modus weiter; der Dienst bleibt nicht verfügbar.",
"uninstallSuccess": "Service erfolgreich deinstalliert"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "Service Installed Successfully",
"permissionFallback": "The service's core files or directories failed a security check. Switched to Sidecar mode; TUN requires administrator privileges. Reason: {{reason}}",
"sidecarFallback": "The service could not start the core. Switched to Sidecar mode; the service remains unavailable.",
"uninstallSuccess": "Service Uninstalled Successfully"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "Servicio instalado con éxito",
"permissionFallback": "Los archivos o directorios del núcleo del servicio no superaron la comprobación de seguridad. Se ha cambiado al modo Sidecar; TUN requiere permisos de administrador. Motivo: {{reason}}",
"sidecarFallback": "El servicio no pudo iniciar el núcleo. Se ha cambiado al modo Sidecar; el servicio sigue sin estar disponible.",
"uninstallSuccess": "Servicio desinstalado con éxito"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "سرویس با موفقیت نصب شد",
"permissionFallback": "فایل‌ها یا پوشه‌های هستهٔ سرویس از بررسی امنیتی عبور نکردند. به حالت Sidecar تغییر یافت؛ TUN به دسترسی مدیر نیاز دارد. دلیل: {{reason}}",
"sidecarFallback": "سرویس نتوانست هسته را اجرا کند. اجرا در حالت Sidecar ادامه می‌یابد؛ سرویس همچنان در دسترس نیست.",
"uninstallSuccess": "سرویس با موفقیت حذف نصب شد"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "Layanan Berhasil Diinstal",
"permissionFallback": "Berkas atau direktori inti layanan tidak lolos pemeriksaan keamanan. Beralih ke mode Sidecar; TUN memerlukan hak administrator. Alasan: {{reason}}",
"sidecarFallback": "Layanan tidak dapat memulai inti. Beralih ke mode Sidecar; layanan masih tidak tersedia.",
"uninstallSuccess": "Layanan Berhasil Dicopot"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "サービスのインストールに成功しました。",
"permissionFallback": "サービスのコアファイルまたはディレクトリがセキュリティチェックに失敗したため、Sidecar モードに切り替えました。TUN には管理者権限が必要です。理由:{{reason}}",
"sidecarFallback": "サービスでコアを起動できなかったため、Sidecar モードで動作を継続しています。サービスは引き続き利用できません。",
"uninstallSuccess": "サービスのアンインストールに成功しました。"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "서비스가 설치되었습니다",
"permissionFallback": "서비스의 코어 파일 또는 디렉터리가 보안 검사를 통과하지 못해 Sidecar 모드로 전환했습니다. TUN에는 관리자 권한이 필요합니다. 사유: {{reason}}",
"sidecarFallback": "서비스가 코어를 시작하지 못해 Sidecar 모드로 계속 실행합니다. 서비스는 여전히 사용할 수 없습니다.",
"uninstallSuccess": "서비스가 제거되었습니다"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "Служба успешно установлена",
"permissionFallback": "Файлы или каталоги ядра службы не прошли проверку безопасности. Работа продолжена в режиме Sidecar; для TUN нужны права администратора. Причина: {{reason}}",
"sidecarFallback": "Служба не смогла запустить ядро. Работа продолжена в режиме Sidecar; служба по-прежнему недоступна.",
"uninstallSuccess": "Служба успешно удалена"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "Hizmet Başarıyla Kuruldu",
"permissionFallback": "Hizmetin çekirdek dosyaları veya dizinleri güvenlik denetimini geçemedi. Sidecar moduna geçildi; TUN için yönetici yetkileri gerekir. Neden: {{reason}}",
"sidecarFallback": "Hizmet çekirdeği başlatamadı. Sidecar moduna geçildi; hizmet hâlâ kullanılamıyor.",
"uninstallSuccess": "Hizmet Başarıyla Kaldırıldı"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "Сервис уңышлы урнаштырылды",
"permissionFallback": "Хезмәт төшенең файллары яки каталоглары иминлек тикшерүен үтмәде. Sidecar режимына күчелде; TUN өчен администратор хокуклары кирәк. Сәбәп: {{reason}}",
"sidecarFallback": "Хезмәт төшне эшләтеп җибәрә алмады. Эш Sidecar режимында дәвам итә; хезмәт һаман да эшләми.",
"uninstallSuccess": "Сервис уңышлы салдырылды"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "已成功安装服务",
"permissionFallback": "服务内核的文件或目录未通过安全检查,已切换到普通模式(Sidecar);普通权限下 TUN 模式不可用。原因:{{reason}}",
"sidecarFallback": "服务无法启动内核,已降级为 Sidecar 模式运行,服务仍不可用。",
"uninstallSuccess": "已成功卸载服务"
},
+1
View File
@@ -684,6 +684,7 @@
},
"clashService": {
"installSuccess": "已成功安裝服務",
"permissionFallback": "服務核心的檔案或目錄未通過安全檢查,已切換至一般模式(Sidecar);一般權限下無法使用 TUN 模式。原因:{{reason}}",
"sidecarFallback": "服務無法啟動核心,已降級為 Sidecar 模式執行,服務仍無法使用。",
"uninstallSuccess": "已成功解除安裝服務"
},
+7 -3
View File
@@ -454,8 +454,12 @@ export const getAppUptime = async () => {
return invoke<number>('get_app_uptime')
}
export type ServiceInstallOutcome =
| { status: 'installed' }
| { status: 'sidecar'; reason: string }
export const installService = async () => {
return invoke<void>('install_service')
return invoke<ServiceInstallOutcome>('install_service')
}
export const uninstallService = async () => {
@@ -463,11 +467,11 @@ export const uninstallService = async () => {
}
export const reinstallService = async () => {
return invoke<void>('reinstall_service')
return invoke<ServiceInstallOutcome>('reinstall_service')
}
export const repairService = async () => {
return invoke<void>('repair_service')
return invoke<ServiceInstallOutcome>('repair_service')
}
export const continueWithSidecar = async () => {
+1
View File
@@ -804,6 +804,7 @@ export const translationKeys = [
'settings.feedback.notifications.clash.changeFailed',
'settings.feedback.notifications.clash.geoDataUpdated',
'settings.feedback.notifications.clashService.installSuccess',
'settings.feedback.notifications.clashService.permissionFallback',
'settings.feedback.notifications.clashService.sidecarFallback',
'settings.feedback.notifications.clashService.uninstallSuccess',
'settings.feedback.notifications.updater.withClashProxySuccess',
+1
View File
@@ -845,6 +845,7 @@ export interface TranslationResources {
}
clashService: {
installSuccess: string
permissionFallback: string
sidecarFallback: string
uninstallSuccess: string
}