fix(service): prompt reinstallation after path permission rejection

Address #7983 with a recovery dialog for Windows service error 1003, including startup refusals received before the UI subscribes. Reinstall the service and require service mode before reporting recovery.
This commit is contained in:
wonfen
2026-09-22 09:02:56 +08:00
parent 93b7bb8fe8
commit bffe91db5e
25 changed files with 105 additions and 11 deletions
+3
View File
@@ -11,6 +11,9 @@
**🐧 Linux**
- Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题
**🖥️ Windows**
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
</details>
+5
View File
@@ -62,6 +62,11 @@ pub fn take_service_fallback_notice() -> bool {
crate::core::service::take_service_fallback_notice()
}
#[tauri::command]
pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
}
#[tauri::command]
pub fn sync_runtime_providers() {
if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) {
+11
View File
@@ -43,6 +43,7 @@ pub(crate) mod windows_fallback;
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback() {
@@ -54,6 +55,10 @@ pub(crate) fn take_service_fallback_notice() -> bool {
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
}
pub(crate) fn take_service_repair_notice() -> bool {
PENDING_SERVICE_REPAIR_NOTICE.swap(false, Ordering::Relaxed)
}
/// Capabilities of the service session that owns the running Core.
/// They are discarded with that session rather than cached across service upgrades.
#[derive(Clone)]
@@ -1153,6 +1158,11 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
response.code,
err_msg
);
#[cfg(target_os = "windows")]
if response.code == ServiceErrorCode::InvalidInstallLocation as u16 {
PENDING_SERVICE_REPAIR_NOTICE.store(true, Ordering::Relaxed);
Handle::notice_message("service_core::repair_required", "");
}
start_owner_monitor();
return Err(ServiceStartRefusal {
code: response.code,
@@ -1179,6 +1189,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
start_owner_monitor();
tracing::Span::current().record("outcome", "started");
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
logging!(
info,
Type::Service,
+1
View File
@@ -171,6 +171,7 @@ mod app_init {
cmd::set_dns_override,
cmd::take_dns_override_notice,
cmd::take_service_fallback_notice,
cmd::take_service_repair_notice,
cmd::take_discarded_keys_notice,
cmd::get_dns_config_content,
cmd::validate_dns_config,
@@ -8,6 +8,7 @@ import {
getRuntimeState,
installService,
patchVergeConfig,
reinstallService,
restartCore,
type FailedOperation,
type PendingFailure,
@@ -22,22 +23,29 @@ import {
} from '@/services/service-request'
import getSystem from '@/utils/get-system'
type Remedy = 'installAndRestart' | 'restartOnly'
type Remedy = 'installAndRestart' | 'reinstallAndRestart' | 'restartOnly'
const remedyFor = (reason: ServiceRequestReason): Remedy =>
reason === 'sysproxySidecarReady' ? 'restartOnly' : 'installAndRestart'
reason === 'serviceLocationRefused'
? 'reinstallAndRestart'
: reason === 'sysproxySidecarReady'
? 'restartOnly'
: 'installAndRestart'
const EXPLANATION = {
sysproxyRefused: 'layout.components.sysproxyPrivilege.message',
sysproxySidecarReady:
'layout.components.sysproxyPrivilege.serviceReadyMessage',
tunNeedsService: 'layout.components.sysproxyPrivilege.tunMessage',
serviceLocationRefused:
'layout.components.serviceMigration.locationRefusedMessage',
} as const
const TITLE = {
sysproxyRefused: 'layout.components.sysproxyPrivilege.title',
sysproxySidecarReady: 'layout.components.sysproxyPrivilege.title',
tunNeedsService: 'layout.components.sysproxyPrivilege.tunTitle',
serviceLocationRefused: 'layout.components.serviceMigration.repair',
} as const
const stateToRestore = (
@@ -70,7 +78,6 @@ const STEP_MESSAGE = {
applying: 'layout.components.sysproxyPrivilege.applying',
} as const
/** Guide recovery from a refused system-proxy write. */
export const SysproxyPrivilegeDialog = () => {
const { t } = useTranslation()
const { failure, dismiss } = useDialogFailure()
@@ -92,7 +99,10 @@ export const SysproxyPrivilegeDialog = () => {
const handleFix = async () => {
try {
if (remedy === 'installAndRestart') {
if (remedy === 'reinstallAndRestart') {
setStep('installing')
await reinstallService()
} else if (remedy === 'installAndRestart') {
setStep('installing')
await installService()
}
@@ -101,6 +111,7 @@ export const SysproxyPrivilegeDialog = () => {
const runState = await getRuntimeState()
const usingAdminFallback =
remedy !== 'reinstallAndRestart' &&
getSystem() === 'windows' &&
runState.mode === 'Sidecar' &&
runState.isAdmin &&
@@ -113,9 +124,11 @@ export const SysproxyPrivilegeDialog = () => {
}
if (!usingAdminFallback) {
showNotice.success(
restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
remedy === 'reinstallAndRestart'
? 'layout.components.serviceMigration.success'
: restoring === undefined
? 'settings.sections.proxyControl.messages.installedCheckProxy'
: 'settings.sections.proxyControl.messages.installedProxyRestored',
)
}
close()
@@ -136,9 +149,11 @@ export const SysproxyPrivilegeDialog = () => {
open={Boolean(request)}
title={t(TITLE[reason])}
okBtn={t(
remedy === 'installAndRestart'
? 'settings.sections.proxyControl.actions.installService'
: 'settings.sections.proxyControl.actions.switchToServiceMode',
remedy === 'reinstallAndRestart'
? 'layout.components.serviceMigration.reinstall'
: remedy === 'installAndRestart'
? 'settings.sections.proxyControl.actions.installService'
: 'settings.sections.proxyControl.actions.switchToServiceMode',
)}
cancelBtn={t('layout.components.sysproxyPrivilege.later')}
// Keep the primary spinner visible; only cancellation is unavailable.
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "لا يمكن للخدمة تشغيل النواة لأن مسار التثبيت أو أذونات المجلد لم تجتز فحص الأمان. أعد تثبيت الخدمة لاستعادة ملفاتها وأذوناتها، ثم حاول مجددًا. قد يطلب Windows موافقة المسؤول.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "Der Dienst kann den Kern nicht starten, da sein Installationspfad oder die Verzeichnisberechtigungen die Sicherheitsprüfung nicht bestanden haben. Installieren Sie den Dienst erneut, um Dateien und Berechtigungen wiederherzustellen, und versuchen Sie es erneut. Windows kann eine Administratorbestätigung anfordern.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "The service cannot start the core because its installation path or directory permissions failed a security check. Reinstall the service to restore its files and permissions, then try again. Windows may ask for administrator approval.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "El servicio no puede iniciar el núcleo porque su ruta de instalación o los permisos del directorio no superaron una comprobación de seguridad. Reinstale el servicio para restaurar sus archivos y permisos e inténtelo de nuevo. Windows puede solicitar autorización de administrador.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "سرویس نمی‌تواند هسته را اجرا کند، زیرا مسیر نصب یا مجوزهای پوشه در بررسی امنیتی تأیید نشده‌اند. برای بازیابی فایل‌ها و مجوزها، سرویس را دوباره نصب و سپس مجدداً تلاش کنید. ممکن است ویندوز تأیید مدیر را درخواست کند.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "Layanan tidak dapat memulai inti karena jalur instalasi atau izin direktori tidak lolos pemeriksaan keamanan. Pasang ulang layanan untuk memulihkan berkas dan izinnya, lalu coba lagi. Windows mungkin meminta persetujuan administrator.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "サービスのインストール先またはフォルダーのアクセス許可がセキュリティチェックに合格しなかったため、コアを起動できません。サービスを再インストールしてファイルとアクセス許可を復元し、再試行してください。Windows が管理者の承認を求める場合があります。",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "서비스 설치 경로나 폴더 권한이 보안 검사를 통과하지 못해 코어를 시작할 수 없습니다. 서비스를 다시 설치하여 파일과 권한을 복원한 후 다시 시도하세요. Windows에서 관리자 승인을 요청할 수 있습니다.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "Служба не может запустить ядро: путь установки или права доступа к каталогу не прошли проверку безопасности. Переустановите службу, чтобы восстановить файлы и права доступа, затем повторите попытку. Windows может запросить подтверждение администратора.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "Hizmetin kurulum yolu veya dizin izinleri güvenlik denetiminden geçemediği için çekirdek başlatılamıyor. Dosyaları ve izinleri geri yüklemek için hizmeti yeniden kurup tekrar deneyin. Windows yönetici onayı isteyebilir.",
"title": "Hizmet güncellemesi gerekli",
"message": "Clash Verge hizmet sürümü, bu uygulamanın gerektirdiği sürümle eşleşmiyor. Mevcut hizmeti kaldırıp gerekli sürümü yüklemek için hizmeti yeniden kurun ya da bu oturumda Sidecar ile devam edin.",
"checkingMessage": "Sistem hizmetinin durumu ve geçerli çalışma modu denetleniyor. Lütfen bekleyin…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "Хезмәтнең урнаштыру юлы яки каталог рөхсәтләре иминлек тикшерүен узмаганга, төшне эшләтеп булмый. Файлларны һәм рөхсәтләрне торгызу өчен хезмәтне яңадан урнаштырыгыз һәм кабатлап карагыз. Windows администратор раславын сорарга мөмкин.",
"title": "Service update required",
"message": "The Clash Verge service version does not match the version required by this app. Choose Reinstall Service to uninstall the existing service and install the required version, or continue with Sidecar for this session.",
"checkingMessage": "Checking the system service status and current running mode. Please wait…",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "系统服务的安装路径或目录权限未通过安全检查,无法启动内核。请重新安装服务以恢复服务文件和权限,随后重试。Windows 可能会请求管理员授权。",
"title": "需要更新系统服务",
"message": "Clash Verge 系统服务版本与当前应用要求不符。请选择「重新安装服务」卸载现有服务并安装所需版本,也可以仅在本次会话继续使用 Sidecar。",
"checkingMessage": "正在确认系统服务状态及当前运行模式,请稍候……",
+1
View File
@@ -21,6 +21,7 @@
}
},
"serviceMigration": {
"locationRefusedMessage": "系統服務的安裝路徑或目錄權限未通過安全檢查,無法啟動核心。請重新安裝服務以還原服務檔案和權限,然後重試。Windows 可能會要求管理員授權。",
"title": "需要更新系統服務",
"message": "Clash Verge 系統服務版本與目前應用程式要求不符。請選擇「重新安裝服務」移除現有服務並安裝所需版本,也可以僅在本次工作階段繼續使用 Sidecar。",
"checkingMessage": "正在確認系統服務狀態及目前執行模式,請稍候……",
@@ -1,9 +1,10 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { takeDnsOverrideNotice } from '@/services/cmds'
import { takeDnsOverrideNotice, takeServiceRepairNotice } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { showNotice } from '@/services/notice-service'
import { revalidateQueries } from '@/services/query-client'
import { requestService } from '@/services/service-request'
import { handleNoticeMessage } from '../utils/notification-handlers'
@@ -17,7 +18,9 @@ vi.mock('@/services/cmds', () => ({
takeDiscardedKeysNotice: vi.fn().mockResolvedValue(null),
takeDnsOverrideNotice: vi.fn(),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
takeServiceRepairNotice: vi.fn().mockResolvedValue(false),
}))
vi.mock('@/services/service-request', () => ({ requestService: vi.fn() }))
vi.mock('@/services/notice-service', () => ({
showNotice: { info: vi.fn() },
}))
@@ -54,3 +57,22 @@ it('drains a DNS notice after listeners mount without duplicating its live event
['getVergeConfig'],
])
})
it('offers service reinstallation for a startup path refusal even before listeners mount', async () => {
vi.mocked(takeServiceRepairNotice)
.mockResolvedValueOnce(true)
.mockResolvedValue(false)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['service_core::repair_required', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
expect(requestService).toHaveBeenCalledExactlyOnceWith({
reason: 'serviceLocationRefused',
})
})
@@ -66,6 +66,7 @@ export const useLayoutEvents = (
handleNotice(['dns_override::auto_disabled', ''])
handleNotice(['enhance::discarded_keys', ''])
handleNotice(['service_core::sidecar_fallback', ''])
handleNotice(['service_core::repair_required', ''])
},
)
}, [handleNotice])
@@ -2,8 +2,10 @@ import {
takeDiscardedKeysNotice,
takeDnsOverrideNotice,
takeServiceFallbackNotice,
takeServiceRepairNotice,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { requestService } from '@/services/service-request'
type NavigateFunction = (path: string, options?: any) => void
type TranslateFunction = (key: string) => string
@@ -29,6 +31,20 @@ export const handleNoticeMessage = (
showNotice.error(msg)
},
'set_config::error': () => showNotice.error(msg),
'service_core::repair_required': () => {
void takeServiceRepairNotice()
.then((pending) => {
if (pending) {
requestService({ reason: 'serviceLocationRefused' })
}
})
.catch((error) => {
console.error(
'Failed to read the pending service repair notice',
error,
)
})
},
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((pending) => {
+4
View File
@@ -186,6 +186,10 @@ export async function takeServiceFallbackNotice() {
return invoke<boolean>('take_service_fallback_notice')
}
export async function takeServiceRepairNotice() {
return invoke<boolean>('take_service_repair_notice')
}
export async function takeDiscardedKeysNotice() {
return invoke<string | null>('take_discarded_keys_notice')
}
+1
View File
@@ -3,6 +3,7 @@ export type ServiceRequestReason =
| 'sysproxyRefused'
| 'sysproxySidecarReady'
| 'tunNeedsService'
| 'serviceLocationRefused'
export interface ServiceRequest {
readonly reason: ServiceRequestReason
+1
View File
@@ -125,6 +125,7 @@ export const translationKeys = [
'layout.components.navigation.menu.lock',
'layout.components.navigation.menu.collapseNavBar',
'layout.components.navigation.menu.expandNavBar',
'layout.components.serviceMigration.locationRefusedMessage',
'layout.components.serviceMigration.title',
'layout.components.serviceMigration.message',
'layout.components.serviceMigration.checkingMessage',
+1
View File
@@ -224,6 +224,7 @@ export interface TranslationResources {
sidecarFailed: string
stateRefreshFailed: string
}
locationRefusedMessage: string
message: string
reinstall: string
repair: string