diff --git a/Changelog.md b/Changelog.md index 215dbc5f1..b121ef609 100644 --- a/Changelog.md +++ b/Changelog.md @@ -11,6 +11,9 @@ **🐧 Linux** - Linux 修复安装或修复服务后仍无法启动内核、开启 TUN 模式的问题 +**🖥️ Windows** + +- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示 diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 63eaed118..1a11351cd 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -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) { diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 19e8f9cfc..1ef636089 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -43,6 +43,7 @@ pub(crate) mod windows_fallback; static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0); static ACTIVE_SERVICE_SESSION: Lazy>> = 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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04fcaf303..55886fe1f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/layout/sysproxy-privilege-dialog.tsx b/src/components/layout/sysproxy-privilege-dialog.tsx index e3f85665c..471560e74 100644 --- a/src/components/layout/sysproxy-privilege-dialog.tsx +++ b/src/components/layout/sysproxy-privilege-dialog.tsx @@ -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. diff --git a/src/locales/ar/layout.json b/src/locales/ar/layout.json index 25395b545..2dbf5b4a9 100644 --- a/src/locales/ar/layout.json +++ b/src/locales/ar/layout.json @@ -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…", diff --git a/src/locales/de/layout.json b/src/locales/de/layout.json index c9bb6855e..0ac834cba 100644 --- a/src/locales/de/layout.json +++ b/src/locales/de/layout.json @@ -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…", diff --git a/src/locales/en/layout.json b/src/locales/en/layout.json index 827f127f9..99e83e868 100644 --- a/src/locales/en/layout.json +++ b/src/locales/en/layout.json @@ -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…", diff --git a/src/locales/es/layout.json b/src/locales/es/layout.json index 8103a5737..25b3e70ee 100644 --- a/src/locales/es/layout.json +++ b/src/locales/es/layout.json @@ -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…", diff --git a/src/locales/fa/layout.json b/src/locales/fa/layout.json index 03c9a95fe..26c310138 100644 --- a/src/locales/fa/layout.json +++ b/src/locales/fa/layout.json @@ -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…", diff --git a/src/locales/id/layout.json b/src/locales/id/layout.json index b6c12ae01..5369c13f3 100644 --- a/src/locales/id/layout.json +++ b/src/locales/id/layout.json @@ -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…", diff --git a/src/locales/jp/layout.json b/src/locales/jp/layout.json index 490a30fc0..6304ed711 100644 --- a/src/locales/jp/layout.json +++ b/src/locales/jp/layout.json @@ -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…", diff --git a/src/locales/ko/layout.json b/src/locales/ko/layout.json index d41b39b0e..f1f931464 100644 --- a/src/locales/ko/layout.json +++ b/src/locales/ko/layout.json @@ -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…", diff --git a/src/locales/ru/layout.json b/src/locales/ru/layout.json index 379e881d5..c6d5b8b51 100644 --- a/src/locales/ru/layout.json +++ b/src/locales/ru/layout.json @@ -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…", diff --git a/src/locales/tr/layout.json b/src/locales/tr/layout.json index 74cdfcf9f..8590d0b33 100644 --- a/src/locales/tr/layout.json +++ b/src/locales/tr/layout.json @@ -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…", diff --git a/src/locales/tt/layout.json b/src/locales/tt/layout.json index 5e8f6f320..2f7fafe05 100644 --- a/src/locales/tt/layout.json +++ b/src/locales/tt/layout.json @@ -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…", diff --git a/src/locales/zh/layout.json b/src/locales/zh/layout.json index fefad3d90..651b04017 100644 --- a/src/locales/zh/layout.json +++ b/src/locales/zh/layout.json @@ -21,6 +21,7 @@ } }, "serviceMigration": { + "locationRefusedMessage": "系统服务的安装路径或目录权限未通过安全检查,无法启动内核。请重新安装服务以恢复服务文件和权限,随后重试。Windows 可能会请求管理员授权。", "title": "需要更新系统服务", "message": "Clash Verge 系统服务版本与当前应用要求不符。请选择「重新安装服务」卸载现有服务并安装所需版本,也可以仅在本次会话继续使用 Sidecar。", "checkingMessage": "正在确认系统服务状态及当前运行模式,请稍候……", diff --git a/src/locales/zhtw/layout.json b/src/locales/zhtw/layout.json index 9fa3cde0e..f3058cde9 100644 --- a/src/locales/zhtw/layout.json +++ b/src/locales/zhtw/layout.json @@ -21,6 +21,7 @@ } }, "serviceMigration": { + "locationRefusedMessage": "系統服務的安裝路徑或目錄權限未通過安全檢查,無法啟動核心。請重新安裝服務以還原服務檔案和權限,然後重試。Windows 可能會要求管理員授權。", "title": "需要更新系統服務", "message": "Clash Verge 系統服務版本與目前應用程式要求不符。請選擇「重新安裝服務」移除現有服務並安裝所需版本,也可以僅在本次工作階段繼續使用 Sidecar。", "checkingMessage": "正在確認系統服務狀態及目前執行模式,請稍候……", diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/pages/_layout/hooks/use-layout-events.test.ts index 8dceda771..1d048bfa7 100644 --- a/src/pages/_layout/hooks/use-layout-events.test.ts +++ b/src/pages/_layout/hooks/use-layout-events.test.ts @@ -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', + }) +}) diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index 0ec1d042e..0ac5e858d 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -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]) diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index 1671fa0b0..4baae3895 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -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) => { diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 4b4ea10d5..07968e9b4 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -186,6 +186,10 @@ export async function takeServiceFallbackNotice() { return invoke('take_service_fallback_notice') } +export async function takeServiceRepairNotice() { + return invoke('take_service_repair_notice') +} + export async function takeDiscardedKeysNotice() { return invoke('take_discarded_keys_notice') } diff --git a/src/services/service-request.ts b/src/services/service-request.ts index 46c7436a2..e4f6aaaa2 100644 --- a/src/services/service-request.ts +++ b/src/services/service-request.ts @@ -3,6 +3,7 @@ export type ServiceRequestReason = | 'sysproxyRefused' | 'sysproxySidecarReady' | 'tunNeedsService' + | 'serviceLocationRefused' export interface ServiceRequest { readonly reason: ServiceRequestReason diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 397e19cf4..44ee22b36 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -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', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 5f2739288..f90c3326d 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -224,6 +224,7 @@ export interface TranslationResources { sidecarFailed: string stateRefreshFailed: string } + locationRefusedMessage: string message: string reinstall: string repair: string