fix(service): explain sidecar failures and clear recovered notices

This commit is contained in:
wonfen
2026-10-03 10:21:54 +08:00
parent 8c1fb3ac56
commit f958dccb25
24 changed files with 284 additions and 84 deletions
+4
View File
@@ -17,4 +17,8 @@
- 优化侧边栏流量图表的 CPU 占用
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的提示:启动、安装或修复服务时说明原因与内核占用,并提供处理方法
</details>
+15 -11
View File
@@ -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, FallbackFuture>(
fallback: Fallback,
) -> CmdResult<ServiceInstallOutcome>
where
Fallback: FnOnce() -> FallbackFuture,
Fallback: FnOnce(String) -> FallbackFuture,
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
{
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<ServiceInstallOutcome> {
#[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::ServiceFallbackNotice> {
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"));
}
}
+23 -2
View File
@@ -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,6 +390,8 @@ 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;
// Record failures before releasing the lifecycle lock, including preparation and rollback errors.
let result = async {
let status = SERVICE_MANAGER.current().await;
tracing::Span::current().record("status", tracing::field::debug(&status));
let mode = self.get_running_mode();
@@ -431,6 +448,9 @@ impl CoreManager {
}
Ok(())
}
.await;
result.map_err(|error| report_sidecar_failure(error, service_rejection))
}
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
pub async fn uninstall_service_and_start_sidecar(&self) -> Result<()> {
@@ -572,6 +592,7 @@ impl CoreManager {
}
// tell the window when a background apply lands
Handle::refresh_verge();
crate::core::notification::retire_sidecar_failure();
Ok(())
}
+40
View File
@@ -231,6 +231,46 @@ pub fn what_was_asked() -> FailedOperation {
static PENDING_FAILURES: Lazy<FailureTable> = 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<String>,
}
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<Mutex<SidecarFailureSnapshot>> = 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<String>) {
PENDING_FAILURES.record(operation, code, detail.into());
notify_pending_failures_changed();
+1
View File
@@ -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,
+60
View File
@@ -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 && (
<Box sx={{ mb: 1 }}>
{t(
'settings.feedback.notifications.clashService.permissionRejectedReason',
)}{' '}
<Link
href={SERVICE_PERMISSION_GUIDE}
color="inherit"
underline="always"
onClick={(event) => {
event.preventDefault()
void openExternalUrl(SERVICE_PERMISSION_GUIDE).catch(
showNotice.error,
)
}}
>
{t(
'settings.feedback.notifications.clashService.permissionRepairGuide',
)}
</Link>
</Box>
)}
{t('settings.feedback.errors.clashService.coreAlreadyRunning', {
pid: existingCore[1],
})}
<Box component="details" sx={{ mt: 1 }}>
<Box component="summary" sx={{ cursor: 'pointer' }}>
{t('shared.feedback.errors.details')}
</Box>
<Box
component="pre"
sx={{
m: 0,
mt: 1,
maxHeight: 160,
overflow: 'auto',
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
userSelect: 'text',
fontSize: 'inherit',
}}
>
{detail}
</Box>
</Box>
</>
)
}
if (i18n.key === SERVICE_PERMISSION_NOTICE) {
return (
<>
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "تعذّر إلغاء تثبيت الخدمة",
"reinstallFailed": "تعذّر إعادة تثبيت الخدمة",
"repairFailed": "تعذّر إصلاح الخدمة",
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar"
"sidecarFailed": "تعذّر المتابعة باستخدام Sidecar",
"coreAlreadyRunning": "تعذّر بدء وضع Sidecar: لا تزال نواة أخرى قيد التشغيل (PID {{pid}}). أغلق نسخ Clash Verge الأخرى أو التطبيق الذي يستخدم هذه النواة، ثم أعد المحاولة."
},
"sysproxy": {
"privilegeRequired": "تعذّر ضبط وكيل النظام: رفض macOS التغيير لعدم كفاية الصلاحيات. ثبّت خدمة Clash Verge؛ وإن كنت مسؤولًا فيمكنك أيضًا إيقاف اشتراط كلمة سر المسؤول للإعدادات على مستوى النظام من الخيارات المتقدمة في إعدادات النظام ← الخصوصية والأمان.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "حذف سرویس ناموفق بود",
"reinstallFailed": "نصب مجدد سرویس ناموفق بود",
"repairFailed": "تعمیر سرویس ناموفق بود",
"sidecarFailed": "ادامه با Sidecar ناموفق بود"
"sidecarFailed": "ادامه با Sidecar ناموفق بود",
"coreAlreadyRunning": "حالت Sidecar شروع نشد: هسته دیگری هنوز در حال اجراست (PID {{pid}}). نمونه‌های دیگر Clash Verge یا برنامه‌ای که از این هسته استفاده می‌کند را ببندید و دوباره تلاش کنید."
},
"sysproxy": {
"privilegeRequired": "تنظیم پراکسی سیستم ممکن نیست: macOS به دلیل نداشتن دسترسی، این تغییر را رد کرد. سرویس Clash Verge را نصب کنید؛ اگر مدیر هستید می‌توانید در گزینه‌های پیشرفتهٔ تنظیمات سیستم ← حریم خصوصی و امنیت، الزام رمز مدیر برای تنظیمات سراسری سیستم را نیز خاموش کنید.",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "サービスのアンインストールに失敗しました",
"reinstallFailed": "サービスの再インストールに失敗しました",
"repairFailed": "サービスの修復に失敗しました",
"sidecarFailed": "Sidecar での続行に失敗しました"
"sidecarFailed": "Sidecar での続行に失敗しました",
"coreAlreadyRunning": "通常モード(Sidecar)を開始できません:別のコアが実行中です(PID {{pid}})。他の Clash Verge またはこのコアを使用しているアプリを終了してから、再試行してください。"
},
"sysproxy": {
"privilegeRequired": "システムプロキシを設定できません: 権限が不足しているため macOS が変更を拒否しました。Clash Verge サービスをインストールしてください。管理者であれば、システム設定 → プライバシーとセキュリティの詳細オプションで、システム全体の設定に管理者パスワードを要求する設定をオフにすることもできます。",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "서비스를 제거하지 못했습니다",
"reinstallFailed": "서비스를 다시 설치하지 못했습니다",
"repairFailed": "서비스를 복구하지 못했습니다",
"sidecarFailed": "Sidecar로 계속하지 못했습니다"
"sidecarFailed": "Sidecar로 계속하지 못했습니다",
"coreAlreadyRunning": "일반 모드(Sidecar)를 시작할 수 없습니다. 다른 코어가 실행 중입니다(PID {{pid}}). 다른 Clash Verge 인스턴스 또는 해당 코어를 사용하는 앱을 종료한 후 다시 시도하세요."
},
"sysproxy": {
"privilegeRequired": "시스템 프록시를 설정할 수 없습니다: 권한이 부족하여 macOS가 변경을 거부했습니다. Clash Verge 서비스를 설치하십시오. 관리자라면 시스템 설정 → 개인 정보 보호 및 보안의 고급 옵션에서, 시스템 전체 설정에 관리자 암호를 요구하는 설정을 끌 수도 있습니다.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "Не удалось удалить службу",
"reinstallFailed": "Не удалось переустановить службу",
"repairFailed": "Не удалось восстановить службу",
"sidecarFailed": "Не удалось продолжить в режиме Sidecar"
"sidecarFailed": "Не удалось продолжить в режиме Sidecar",
"coreAlreadyRunning": "Не удалось запустить режим Sidecar: другое ядро ещё работает (PID {{pid}}). Закройте другие экземпляры Clash Verge или приложение, использующее это ядро, и повторите попытку."
},
"sysproxy": {
"privilegeRequired": "Не удалось настроить системный прокси: macOS отклонил изменение из-за нехватки прав. Установите службу Clash Verge; администратор также может отключить требование пароля администратора для общесистемных настроек в дополнительных параметрах раздела «Системные настройки → Конфиденциальность и безопасность».",
+2 -1
View File
@@ -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.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "Хезмәтне бетереп булмады",
"reinstallFailed": "Хезмәтне яңадан урнаштырып булмады",
"repairFailed": "Хезмәтне төзәтеп булмады",
"sidecarFailed": "Sidecar белән дәвам итеп булмады"
"sidecarFailed": "Sidecar белән дәвам итеп булмады",
"coreAlreadyRunning": "Sidecar режимын башлап булмады: башка төш әле эшли (PID {{pid}}). Башка Clash Verge нөсхәләрен яки бу төшне кулланучы кушымтаны ябыгыз һәм яңадан сынап карагыз."
},
"sysproxy": {
"privilegeRequired": "Система проксиен көйләп булмый: хокуклар җитмәгәнгә күрә macOS бу үзгәрешне кире какты. Clash Verge хезмәтен урнаштырыгыз; әгәр сез администратор булсагыз, Система көйләнмәләре → Шәхсилек вә иминлек бүлегенең өстәмә параметрларында, система дәрәҗәсендәге көйләнмәләр өчен администратор паролен таләп итүне сүндерә аласыз.",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "卸载服务失败",
"reinstallFailed": "重新安装服务失败",
"repairFailed": "修复服务失败",
"sidecarFailed": "继续使用 Sidecar 失败"
"sidecarFailed": "继续使用 Sidecar 失败",
"coreAlreadyRunning": "无法启动普通模式(Sidecar):已有内核进程正在运行(PID {{pid}})。请先退出其他 Clash Verge 实例或使用该内核的程序,然后重试。"
},
"sysproxy": {
"privilegeRequired": "无法设置系统代理:macOS 以权限不足为由拒绝了这次修改。请安装 Clash Verge 服务;管理员也可以在系统设置 → 隐私与安全性的高级选项中,关闭访问系统范围设置需要管理员密码的要求。",
+2 -1
View File
@@ -663,7 +663,8 @@
"uninstallFailed": "解除安裝服務失敗",
"reinstallFailed": "重新安裝服務失敗",
"repairFailed": "修復服務失敗",
"sidecarFailed": "繼續使用 Sidecar 失敗"
"sidecarFailed": "繼續使用 Sidecar 失敗",
"coreAlreadyRunning": "無法啟動一般模式(Sidecar):已有核心程序正在執行(PID {{pid}})。請先結束其他 Clash Verge 執行個體或使用該核心的程式,然後重試。"
},
"sysproxy": {
"privilegeRequired": "無法設定系統代理:macOS 以權限不足為由拒絕了這次修改。請安裝 Clash Verge 服務;管理者也可以在系統設定 → 隱私權與安全性的進階選項中,關閉存取系統層級設定需要管理者密碼的要求。",
@@ -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<string>([
@@ -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,
)
}
+9
View File
@@ -467,6 +467,15 @@ export const getPendingFailures = async () => {
return invoke<PendingFailure[]>('get_pending_failures')
}
export interface SidecarFailureSnapshot {
revision: number
detail: string | null
}
export const getSidecarFailure = async () => {
return invoke<SidecarFailureSnapshot>('get_sidecar_failure')
}
export const getAppUptime = async () => {
return invoke<number>('get_app_uptime')
}
+27 -2
View File
@@ -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<Record<string, TranslationKey>> = {
let nextId = 0
let notices: NoticeItem[] = []
let sidecarFailureRevision = -1
const subscribers: Set<NoticeSubscriber> = 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 () => {
+1
View File
@@ -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',
+1
View File
@@ -820,6 +820,7 @@ export interface TranslationResources {
stopFailed: string
}
clashService: {
coreAlreadyRunning: string
installFailed: string
reinstallFailed: string
repairFailed: string