diff --git a/Changelog.md b/Changelog.md index 695b0d1f3..a6c185b3f 100644 --- a/Changelog.md +++ b/Changelog.md @@ -27,6 +27,7 @@ **🖥️ Windows** +- 修复 Windows 管理员运行时,服务权限校验失败后无法继续使用代理的问题 - 修复 Windows 便携版或 Scoop 安装后服务内核无法启动的问题 - 修复 Windows 系统代理设置偶发失败的问题 - 修复 Windows 关机被应用阻止的问题 diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 1d1a21074..63eaed118 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -57,6 +57,11 @@ pub async fn continue_with_sidecar() -> CmdResult { .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED")) } +#[tauri::command] +pub fn take_service_fallback_notice() -> bool { + crate::core::service::take_service_fallback_notice() +} + #[tauri::command] pub fn sync_runtime_providers() { if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) { diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 0496859d2..c19f2d85b 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -185,6 +185,42 @@ where apply_proxy().await } +#[cfg(target_os = "windows")] +async fn run_service_start_with_sidecar_fallback( + is_admin: bool, + start_service: Start, + start_sidecar: Fallback, +) -> Result<()> +where + Start: FnOnce() -> StartFuture, + StartFuture: std::future::Future>, + Fallback: FnOnce(anyhow::Error) -> FallbackFuture, + FallbackFuture: std::future::Future>, +{ + let Err(error) = start_service().await else { + return Ok(()); + }; + // A transport failure cannot establish whether the service already started the core. + let location_refused = error + .downcast_ref::() + .is_some_and(|refusal| { + refusal.code == clash_verge_service_ipc::ServiceErrorCode::InvalidInstallLocation as u16 + }); + if !is_admin || !location_refused { + return Err(error); + } + + logging!( + warn, + Type::Core, + "service refused the core location while app is elevated; falling back to sidecar: {error:#}" + ); + let reason = format!("{error:#}"); + start_sidecar(error) + .await + .map_err(|error| error.context(format!("sidecar fallback failed after service refusal: {reason}"))) +} + async fn run_sidecar_termination_transition( clear_proxy: Clear, terminate_sidecar: Terminate, @@ -550,7 +586,30 @@ impl CoreManager { } let result = match startup { - StartupDecision::Service => self.start_core_by_service().await, + StartupDecision::Service => { + #[cfg(target_os = "windows")] + { + run_service_start_with_sidecar_fallback( + crate::core::runstate::RUN_STATE.state().is_admin, + || self.start_core_by_service(), + |error| async move { + use crate::core::runstate::RUN_STATE; + + RUN_STATE.allow_sidecar_after_service_refusal(format!("{error:#}"))?; + let result = self.start_core_by_sidecar().await; + if result.is_err() { + SERVICE_MANAGER.withdraw_sidecar_allowance(); + } else { + crate::core::service::notify_service_fallback(); + } + result + }, + ) + .await + } + #[cfg(not(target_os = "windows"))] + self.start_core_by_service().await + } StartupDecision::Sidecar => self.start_core_by_sidecar().await, StartupDecision::Wait => Ok(()), }; diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index d7486651f..996e062ef 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -18,6 +18,44 @@ use tauri_plugin_shell::ShellExt as _; const SIDECAR_READINESS_ATTEMPTS: usize = 30; +#[cfg(target_os = "windows")] +async fn retry_service_start( + attempts: usize, + retry_delay: std::time::Duration, + mut start: Start, +) -> Result<()> +where + Start: FnMut() -> StartFuture, + StartFuture: std::future::Future>, +{ + let mut last_error = None; + for attempt in 0..attempts { + match start().await { + Ok(()) => return Ok(()), + Err(error) => { + logging!( + warn, + Type::Core, + "service start attempt {}/{} failed: {error:#}", + attempt + 1, + attempts + ); + if error + .downcast_ref::() + .is_some_and(|refusal| service::StageRequest::is_about_the_bundle(refusal.code)) + { + return Err(error); + } + last_error = Some(error); + if attempt + 1 < attempts { + tokio::time::sleep(retry_delay).await; + } + } + } + } + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service start failed"))) +} + impl CoreManager { /// Restores profile selections before callers enable the system proxy. /// The bounded first pass continues in the background, and later calls supersede earlier ones. @@ -269,30 +307,15 @@ impl CoreManager { #[cfg(target_os = "windows")] { use crate::constants::timing; - let mut last_err = None; - for attempt in 0..timing::SERVICE_START_RETRIES { - match service::run_core_by_service(config_file).await { - Ok(()) => { - self.mark_core_ready(); - self.core_started(RunningMode::Service); - self.restore_selected_nodes().await; - service::request_runtime_provider_sync(crate::constants::timing::RUNTIME_PROVIDER_SYNC_DELAY); - return Ok(()); - } - Err(e) => { - logging!( - warn, - Type::Core, - "service start attempt {}/{} failed: {e:#}", - attempt + 1, - timing::SERVICE_START_RETRIES - ); - last_err = Some(e); - tokio::time::sleep(timing::SERVICE_START_RETRY_DELAY).await; - } - } - } - Err(last_err.unwrap_or_else(|| anyhow::anyhow!("service start failed"))) + retry_service_start(timing::SERVICE_START_RETRIES, timing::SERVICE_START_RETRY_DELAY, || { + service::run_core_by_service(config_file) + }) + .await?; + self.mark_core_ready(); + self.core_started(RunningMode::Service); + self.restore_selected_nodes().await; + service::request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SYNC_DELAY); + Ok(()) } #[cfg(not(target_os = "windows"))] diff --git a/src-tauri/src/core/runstate/mod.rs b/src-tauri/src/core/runstate/mod.rs index 69959873a..3153b4666 100644 --- a/src-tauri/src/core/runstate/mod.rs +++ b/src-tauri/src/core/runstate/mod.rs @@ -323,6 +323,21 @@ impl RunStateStore { Ok(()) } + #[cfg(target_os = "windows")] + pub fn allow_sidecar_after_service_refusal(&self, reason: String) -> Result<()> { + let mut state = self.service.lock(); + if self.operation_running.load(Ordering::Acquire) || state.service.pending.is_some() { + bail!("cannot fall back while a service operation is pending"); + } + // Publish the refusal and allowance together so it never asks the UI for a repair. + state.service.health = ServiceHealth::Unavailable(reason); + state.service.allow_sidecar(); + state.bump(); + drop(state); + self.announce(); + Ok(()) + } + /// Requests installation only for a confirmed absent Service. pub fn require_install_for_session(&self) -> Result<()> { let mut state = self.service.lock(); diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 7529cc6e2..6c9559ec2 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -39,6 +39,17 @@ use std::{ 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); + +#[cfg(target_os = "windows")] +pub(crate) fn notify_service_fallback() { + PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed); + Handle::notice_message("service_core::sidecar_fallback", ""); +} + +pub(crate) fn take_service_fallback_notice() -> bool { + PENDING_SERVICE_FALLBACK_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. @@ -1076,6 +1087,25 @@ pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result) -> std::fmt::Result { + write!( + formatter, + "failed to start Service core at {} (code {}): {}", + self.core_path, self.code, self.message + ) + } +} + +impl std::error::Error for ServiceStartRefusal {} + /// 尝试使用服务启动core #[tracing::instrument(skip_all, level = "info", fields(generation = tracing::field::Empty, staging = tracing::field::Empty, code = tracing::field::Empty, outcome = tracing::field::Empty))] pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> { @@ -1111,10 +1141,12 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<() err_msg ); start_owner_monitor(); - bail!( - "failed to start Service core at {}: {err_msg}", - request.runtime.core_path - ); + return Err(ServiceStartRefusal { + code: response.code, + core_path: request.runtime.core_path, + message: err_msg, + } + .into()); } let result = response.data.context("Clash Verge Service 未返回会话信息")?; @@ -1133,6 +1165,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<() // PAC follows the Running Mode; the caller opens it via `core_started(Service)`. start_owner_monitor(); tracing::Span::current().record("outcome", "started"); + PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed); logging!( info, Type::Service, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 19891eba3..a1e4d1a82 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -163,6 +163,7 @@ mod app_init { cmd::apply_dns_config, cmd::set_dns_override, cmd::take_dns_override_notice, + cmd::take_service_fallback_notice, cmd::get_dns_config_content, cmd::validate_dns_config, cmd::get_clash_logs, diff --git a/src/components/layout/sysproxy-privilege-dialog.tsx b/src/components/layout/sysproxy-privilege-dialog.tsx index c193c4c59..e3f85665c 100644 --- a/src/components/layout/sysproxy-privilege-dialog.tsx +++ b/src/components/layout/sysproxy-privilege-dialog.tsx @@ -20,6 +20,7 @@ import { type ServiceRequest, type ServiceRequestReason, } from '@/services/service-request' +import getSystem from '@/utils/get-system' type Remedy = 'installAndRestart' | 'restartOnly' @@ -98,19 +99,25 @@ export const SysproxyPrivilegeDialog = () => { setStep('restarting') await restartCore() - // Verify that restart actually moved the core into the service. const runState = await getRuntimeState() - if (runState.mode === 'Service') { - // Retry the original request now that the service can apply it. + const usingAdminFallback = + getSystem() === 'windows' && + runState.mode === 'Sidecar' && + runState.isAdmin && + runState.sidecarAllowed && + runState.service === 'unavailable' + if (runState.mode === 'Service' || usingAdminFallback) { if (restoring !== undefined) { setStep('applying') await patchVergeConfig(restoring) } - showNotice.success( - restoring === undefined - ? 'settings.sections.proxyControl.messages.installedCheckProxy' - : 'settings.sections.proxyControl.messages.installedProxyRestored', - ) + if (!usingAdminFallback) { + showNotice.success( + restoring === undefined + ? 'settings.sections.proxyControl.messages.installedCheckProxy' + : 'settings.sections.proxyControl.messages.installedProxyRestored', + ) + } close() } else { showNotice.error( diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 4aec89f19..4fd6792cb 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "تم تثبيت الخدمة بنجاح", + "sidecarFallback": "تعذّر على الخدمة تشغيل النواة بسبب أذونات المسار. تم التبديل إلى وضع المسؤول لمواصلة التشغيل.", "uninstallSuccess": "تم إلغاء تثبيت الخدمة بنجاح" }, "updater": { diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 109f32725..e6d16ec5b 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "Service erfolgreich installiert", + "sidecarFallback": "Der Dienst konnte den Kern aufgrund der Pfadberechtigungen nicht starten. Die Anwendung läuft im Administratormodus weiter.", "uninstallSuccess": "Service erfolgreich deinstalliert" }, "updater": { diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index fe0f9c94a..24450ae3f 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "Service Installed Successfully", + "sidecarFallback": "The service could not start the core due to path permissions. Switched to administrator mode to continue running.", "uninstallSuccess": "Service Uninstalled Successfully" }, "updater": { diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index b8492bb2c..c686037e3 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "Servicio instalado con éxito", + "sidecarFallback": "El servicio no pudo iniciar el núcleo debido a los permisos de la ruta. Se ha cambiado al modo administrador para continuar.", "uninstallSuccess": "Servicio desinstalado con éxito" }, "updater": { diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index f2b6fc10c..32cf0e447 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "سرویس با موفقیت نصب شد", + "sidecarFallback": "سرویس به‌دلیل مجوزهای مسیر نتوانست هسته را اجرا کند. برای ادامهٔ اجرا به حالت مدیر تغییر یافت.", "uninstallSuccess": "سرویس با موفقیت حذف نصب شد" }, "updater": { diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index cf7245cb7..aefc26171 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "Layanan Berhasil Diinstal", + "sidecarFallback": "Layanan tidak dapat memulai inti karena izin jalur. Beralih ke mode administrator untuk melanjutkan.", "uninstallSuccess": "Layanan Berhasil Dicopot" }, "updater": { diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index a7ec639f7..b39537ecf 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "サービスのインストールに成功しました。", + "sidecarFallback": "パスの権限によりサービスがコアを起動できなかったため、管理者モードに切り替えて動作を継続しています。", "uninstallSuccess": "サービスのアンインストールに成功しました。" }, "updater": { diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 725607d0d..6aadcdc98 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "서비스가 설치되었습니다", + "sidecarFallback": "경로 권한 문제로 서비스가 코어를 시작하지 못해 관리자 모드로 전환하여 계속 실행합니다.", "uninstallSuccess": "서비스가 제거되었습니다" }, "updater": { diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index f96d6f6a5..cf5be05dd 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "Служба успешно установлена", + "sidecarFallback": "Служба не смогла запустить ядро из-за прав доступа к пути. Работа продолжена в режиме администратора.", "uninstallSuccess": "Служба успешно удалена" }, "updater": { diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 619b201ca..45dc3e4fc 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "Hizmet Başarıyla Kuruldu", + "sidecarFallback": "Hizmet, yol izinleri nedeniyle çekirdeği başlatamadı. Çalışmaya devam etmek için yönetici moduna geçildi.", "uninstallSuccess": "Hizmet Başarıyla Kaldırıldı" }, "updater": { diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 2aa3fb7ca..97c5e3abe 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "Сервис уңышлы урнаштырылды", + "sidecarFallback": "Хезмәт юлга керү хокуклары аркасында төшне эшләтеп җибәрә алмады. Эшне дәвам итү өчен администратор режимына күчелде.", "uninstallSuccess": "Сервис уңышлы салдырылды" }, "updater": { diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 42ac1101f..090e6fff0 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "已成功安装服务", + "sidecarFallback": "服务因路径权限问题无法启动内核,已切回管理员模式继续运行。", "uninstallSuccess": "已成功卸载服务" }, "updater": { diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index 6997082ac..89eb9689e 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -683,6 +683,7 @@ }, "clashService": { "installSuccess": "已成功安裝服務", + "sidecarFallback": "服務因路徑權限問題無法啟動核心,已切回管理員模式繼續執行。", "uninstallSuccess": "已成功解除安裝服務" }, "updater": { diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/pages/_layout/hooks/use-layout-events.test.ts index 59edc8b34..0a3f50f4d 100644 --- a/src/pages/_layout/hooks/use-layout-events.test.ts +++ b/src/pages/_layout/hooks/use-layout-events.test.ts @@ -13,7 +13,10 @@ vi.mock('react', () => ({ useEffect: (effect: () => void) => effect() })) vi.mock('@/hooks/use-profiles', () => ({ revalidateProfiles: vi.fn() })) vi.mock('@/hooks/use-system-state', () => ({ runStateQueryKey: ['state'] })) vi.mock('@/services/events', () => ({ subscribeVergeEvents: vi.fn() })) -vi.mock('@/services/cmds', () => ({ takeDnsOverrideNotice: vi.fn() })) +vi.mock('@/services/cmds', () => ({ + takeDnsOverrideNotice: vi.fn(), + takeServiceFallbackNotice: vi.fn().mockResolvedValue(false), +})) vi.mock('@/services/notice-service', () => ({ showNotice: { info: vi.fn() }, })) diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index b1094cf0c..eddf2318f 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -64,6 +64,7 @@ export const useLayoutEvents = ( () => { revalidateKeys(['getRuntimeState', 'getVergeConfig']) handleNotice(['dns_override::auto_disabled', '']) + handleNotice(['service_core::sidecar_fallback', '']) }, ) }, [handleNotice]) diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index d866cd762..cff425e8e 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -1,4 +1,7 @@ -import { takeDnsOverrideNotice } from '@/services/cmds' +import { + takeDnsOverrideNotice, + takeServiceFallbackNotice, +} from '@/services/cmds' import { showNotice } from '@/services/notice-service' type NavigateFunction = (path: string, options?: any) => void @@ -25,6 +28,22 @@ export const handleNoticeMessage = ( showNotice.error(msg) }, 'set_config::error': () => showNotice.error(msg), + 'service_core::sidecar_fallback': () => { + void takeServiceFallbackNotice() + .then((pending) => { + if (pending) { + showNotice.warning( + 'settings.feedback.notifications.clashService.sidecarFallback', + ) + } + }) + .catch((error) => { + console.error( + 'Failed to read the pending service fallback notice', + error, + ) + }) + }, 'dns_override::auto_disabled': () => { void takeDnsOverrideNotice() .then((pending) => { diff --git a/src/services/cmds.ts b/src/services/cmds.ts index efea521b4..034d77efa 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -182,6 +182,10 @@ export async function takeDnsOverrideNotice() { return invoke('take_dns_override_notice') } +export async function takeServiceFallbackNotice() { + return invoke('take_service_fallback_notice') +} + export async function getSystemProxy() { return invoke<{ enable: boolean diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 928bdcd67..d2535331c 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -793,6 +793,7 @@ export const translationKeys = [ 'settings.feedback.notifications.clash.changeFailed', 'settings.feedback.notifications.clash.geoDataUpdated', 'settings.feedback.notifications.clashService.installSuccess', + 'settings.feedback.notifications.clashService.sidecarFallback', 'settings.feedback.notifications.clashService.uninstallSuccess', 'settings.feedback.notifications.updater.withClashProxySuccess', 'settings.feedback.notifications.updater.withClashProxyFailed', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index f92a33cd1..474314e5b 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -831,6 +831,7 @@ export interface TranslationResources { } clashService: { installSuccess: string + sidecarFallback: string uninstallSuccess: string } updater: {