From f958dccb25530b5a4c7b13dc2c2afb79fe9fab9f Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 2 Oct 2026 19:52:47 +0800 Subject: [PATCH] fix(service): explain sidecar failures and clear recovered notices --- Changelog.md | 4 + src-tauri/src/cmd/service.rs | 26 ++-- src-tauri/src/core/manager/lifecycle.rs | 127 ++++++++++-------- src-tauri/src/core/notification.rs | 40 ++++++ src-tauri/src/lib.rs | 1 + src/components/layout/notice-manager.tsx | 60 +++++++++ src/locales/ar/settings.json | 3 +- src/locales/de/settings.json | 3 +- src/locales/en/settings.json | 3 +- src/locales/es/settings.json | 3 +- src/locales/fa/settings.json | 3 +- src/locales/id/settings.json | 3 +- src/locales/jp/settings.json | 3 +- src/locales/ko/settings.json | 3 +- src/locales/ru/settings.json | 3 +- src/locales/tr/settings.json | 3 +- src/locales/tt/settings.json | 3 +- src/locales/zh/settings.json | 3 +- src/locales/zhtw/settings.json | 3 +- .../_layout/hooks/use-pending-failures.ts | 31 ++++- src/services/cmds.ts | 9 ++ src/services/notice-service.ts | 29 +++- src/types/generated/i18n-keys.ts | 1 + src/types/generated/i18n-resources.ts | 1 + 24 files changed, 284 insertions(+), 84 deletions(-) diff --git a/Changelog.md b/Changelog.md index b23eae369..0927a0100 100644 --- a/Changelog.md +++ b/Changelog.md @@ -17,4 +17,8 @@ - 优化侧边栏流量图表的 CPU 占用 +**🖥️ Windows** + +- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法 + diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index bf5aa4d85..2f96ce98d 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -34,7 +34,10 @@ async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) SERVICE_MANAGER.handle_service_status(status).await }; #[cfg(windows)] - return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await; + return finish_service_installation(result, error_code, |reason| async move { + manager.continue_with_sidecar(Some(&reason)).await + }) + .await; #[cfg(not(windows))] result @@ -49,7 +52,7 @@ async fn finish_service_installation( fallback: Fallback, ) -> CmdResult where - Fallback: FnOnce() -> FallbackFuture, + Fallback: FnOnce(String) -> FallbackFuture, FallbackFuture: std::future::Future>, { let Err(error) = installation else { @@ -72,12 +75,9 @@ where 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", - ) - })?; + fallback(reason.clone()) + .await + .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?; Ok(ServiceInstallOutcome::Sidecar { reason }) } @@ -107,11 +107,16 @@ pub async fn repair_service() -> CmdResult { #[tauri::command] pub async fn continue_with_sidecar() -> CmdResult { crate::core::CoreManager::global() - .continue_with_sidecar() + .continue_with_sidecar(None) .await .map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED")) } +#[tauri::command] +pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot { + crate::core::notification::sidecar_failure_snapshot() +} + #[tauri::command] pub fn take_service_fallback_notice() -> Option { crate::core::service::take_service_fallback_notice() @@ -175,7 +180,7 @@ mod tests { None => anyhow::anyhow!("{reason}"), } .context("install service failed"); - let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async { + let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async { fallback_called.set(true); anyhow::ensure!(fallback_ready, "another core is running"); Ok(()) @@ -201,7 +206,6 @@ mod tests { }) ); 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 1e2b43d82..d4aa20d0e 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -334,6 +334,18 @@ enum HandoffOutcome { Failed, } +fn report_sidecar_failure(error: anyhow::Error, service_rejection: Option<&str>) -> anyhow::Error { + let error = match service_rejection { + Some(reason) => error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")), + None => error, + }; + // Proxy failures have their own pending state and recovery dialog. + if SysproxyFailure::from_chain(&error).is_none() { + crate::core::notification::record_sidecar_failure(format!("{error:#}").into()); + } + error +} + impl CoreManager { async fn rollback_failed_start(&self) { proxy_control::stop_guard().await; @@ -364,9 +376,12 @@ impl CoreManager { } #[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))] - pub async fn continue_with_sidecar(&self) -> Result<()> { + pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> { if !self.try_start_config_update() { - anyhow::bail!("configuration update is already running"); + return Err(report_sidecar_failure( + anyhow::anyhow!("configuration update is already running"), + service_rejection, + )); } defer! { self.finish_config_update(); @@ -375,61 +390,66 @@ impl CoreManager { // draft without claiming it, so without this it can commit another transaction's staged patch. let config_write = Config::lock_config_write().await; let _life = self.lifecycle_lock.lock().await; - let status = SERVICE_MANAGER.current().await; - tracing::Span::current().record("status", tracing::field::debug(&status)); - let mode = self.get_running_mode(); - if !can_allow_sidecar_for_session(&mode, &status) { - anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}"); - } - #[cfg(target_os = "windows")] - if matches!(*mode, RunningMode::NotRunning) { - clash_verge_service_ipc::execution::check_sidecar_available().await?; - } - 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 { - let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); - if crate::core::runstate::RUN_STATE - .state() - .tun_should_be_disabled(tun_enabled) - { - tracing::Span::current().record("tun_disabled", true); - Config::disable_tun_and_persist().await?; - } - Config::generate().await - } - .await; - drop(config_write); - if let Err(error) = prepared { - SERVICE_MANAGER.withdraw_sidecar_allowance(); - return Err(error); - } - // Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded. - proxy_control::stop_guard().await; + // Record failures before releasing the lifecycle lock, including preparation and rollback errors. let result = async { - self.start_core_inner().await?; - if !matches!(*self.get_running_mode(), RunningMode::Sidecar) - || self.current_core_readiness_generation().is_none() - { - anyhow::bail!("Sidecar did not become ready"); + let status = SERVICE_MANAGER.current().await; + tracing::Span::current().record("status", tracing::field::debug(&status)); + let mode = self.get_running_mode(); + if !can_allow_sidecar_for_session(&mode, &status) { + anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}"); } - tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation()); - self.apply_proxy_after_start().await + #[cfg(target_os = "windows")] + if matches!(*mode, RunningMode::NotRunning) { + clash_verge_service_ipc::execution::check_sidecar_available().await?; + } + 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 { + let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); + if crate::core::runstate::RUN_STATE + .state() + .tun_should_be_disabled(tun_enabled) + { + tracing::Span::current().record("tun_disabled", true); + Config::disable_tun_and_persist().await?; + } + Config::generate().await + } + .await; + drop(config_write); + if let Err(error) = prepared { + SERVICE_MANAGER.withdraw_sidecar_allowance(); + return Err(error); + } + // Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded. + proxy_control::stop_guard().await; + let result = async { + self.start_core_inner().await?; + if !matches!(*self.get_running_mode(), RunningMode::Sidecar) + || self.current_core_readiness_generation().is_none() + { + anyhow::bail!("Sidecar did not become ready"); + } + tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation()); + self.apply_proxy_after_start().await + } + .await; + if let Err(error) = result { + // Revoke only the failed Sidecar allowance; its startup says nothing about Service health. + SERVICE_MANAGER.withdraw_sidecar_allowance(); + if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await { + return Err(proxy_control::rollback_failure(error, cleanup_error) + .context("failed to clear the proxy before rolling back")); + } + return Err(error); + } + Ok(()) } .await; - if let Err(error) = result { - // Revoke only the failed Sidecar allowance; its startup says nothing about Service health. - SERVICE_MANAGER.withdraw_sidecar_allowance(); - if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await { - return Err(proxy_control::rollback_failure(error, cleanup_error) - .context("failed to clear the proxy before rolling back")); - } - return Err(error); - } - Ok(()) + result.map_err(|error| report_sidecar_failure(error, service_rejection)) } #[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))] @@ -572,6 +592,7 @@ impl CoreManager { } // tell the window when a background apply lands Handle::refresh_verge(); + crate::core::notification::retire_sidecar_failure(); Ok(()) } diff --git a/src-tauri/src/core/notification.rs b/src-tauri/src/core/notification.rs index 9f8be9797..482e05218 100644 --- a/src-tauri/src/core/notification.rs +++ b/src-tauri/src/core/notification.rs @@ -231,6 +231,46 @@ pub fn what_was_asked() -> FailedOperation { static PENDING_FAILURES: Lazy = Lazy::new(FailureTable::default); +/// A removal has a revision too, so a delayed read cannot restore a resolved failure. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct SidecarFailureSnapshot { + pub revision: u64, + pub detail: Option, +} + +impl SidecarFailureSnapshot { + fn record(&mut self, detail: String) { + self.revision += 1; + self.detail = Some(detail); + } + + fn recover(&mut self) -> bool { + if self.detail.take().is_none() { + return false; + } + self.revision += 1; + true + } +} + +static SIDECAR_FAILURE: Lazy> = Lazy::new(Mutex::default); + +pub fn sidecar_failure_snapshot() -> SidecarFailureSnapshot { + SIDECAR_FAILURE.lock().clone() +} + +pub fn record_sidecar_failure(detail: String) { + SIDECAR_FAILURE.lock().record(detail); + notify_pending_failures_changed(); +} + +pub fn retire_sidecar_failure() { + let changed = SIDECAR_FAILURE.lock().recover(); + if changed { + notify_pending_failures_changed(); + } +} + pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into) { PENDING_FAILURES.record(operation, code, detail.into()); notify_pending_failures_changed(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c186457c7..bbd71c092 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -143,6 +143,7 @@ mod app_init { cmd::upgrade_clash_core, cmd::get_runtime_state, cmd::get_pending_failures, + cmd::get_sidecar_failure, cmd::entry_lightweight_mode, cmd::install_service, cmd::uninstall_service, diff --git a/src/components/layout/notice-manager.tsx b/src/components/layout/notice-manager.tsx index 4e2140d61..c70bd0838 100644 --- a/src/components/layout/notice-manager.tsx +++ b/src/components/layout/notice-manager.tsx @@ -68,6 +68,66 @@ const resolveNoticeMessage = ( const i18n = notice.i18n if (!i18n) return bound(notice.message) + const detail = i18n.params?.message + const existingCore = + notice.code === 'SERVICE_SIDECAR_FAILED' && typeof detail === 'string' + ? /process verge-mihomo(?:-alpha)?(?:\.exe)? \(PID (\d+)\) is still running; refusing a second core\s*$/.exec( + detail, + ) + : null + if (existingCore && typeof detail === 'string') { + const permissionRejected = detail.startsWith('Service core rejected:') + return ( + <> + {permissionRejected && ( + + {t( + 'settings.feedback.notifications.clashService.permissionRejectedReason', + )}{' '} + { + event.preventDefault() + void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch( + showNotice.error, + ) + }} + > + {t( + 'settings.feedback.notifications.clashService.permissionRepairGuide', + )} + + + )} + {t('settings.feedback.errors.clashService.coreAlreadyRunning', { + pid: existingCore[1], + })} + + + {t('shared.feedback.errors.details')} + + + {detail} + + + + ) + } + if (i18n.key === SERVICE_PERMISSION_NOTICE) { return ( <> diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 7816657b3..456b10271 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "تعذّر إلغاء تثبيت الخدمة", "reinstallFailed": "تعذّر إعادة تثبيت الخدمة", "repairFailed": "تعذّر إصلاح الخدمة", - "sidecarFailed": "تعذّر المتابعة باستخدام Sidecar" + "sidecarFailed": "تعذّر المتابعة باستخدام Sidecar", + "coreAlreadyRunning": "تعذّر بدء وضع Sidecar: لا تزال نواة أخرى قيد التشغيل (PID {{pid}}). أغلق نسخ Clash Verge الأخرى أو التطبيق الذي يستخدم هذه النواة، ثم أعد المحاولة." }, "sysproxy": { "privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.", diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 500d5e00a..0498d2d0e 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Dienst konnte nicht deinstalliert werden", "reinstallFailed": "Dienst konnte nicht neu installiert werden", "repairFailed": "Dienst konnte nicht repariert werden", - "sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen" + "sidecarFailed": "Fortsetzung mit Sidecar fehlgeschlagen", + "coreAlreadyRunning": "Der Sidecar-Modus konnte nicht gestartet werden: Ein anderer Kern läuft noch (PID {{pid}}). Beenden Sie andere Clash-Verge-Instanzen oder die App, die diesen Kern verwendet, und versuchen Sie es erneut." }, "sysproxy": { "privilegeRequired": "Systemproxy kann nicht gesetzt werden: macOS hat die Änderung mangels Berechtigung abgelehnt. Installieren Sie den Clash-Verge-Dienst; als Administrator können Sie in den erweiterten Optionen unter Systemeinstellungen → Datenschutz & Sicherheit auch die Passwortpflicht für systemweite Einstellungen deaktivieren.", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 7b1da70e9..c614ef0b7 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Failed to uninstall the service", "reinstallFailed": "Failed to reinstall the service", "repairFailed": "Failed to repair the service", - "sidecarFailed": "Failed to continue with Sidecar" + "sidecarFailed": "Failed to continue with Sidecar", + "coreAlreadyRunning": "Could not start normal mode (Sidecar): another core is still running (PID {{pid}}). Exit other Clash Verge instances or the app using this core, then retry." }, "sysproxy": { "privilegeRequired": "Cannot set the system proxy: macOS refused the change for lack of privileges. Install the Clash Verge service; an administrator can also turn off the administrator-password requirement for system-wide settings, in the advanced options of System Settings → Privacy & Security.", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 61116797c..2a3ba8c6b 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "No se pudo desinstalar el servicio", "reinstallFailed": "No se pudo reinstalar el servicio", "repairFailed": "No se pudo reparar el servicio", - "sidecarFailed": "No se pudo continuar con Sidecar" + "sidecarFailed": "No se pudo continuar con Sidecar", + "coreAlreadyRunning": "No se pudo iniciar el modo Sidecar: hay otro núcleo en ejecución (PID {{pid}}). Cierre las otras instancias de Clash Verge o la aplicación que usa ese núcleo y vuelva a intentarlo." }, "sysproxy": { "privilegeRequired": "No se puede establecer el proxy del sistema: macOS rechazó el cambio por falta de permisos. Instale el servicio de Clash Verge; si es administrador, también puede desactivar la exigencia de contraseña de administrador para los ajustes de todo el sistema en las opciones avanzadas de Ajustes del Sistema → Privacidad y seguridad.", diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index 7f301933e..3332e07bd 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "حذف سرویس ناموفق بود", "reinstallFailed": "نصب مجدد سرویس ناموفق بود", "repairFailed": "تعمیر سرویس ناموفق بود", - "sidecarFailed": "ادامه با Sidecar ناموفق بود" + "sidecarFailed": "ادامه با Sidecar ناموفق بود", + "coreAlreadyRunning": "حالت Sidecar شروع نشد: هسته دیگری هنوز در حال اجراست (PID {{pid}}). نمونه‌های دیگر Clash Verge یا برنامه‌ای که از این هسته استفاده می‌کند را ببندید و دوباره تلاش کنید." }, "sysproxy": { "privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید می‌توانید در گزینه‌های پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.", diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 2543b6711..d523c89c4 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Gagal mencopot layanan", "reinstallFailed": "Gagal memasang ulang layanan", "repairFailed": "Gagal memperbaiki layanan", - "sidecarFailed": "Gagal melanjutkan dengan Sidecar" + "sidecarFailed": "Gagal melanjutkan dengan Sidecar", + "coreAlreadyRunning": "Tidak dapat memulai mode Sidecar: inti lain masih berjalan (PID {{pid}}). Tutup instans Clash Verge lain atau aplikasi yang menggunakan inti tersebut, lalu coba lagi." }, "sysproxy": { "privilegeRequired": "Tidak dapat mengatur proxy sistem: macOS menolak perubahan ini karena hak akses tidak mencukupi. Pasang layanan Clash Verge; jika Anda administrator, Anda juga dapat mematikan keharusan kata sandi administrator untuk pengaturan seluruh sistem di opsi lanjutan Pengaturan Sistem → Privasi & Keamanan.", diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 85233efce..1259f1409 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "サービスのアンインストールに失敗しました", "reinstallFailed": "サービスの再インストールに失敗しました", "repairFailed": "サービスの修復に失敗しました", - "sidecarFailed": "Sidecar での続行に失敗しました" + "sidecarFailed": "Sidecar での続行に失敗しました", + "coreAlreadyRunning": "通常モード(Sidecar)を開始できません:別のコアが実行中です(PID {{pid}})。他の Clash Verge またはこのコアを使用しているアプリを終了してから、再試行してください。" }, "sysproxy": { "privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。", diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index aec37f65a..9c60c69db 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "서비스를 제거하지 못했습니다", "reinstallFailed": "서비스를 다시 설치하지 못했습니다", "repairFailed": "서비스를 복구하지 못했습니다", - "sidecarFailed": "Sidecar로 계속하지 못했습니다" + "sidecarFailed": "Sidecar로 계속하지 못했습니다", + "coreAlreadyRunning": "일반 모드(Sidecar)를 시작할 수 없습니다. 다른 코어가 실행 중입니다(PID {{pid}}). 다른 Clash Verge 인스턴스 또는 해당 코어를 사용하는 앱을 종료한 후 다시 시도하세요." }, "sysproxy": { "privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.", diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index f567ec32e..6111fd6a9 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Не удалось удалить службу", "reinstallFailed": "Не удалось переустановить службу", "repairFailed": "Не удалось восстановить службу", - "sidecarFailed": "Не удалось продолжить в режиме Sidecar" + "sidecarFailed": "Не удалось продолжить в режиме Sidecar", + "coreAlreadyRunning": "Не удалось запустить режим Sidecar: другое ядро ещё работает (PID {{pid}}). Закройте другие экземпляры Clash Verge или приложение, использующее это ядро, и повторите попытку." }, "sysproxy": { "privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».", diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 4c7e9dff3..958cd04a4 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Hizmet kaldırılamadı", "reinstallFailed": "Hizmet yeniden kurulamadı", "repairFailed": "Hizmet onarılamadı", - "sidecarFailed": "Sidecar ile devam edilemedi" + "sidecarFailed": "Sidecar ile devam edilemedi", + "coreAlreadyRunning": "Sidecar modu başlatılamadı: başka bir çekirdek hâlâ çalışıyor (PID {{pid}}). Diğer Clash Verge örneklerini veya bu çekirdeği kullanan uygulamayı kapatıp yeniden deneyin." }, "sysproxy": { "privilegeRequired": "Sistem vekil sunucusu ayarlanamıyor: macOS yetki yetersizliği nedeniyle değişikliği reddetti. Clash Verge hizmetini kurun; yöneticiyseniz Sistem Ayarları → Gizlilik ve Güvenlik bölümündeki gelişmiş seçeneklerden, sistem genelindeki ayarlar için yönetici parolası zorunluluğunu da kapatabilirsiniz.", diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index effbd7596..1bb591f7c 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "Хезмәтне бетереп булмады", "reinstallFailed": "Хезмәтне яңадан урнаштырып булмады", "repairFailed": "Хезмәтне төзәтеп булмады", - "sidecarFailed": "Sidecar белән дәвам итеп булмады" + "sidecarFailed": "Sidecar белән дәвам итеп булмады", + "coreAlreadyRunning": "Sidecar режимын башлап булмады: башка төш әле эшли (PID {{pid}}). Башка Clash Verge нөсхәләрен яки бу төшне кулланучы кушымтаны ябыгыз һәм яңадан сынап карагыз." }, "sysproxy": { "privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 792b463df..7316ef9b7 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "卸载服务失败", "reinstallFailed": "重新安装服务失败", "repairFailed": "修复服务失败", - "sidecarFailed": "继续使用 Sidecar 失败" + "sidecarFailed": "继续使用 Sidecar 失败", + "coreAlreadyRunning": "无法启动普通模式(Sidecar):已有内核进程正在运行(PID {{pid}})。请先退出其他 Clash Verge 实例或使用该内核的程序,然后重试。" }, "sysproxy": { "privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。", diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index a942e67fa..92fe5138f 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -663,7 +663,8 @@ "uninstallFailed": "解除安裝服務失敗", "reinstallFailed": "重新安裝服務失敗", "repairFailed": "修復服務失敗", - "sidecarFailed": "繼續使用 Sidecar 失敗" + "sidecarFailed": "繼續使用 Sidecar 失敗", + "coreAlreadyRunning": "無法啟動一般模式(Sidecar):已有核心程序正在執行(PID {{pid}})。請先結束其他 Clash Verge 執行個體或使用該核心的程式,然後重試。" }, "sysproxy": { "privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。", diff --git a/src/pages/_layout/hooks/use-pending-failures.ts b/src/pages/_layout/hooks/use-pending-failures.ts index 90efbd5d6..ed5ad7438 100644 --- a/src/pages/_layout/hooks/use-pending-failures.ts +++ b/src/pages/_layout/hooks/use-pending-failures.ts @@ -1,9 +1,14 @@ import { getCurrentWindow } from '@tauri-apps/api/window' import { useCallback, useEffect, useRef, useState } from 'react' -import { getPendingFailures, type PendingFailure } from '@/services/cmds' +import { + getPendingFailures, + getSidecarFailure, + type PendingFailure, + type SidecarFailureSnapshot, +} from '@/services/cmds' import { subscribeVergeEvents } from '@/services/events' -import { showNotice } from '@/services/notice-service' +import { showNotice, syncSidecarFailure } from '@/services/notice-service' /** Failures handled by the recovery dialog instead of a toast. */ const CODES_SHOWN_AS_A_DIALOG = new Set([ @@ -28,13 +33,27 @@ const windowIsWatched = async () => { const usePendingFailureReader = ( onFailures: (failures: PendingFailure[]) => void, + onSidecarFailure?: (snapshot: SidecarFailureSnapshot) => void, ) => { // Avoid resubscribing when the handler changes. - const handlerRef = useRef(onFailures) - handlerRef.current = onFailures + const handlerRef = useRef({ onFailures, onSidecarFailure }) + handlerRef.current = { onFailures, onSidecarFailure } useEffect(() => { + let disposed = false const read = () => { + if (handlerRef.current.onSidecarFailure) { + void getSidecarFailure() + .then((snapshot) => { + if (!disposed) handlerRef.current.onSidecarFailure?.(snapshot) + }) + .catch((error) => { + console.warn( + '[pending-failures] Sidecar failure could not be read:', + error, + ) + }) + } void (async () => { let failures: PendingFailure[] try { @@ -43,7 +62,7 @@ const usePendingFailureReader = ( console.warn('[pending-failures] could not be read:', error) return } - handlerRef.current(failures) + if (!disposed) handlerRef.current.onFailures(failures) })() } @@ -62,6 +81,7 @@ const usePendingFailureReader = ( }) return () => { + disposed = true unsubscribe() document.removeEventListener('visibilitychange', readWhenVisible) void unlistenFocus.then((unlisten) => unlisten()) @@ -96,6 +116,7 @@ export const usePendingFailures = () => { }, [showNewFailures], ), + syncSidecarFailure, ) } diff --git a/src/services/cmds.ts b/src/services/cmds.ts index c2df176fb..75172d40d 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -467,6 +467,15 @@ export const getPendingFailures = async () => { return invoke('get_pending_failures') } +export interface SidecarFailureSnapshot { + revision: number + detail: string | null +} + +export const getSidecarFailure = async () => { + return invoke('get_sidecar_failure') +} + export const getAppUptime = async () => { return invoke('get_app_uptime') } diff --git a/src/services/notice-service.ts b/src/services/notice-service.ts index 5edecfda2..fd4f8779d 100644 --- a/src/services/notice-service.ts +++ b/src/services/notice-service.ts @@ -1,7 +1,7 @@ import i18n from 'i18next' import { ReactNode, isValidElement } from 'react' -import type { FailedOperation } from '@/services/cmds' +import type { FailedOperation, SidecarFailureSnapshot } from '@/services/cmds' import type { TranslationKey } from '@/types/generated/i18n-keys' type NoticeType = 'success' | 'error' | 'info' | 'warning' @@ -97,6 +97,7 @@ const CODED_ERROR_TRANSLATION_KEYS: Readonly> = { let nextId = 0 let notices: NoticeItem[] = [] +let sidecarFailureRevision = -1 const subscribers: Set = new Set() function notifySubscribers() { @@ -461,7 +462,8 @@ const baseShowNotice = ( const code = failureCode(raw) ?? failureCode(message) ?? nestedCode const operation = failureOperation(raw) ?? failureOperation(message) // Suppress duplicate toasts while preserving the caller's id contract. - if (isReportedByDialog(code)) return id + // Sidecar failures come only from versioned backend state, never delayed command rejections. + if (isReportedByDialog(code) || code === 'SERVICE_SIDECAR_FAILED') return id const effectiveDuration = resolveDuration(type, duration) const timerId = effectiveDuration > 0 @@ -524,6 +526,29 @@ export function hideNotice(id: number) { notifySubscribers() } +export function hideNoticesByCode(code: string) { + notices + .filter((notice) => notice.code === code) + .forEach(({ id }) => hideNotice(id)) +} + +export function syncSidecarFailure(snapshot: SidecarFailureSnapshot) { + if (snapshot.revision <= sidecarFailureRevision) return + sidecarFailureRevision = snapshot.revision + hideNoticesByCode('SERVICE_SIDECAR_FAILED') + if (snapshot.detail === null) return + + const failure = { code: 'SERVICE_SIDECAR_FAILED', detail: snapshot.detail } + notices = [ + ...notices, + buildNotice(nextId++, 'error', 0, { + ...normalizeNoticeMessage(failure), + code: failure.code, + }), + ] + notifySubscribers() +} + export function subscribeNotices(subscriber: NoticeSubscriber) { subscribers.add(subscriber) return () => { diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index ee97efbc9..4ef55850b 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -792,6 +792,7 @@ export const translationKeys = [ 'settings.feedback.errors.clashService.reinstallFailed', 'settings.feedback.errors.clashService.repairFailed', 'settings.feedback.errors.clashService.sidecarFailed', + 'settings.feedback.errors.clashService.coreAlreadyRunning', 'settings.feedback.errors.sysproxy.privilegeRequired', 'settings.feedback.errors.sysproxy.directFallback', 'settings.feedback.errors.sysproxy.guardStopped', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 5d71ad7b1..0b4005d6a 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -820,6 +820,7 @@ export interface TranslationResources { stopFailed: string } clashService: { + coreAlreadyRunning: string installFailed: string reinstallFailed: string repairFailed: string