diff --git a/Cargo.lock b/Cargo.lock index 3ae92202f..ba967e272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1224,7 +1224,6 @@ dependencies = [ [[package]] name = "clash_verge_service_ipc" version = "2.7.3" -source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.7.3#29251d968e1a38fac1de0ebe9d6d69402bea83b4" dependencies = [ "anyhow", "kode-bridge", diff --git a/Changelog.md b/Changelog.md index f088e7508..10d89d82d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -15,6 +15,8 @@ **🖥️ Windows** +- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装 +- 修复 Windows 服务安装后权限检查不通过时,未说明原因并自动切换为普通模式的问题 - 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示 - 修复 Windows 服务回退检查失败后状态未更新、无法重新尝试 Sidecar 的问题 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 833584682..f3071ab25 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -99,7 +99,7 @@ tracing = { workspace = true } tauri-plugin-devtools = { version = "2.2.1", optional = true } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" } async-trait = "0.1.92" -clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.3", version = "2.7.3", features = [ +clash_verge_service_ipc = { path = "../../clash-verge-service-ipc", version = "2.7.3", features = [ "client", ] } arc-swap = "1.9.2" diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 1a11351cd..073bfbf19 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -8,26 +8,78 @@ use crate::{ }, }; -async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult { +#[derive(Debug, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum ServiceInstallOutcome { + Installed, + Sidecar { reason: String }, +} + +async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult { let manager = CoreManager::global(); - let _lifecycle = manager.lifecycle_lock.lock().await; - if matches!( - &status, - ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired - ) { - manager - .controlled_stop_core_inner() - .await - .map_err(|error| proxy_aware_coded_error(&error, error_code))?; - } - SERVICE_MANAGER - .handle_service_status(status) - .await + let result = { + let _lifecycle = manager.lifecycle_lock.lock().await; + if matches!( + &status, + ServiceStatus::ReinstallRequired | ServiceStatus::ForceReinstallRequired + ) { + manager + .controlled_stop_core_inner() + .await + .map_err(|error| proxy_aware_coded_error(&error, error_code))?; + } + SERVICE_MANAGER.handle_service_status(status).await + }; + #[cfg(windows)] + return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await; + + #[cfg(not(windows))] + result + .map(|()| ServiceInstallOutcome::Installed) .map_err(|error| proxy_aware_coded_error(&error, error_code)) } +#[cfg(any(windows, test))] +async fn finish_service_installation( + installation: anyhow::Result<()>, + error_code: &str, + fallback: Fallback, +) -> CmdResult +where + Fallback: FnOnce() -> FallbackFuture, + FallbackFuture: std::future::Future>, +{ + let Err(error) = installation else { + return Ok(ServiceInstallOutcome::Installed); + }; + let Some(failure) = error.downcast_ref::() + else { + return Err(proxy_aware_coded_error(&error, error_code)); + }; + let reasons = failure + .status + .cores + .iter() + .filter_map(|core| match &core.availability { + clash_verge_service_ipc::CoreAvailability::Rejected { reason } => Some(format!("{}: {reason}", core.name)), + _ => None, + }) + .collect::>(); + if reasons.is_empty() { + return Err(proxy_aware_coded_error(&error, error_code)); + } + let reason = reasons.join("\n"); + fallback().await.map_err(|error| { + proxy_aware_coded_error( + &error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")), + "SERVICE_SIDECAR_FAILED", + ) + })?; + Ok(ServiceInstallOutcome::Sidecar { reason }) +} + #[tauri::command] -pub async fn install_service() -> CmdResult { +pub async fn install_service() -> CmdResult { execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await } @@ -40,12 +92,12 @@ pub async fn uninstall_service() -> CmdResult { } #[tauri::command] -pub async fn reinstall_service() -> CmdResult { +pub async fn reinstall_service() -> CmdResult { execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await } #[tauri::command] -pub async fn repair_service() -> CmdResult { +pub async fn repair_service() -> CmdResult { execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await } @@ -73,3 +125,73 @@ pub fn sync_runtime_providers() { request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE); } } + +#[cfg(test)] +#[allow(clippy::expect_used, reason = "tests assert by panicking")] +mod tests { + use super::{ServiceInstallOutcome, finish_service_installation}; + use clash_verge_service_ipc::{ + CoreAvailability, CoreInspection, InstallationStatus, ProtocolInfo, management::InstallationVerificationError, + }; + use std::cell::Cell; + + #[tokio::test] + async fn rejected_installation_reports_sidecar_only_after_successful_continuation() { + let reason = "core path C:\\ is writable by Everyone"; + for (availability, fallback_ready) in [ + (None, false), + (Some(CoreAvailability::Missing), false), + (Some(CoreAvailability::DigestMismatch), false), + (Some(CoreAvailability::Rejected { reason: reason.into() }), false), + (Some(CoreAvailability::Rejected { reason: reason.into() }), true), + ] { + let fallback_called = Cell::new(false); + let rejected = matches!(&availability, Some(CoreAvailability::Rejected { .. })); + let error = match availability { + Some(availability) => anyhow::Error::new(InstallationVerificationError { + status: InstallationStatus { + service_sha256: String::new(), + protocol: ProtocolInfo::current(), + cores: vec![CoreInspection { + name: "verge-mihomo.exe".into(), + availability, + }], + core_busy: false, + }, + }), + None => anyhow::anyhow!("{reason}"), + } + .context("install service failed"); + let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async { + fallback_called.set(true); + anyhow::ensure!(fallback_ready, "another core is running"); + Ok(()) + }) + .await; + + assert_eq!(fallback_called.get(), rejected); + if rejected && fallback_ready { + assert_eq!( + result, + Ok(ServiceInstallOutcome::Sidecar { + reason: format!("verge-mihomo.exe: {reason}") + }) + ); + } else { + let error = result.expect_err("an unavailable fallback must remain an error"); + assert_eq!( + error.code.as_deref(), + Some(if rejected { + "SERVICE_SIDECAR_FAILED" + } else { + "SERVICE_INSTALL_FAILED" + }) + ); + if rejected { + assert!(error.detail.contains(reason)); + assert!(error.detail.contains("another core is running")); + } + } + } + } +} diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 554f1be0b..d8c5784f2 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -62,7 +62,11 @@ const fn can_allow_sidecar_for_session(running_mode: &RunningMode, service_statu | ServiceStatus::NeedsReinstall | ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) - ) | (RunningMode::Sidecar, ServiceStatus::InstallRequired) + | ServiceStatus::SidecarAllowed + ) | ( + RunningMode::Sidecar, + ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed + ) ) } @@ -381,7 +385,9 @@ impl CoreManager { if matches!(*mode, RunningMode::NotRunning) { clash_verge_service_ipc::execution::check_sidecar_available().await?; } - SERVICE_MANAGER.allow_sidecar_for_session()?; + if !matches!(status, ServiceStatus::SidecarAllowed) { + SERVICE_MANAGER.allow_sidecar_for_session()?; + } // Settling on Sidecar is what makes the verdict final, so ask only once it is recorded. // Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off. let prepared = async { @@ -1474,12 +1480,18 @@ mod tests { ServiceStatus::NeedsReinstall, ServiceStatus::InstallRequired, ServiceStatus::Unavailable("offline".into()), + ServiceStatus::SidecarAllowed, ]; for status in &allowed_statuses { assert!(can_allow_sidecar_for_session(&RunningMode::NotRunning, status)); assert!(!can_allow_sidecar_for_session(&RunningMode::Service, status)); - if !matches!(status, ServiceStatus::InstallRequired) { + if !matches!( + status, + ServiceStatus::InstallRequired | ServiceStatus::Unavailable(_) | ServiceStatus::SidecarAllowed + ) { assert!(!can_allow_sidecar_for_session(&RunningMode::Sidecar, status)); + } else { + assert!(can_allow_sidecar_for_session(&RunningMode::Sidecar, status)); } } @@ -1489,7 +1501,6 @@ mod tests { ServiceStatus::UninstallRequired, ServiceStatus::ReinstallRequired, ServiceStatus::ForceReinstallRequired, - ServiceStatus::SidecarAllowed, ]; for status in &rejected_statuses { assert!(!can_allow_sidecar_for_session(&RunningMode::NotRunning, status)); diff --git a/src-tauri/src/core/runstate/probe.rs b/src-tauri/src/core/runstate/probe.rs index f5857e027..3fd6d95e7 100644 --- a/src-tauri/src/core/runstate/probe.rs +++ b/src-tauri/src/core/runstate/probe.rs @@ -47,9 +47,7 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer return match &reply.core { Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready, Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => { - ServiceVersionCheck::CoreUnavailable(format!( - "approved core was rejected: {reason}; reinstall the service to repair it" - )) + ServiceVersionCheck::CoreUnavailable(format!("approved core was rejected: {reason}")) } _ => ServiceVersionCheck::CoreUnavailable( "the selected approved core is missing or does not match; reinstall the service to repair it".into(), diff --git a/src/components/layout/service-migration-dialog.test.ts b/src/components/layout/service-migration-dialog.test.ts index 592c5e0ec..d4531b49e 100644 --- a/src/components/layout/service-migration-dialog.test.ts +++ b/src/components/layout/service-migration-dialog.test.ts @@ -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) + 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' }, diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index 4e0963c2b..10826fd3a 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -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 { diff --git a/src/components/layout/sysproxy-privilege-dialog.tsx b/src/components/layout/sysproxy-privilege-dialog.tsx index 471560e74..0bec10057 100644 --- a/src/components/layout/sysproxy-privilege-dialog.tsx +++ b/src/components/layout/sysproxy-privilege-dialog.tsx @@ -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() diff --git a/src/hooks/use-service-installer.ts b/src/hooks/use-service-installer.ts index b24ccc255..f2381d269 100644 --- a/src/hooks/use-service-installer.ts +++ b/src/hooks/use-service-installer.ts @@ -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, +const executeWithErrorHandling = async ( + operation: () => Promise, 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', ) diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 631d1527f..86cad491d 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -684,6 +684,7 @@ }, "clashService": { "installSuccess": "تم تثبيت الخدمة بنجاح", + "permissionFallback": "لم تجتز ملفات نواة الخدمة أو مجلداتها فحص الأمان. تم التبديل إلى وضع Sidecar؛ ويتطلب TUN صلاحيات المسؤول. السبب: {{reason}}", "sidecarFallback": "تعذّر على الخدمة تشغيل النواة. تم التبديل إلى وضع Sidecar؛ ولا تزال الخدمة غير متاحة.", "uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح" }, diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index f783e0751..7665c357a 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -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" }, diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 5d79df27c..dba64041b 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -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" }, diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 9cc6be44a..ac42444d6 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -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" }, diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 8033ef072..bed342ecf 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -684,6 +684,7 @@ }, "clashService": { "installSuccess": "سرویس با موفقیت نصب شد", + "permissionFallback": "فایل‌ها یا پوشه‌های هستهٔ سرویس از بررسی امنیتی عبور نکردند. به حالت Sidecar تغییر یافت؛ TUN به دسترسی مدیر نیاز دارد. دلیل: {{reason}}", "sidecarFallback": "سرویس نتوانست هسته را اجرا کند. اجرا در حالت Sidecar ادامه می‌یابد؛ سرویس همچنان در دسترس نیست.", "uninstallSuccess": "سرویس با موفقیت حذف نصب شد" }, diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index e544fb63a..05c8d8879 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -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" }, diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index eb5ecee34..53f885d56 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -684,6 +684,7 @@ }, "clashService": { "installSuccess": "サービスのインストールに成功しました。", + "permissionFallback": "サービスのコアファイルまたはディレクトリがセキュリティチェックに失敗したため、Sidecar モードに切り替えました。TUN には管理者権限が必要です。理由:{{reason}}", "sidecarFallback": "サービスでコアを起動できなかったため、Sidecar モードで動作を継続しています。サービスは引き続き利用できません。", "uninstallSuccess": "サービスのアンインストールに成功しました。" }, diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 35055eb98..a1627aa89 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -684,6 +684,7 @@ }, "clashService": { "installSuccess": "서비스가 설치되었습니다", + "permissionFallback": "서비스의 코어 파일 또는 디렉터리가 보안 검사를 통과하지 못해 Sidecar 모드로 전환했습니다. TUN에는 관리자 권한이 필요합니다. 사유: {{reason}}", "sidecarFallback": "서비스가 코어를 시작하지 못해 Sidecar 모드로 계속 실행합니다. 서비스는 여전히 사용할 수 없습니다.", "uninstallSuccess": "서비스가 제거되었습니다" }, diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 9c80fb9f7..c5c8d622d 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -684,6 +684,7 @@ }, "clashService": { "installSuccess": "Служба успешно установлена", + "permissionFallback": "Файлы или каталоги ядра службы не прошли проверку безопасности. Работа продолжена в режиме Sidecar; для TUN нужны права администратора. Причина: {{reason}}", "sidecarFallback": "Служба не смогла запустить ядро. Работа продолжена в режиме Sidecar; служба по-прежнему недоступна.", "uninstallSuccess": "Служба успешно удалена" }, diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 5c6d5078c..6682cf381 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -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ı" }, diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 6181ae668..91c34e255 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -684,6 +684,7 @@ }, "clashService": { "installSuccess": "Сервис уңышлы урнаштырылды", + "permissionFallback": "Хезмәт төшенең файллары яки каталоглары иминлек тикшерүен үтмәде. Sidecar режимына күчелде; TUN өчен администратор хокуклары кирәк. Сәбәп: {{reason}}", "sidecarFallback": "Хезмәт төшне эшләтеп җибәрә алмады. Эш Sidecar режимында дәвам итә; хезмәт һаман да эшләми.", "uninstallSuccess": "Сервис уңышлы салдырылды" }, diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 909cb91a3..e99fc8f8d 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -684,6 +684,7 @@ }, "clashService": { "installSuccess": "已成功安装服务", + "permissionFallback": "服务内核的文件或目录未通过安全检查,已切换到普通模式(Sidecar);普通权限下 TUN 模式不可用。原因:{{reason}}", "sidecarFallback": "服务无法启动内核,已降级为 Sidecar 模式运行,服务仍不可用。", "uninstallSuccess": "已成功卸载服务" }, diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index 58baf8a58..6f190a12a 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -684,6 +684,7 @@ }, "clashService": { "installSuccess": "已成功安裝服務", + "permissionFallback": "服務核心的檔案或目錄未通過安全檢查,已切換至一般模式(Sidecar);一般權限下無法使用 TUN 模式。原因:{{reason}}", "sidecarFallback": "服務無法啟動核心,已降級為 Sidecar 模式執行,服務仍無法使用。", "uninstallSuccess": "已成功解除安裝服務" }, diff --git a/src/services/cmds.ts b/src/services/cmds.ts index a40f98650..0d82f19ba 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -454,8 +454,12 @@ export const getAppUptime = async () => { return invoke('get_app_uptime') } +export type ServiceInstallOutcome = + | { status: 'installed' } + | { status: 'sidecar'; reason: string } + export const installService = async () => { - return invoke('install_service') + return invoke('install_service') } export const uninstallService = async () => { @@ -463,11 +467,11 @@ export const uninstallService = async () => { } export const reinstallService = async () => { - return invoke('reinstall_service') + return invoke('reinstall_service') } export const repairService = async () => { - return invoke('repair_service') + return invoke('repair_service') } export const continueWithSidecar = async () => { diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 2d6fbcfa8..41ae06f84 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -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', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 93fb9d8b4..179d29ed8 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -845,6 +845,7 @@ export interface TranslationResources { } clashService: { installSuccess: string + permissionFallback: string sidecarFallback: string uninstallSuccess: string }