diff --git a/Changelog.md b/Changelog.md index 5990e4618..45a5b1446 100644 --- a/Changelog.md +++ b/Changelog.md @@ -16,12 +16,13 @@ - macOS 修复编辑规则时「规则内容」输入的首字母被自动转为大写的问题 - macOS 修复残留的服务进程导致内核无法启动、「继续使用 Sidecar」失败且无法修复的问题 - macOS 优化用户目录在外置磁盘时服务拒绝启动内核的提示:说明原因并给出修复命令 +- macOS 修复服务模式内核意外停止后,系统代理仍指向失效端口导致无法上网的问题
🚀 优化改进 -- 优化内核启动失败提示:显示具体原因,并在窗口恢复后提示尚未解决的启动错误 +- 优化内核启动失败和服务模式内核意外停止的提示:显示具体原因,并在窗口恢复后提示尚未解决的错误
diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 923cbe8d4..71a286330 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -115,7 +115,7 @@ pub fn take_service_fallback_notice() -> bool { } #[tauri::command] -pub fn get_core_startup_error() -> Option { +pub fn get_core_startup_error() -> Option { crate::core::CoreManager::global().get_startup_error() } diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index 4af3acdd6..ae5889e7d 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -74,6 +74,14 @@ impl fmt::Display for RunningMode { } } +/// Why the Core is not running, kept until a Core starts again. +#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "detail", rename_all = "camelCase")] +pub enum CoreFailure { + StartFailed(String), + ServiceCoreStopped(String), +} + #[derive(Debug)] pub struct CoreManager { /// The Run State this manager reports transitions to. @@ -87,7 +95,7 @@ pub struct CoreManager { job_handle: ArcSwapOption, config_update_in_progress: AtomicBool, core_readiness_state: AtomicU64, - startup_error: parking_lot::Mutex>, + startup_error: parking_lot::Mutex>, sidecar_exit: tokio::sync::Mutex>>, // 串行化 start/stop/restart 和 sidecar→service 交接。 // 锁序固定为 config_update_in_progress → lifecycle_lock。 @@ -143,14 +151,14 @@ impl CoreManager { self.run_state.mode_arc() } - pub(crate) fn record_startup_error(&self, error: String) { + pub(crate) fn record_startup_error(&self, failure: CoreFailure) { let mut startup_error = self.startup_error.lock(); if matches!(*self.get_running_mode(), RunningMode::NotRunning) { - *startup_error = Some(error); + *startup_error = Some(failure); } } - pub(crate) fn get_startup_error(&self) -> Option { + pub(crate) fn get_startup_error(&self) -> Option { self.startup_error.lock().clone() } @@ -325,13 +333,13 @@ singleton!(CoreManager, CORE_MANAGER); #[cfg(test)] mod startup_error_tests { - use super::{CoreManager, RunningMode}; + use super::{CoreFailure, CoreManager, RunningMode}; #[test] fn recovery_invalidates_an_unread_startup_error_even_after_a_later_exit() { for mode in [RunningMode::Sidecar, RunningMode::Service] { let manager = CoreManager::isolated(); - manager.record_startup_error("startup failed".into()); + manager.record_startup_error(CoreFailure::StartFailed("startup failed".into())); manager.core_started(mode); manager.core_stopped(); assert_eq!(manager.get_startup_error(), None); @@ -341,9 +349,10 @@ mod startup_error_tests { #[test] fn unresolved_startup_error_survives_repeated_reads() { let manager = CoreManager::isolated(); - manager.record_startup_error("startup failed".into()); + let failure = CoreFailure::StartFailed("startup failed".into()); + manager.record_startup_error(failure.clone()); for _ in 0..2 { - assert_eq!(manager.get_startup_error().as_deref(), Some("startup failed")); + assert_eq!(manager.get_startup_error().as_ref(), Some(&failure)); } } } diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index dc684b7c8..ec98dcd0e 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -5,7 +5,7 @@ use crate::{ core::{ CoreManager, handle::Handle, - manager::RunningMode, + manager::{CoreFailure, RunningMode}, owner_identity::current_owner_credentials, proxy_control, runstate::{ @@ -22,8 +22,8 @@ use clash_verge_draft::Draft; use clash_verge_logging::{Type, logging}; use clash_verge_service_ipc::{ MacosProxyConfig, OwnerCredentials, OwnerIdentity, OwnerSessionProof, ProtocolInfo, ProxyApplyOutcome, - RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, StageRuntimeOutcome, StartClashRequest, - WriterConfig, + RuntimeBundle, RuntimeFileOutcome, RuntimeFileRequest, ServiceErrorCode, ServiceStatusSnapshot, + StageRuntimeOutcome, StartClashRequest, WriterConfig, }; use once_cell::sync::Lazy; use parking_lot::Mutex; @@ -942,7 +942,7 @@ pub(super) async fn get_clash_logs_by_service() -> Result> { if response.code > 0 { if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { - recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await; + recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await; } let err_msg = response.message; bail!(err_msg); @@ -960,7 +960,7 @@ pub(crate) async fn get_clash_log_snapshot_by_service() -> Result { let response = response.context("无法连接到Clash Verge Service")?; if response.code > 0 { if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { - recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await; + recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced, None).await; } bail!(response.message); } @@ -1493,9 +1493,11 @@ struct OwnerRecoveryPolicy { reset_system_proxy: bool, } -const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy { +/// The macOS proxy is machine-wide and only the helper may write it, for the session it still +/// honours; a displaced or unreachable owner must leave it alone. +const fn owner_recovery_policy(reason: OwnerRecoveryReason, is_macos: bool) -> OwnerRecoveryPolicy { OwnerRecoveryPolicy { - reset_system_proxy: !is_macos, + reset_system_proxy: !is_macos || matches!(reason, OwnerRecoveryReason::SameOwnerFailure), } } @@ -1517,6 +1519,7 @@ fn start_owner_monitor() { AsyncHandler::spawn(move || async move { logging!(debug, Type::Service, "owner monitor started (generation {generation})"); let mut watch = OwnerWatch::new(); + let mut core_restarts = None; loop { tokio::time::sleep(OWNER_MONITOR_INTERVAL).await; if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation { @@ -1536,7 +1539,10 @@ fn start_owner_monitor() { break; } - let sample = read_owner_sample().await; + let (sample, status) = read_owner_sample().await; + if let Some(status) = &status { + log_core_restarts(&mut core_restarts, status); + } let mut step = watch.observe(sample); if matches!(step, OwnerStep::VerifyTransport) { if watch.just_became_sustained() { @@ -1552,15 +1558,41 @@ fn start_owner_monitor() { } if let OwnerStep::Recover(reason) = step { - recover_after_owner_loss(generation, reason).await; + recover_after_owner_loss(generation, reason, status.as_ref()).await; break; } } }); } +/// The Service logs its watchdog restarts where the app cannot read them. +fn log_core_restarts(seen: &mut Option, status: &ServiceStatusSnapshot) { + if seen.is_some_and(|seen| status.restart_count > seen) { + logging!( + warn, + Type::Service, + "service restarted the core ({} restarts so far); last exit: {}", + status.restart_count, + status.last_core_exit_reason.as_deref().unwrap_or("unknown") + ); + } + *seen = Some(status.restart_count); +} + +fn report_service_core_stopped(status: &ServiceStatusSnapshot) { + let detail = format!( + "service state {:?}, {} restarts, last exit: {}", + status.service_state, + status.restart_count, + status.last_core_exit_reason.as_deref().unwrap_or("none reported") + ); + logging!(error, Type::Service, "service core stopped: {detail}"); + CoreManager::global().record_startup_error(CoreFailure::ServiceCoreStopped(detail)); + Handle::notice_message("core_start::error", ""); +} + /// Samples ownership, treating every unusable reply as unreadable. -async fn read_owner_sample() -> OwnerSample { +async fn read_owner_sample() -> (OwnerSample, Option) { let response = match current_owner_credentials() { Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await, Err(error) => Err(error), @@ -1570,12 +1602,12 @@ async fn read_owner_sample() -> OwnerSample { Ok(response) => response, Err(error) => { logging!(debug, Type::Service, "service owner status was unreadable: {error:#}"); - return OwnerSample::Unreadable; + return (OwnerSample::Unreadable, None); } }; if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { - return OwnerSample::NotActive; + return (OwnerSample::NotActive, None); } if response.code != 0 { logging!( @@ -1585,24 +1617,25 @@ async fn read_owner_sample() -> OwnerSample { response.code, response.message ); - return OwnerSample::Unreadable; + return (OwnerSample::Unreadable, None); } let Some(status) = response.data else { logging!(debug, Type::Service, "service owner status omitted data"); - return OwnerSample::Unreadable; + return (OwnerSample::Unreadable, None); }; // A session that no longer matches is another owner's, whatever the flags say. if !session_matches_active_status(status.is_active, status.active_generation) { - return OwnerSample::NotActive; + return (OwnerSample::NotActive, None); } - OwnerSample::Status { + let sample = OwnerSample::Status { is_active: status.is_active, desired_core_should_be_running: status.desired_core_should_be_running, service_state: status.service_state, core_pid: status.core_pid, - } + }; + (sample, Some(status)) } fn session_matches_active_status(is_active: bool, active_generation: Option) -> bool { @@ -1620,7 +1653,12 @@ pub(crate) fn owner_monitor_generation() -> u64 { OWNER_MONITOR_GENERATION.load(Ordering::Acquire) } -async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) { +/// `status` is the sample that led to the recovery, if any; a failed core is reported from it. +async fn recover_after_owner_loss( + generation: u64, + reason: OwnerRecoveryReason, + status: Option<&ServiceStatusSnapshot>, +) { let manager = CoreManager::global(); if !matches!(*manager.get_running_mode(), RunningMode::Service) { return; @@ -1636,6 +1674,10 @@ async fn recover_after_owner_loss(generation: u64, reason: OwnerRecoveryReason) return; } recover_after_owner_loss_while_locked(reason).await; + // Still under the lifecycle lock, so a later start is the one that clears it. + if let (OwnerRecoveryReason::SameOwnerFailure, Some(status)) = (reason, status) { + report_service_core_stopped(status); + } } fn claim_owner_recovery_generation(generation: &AtomicU64, captured_generation: u64) -> Option { @@ -1660,13 +1702,15 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) { ); mark_service_unavailable_after_owner_loss(&RUN_STATE, reason); proxy_control::stop_guard().await; + // Clear while still in Service mode with the session: on macOS it routes through the helper. + if owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy { + clear_proxy_after_owner_loss().await; + } clear_active_service_session(); CoreManager::global().core_stopped(); +} - if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy { - return; - } - +async fn clear_proxy_after_owner_loss() { let mut last_error = None; for attempt in 1..=3 { match proxy_control::clear().await { @@ -2182,13 +2226,16 @@ mod tests { } #[test] - fn macos_recovery_never_resets_machine_wide_proxy() { + fn macos_recovery_resets_machine_wide_proxy_only_for_its_own_failed_core() { for reason in [ OwnerRecoveryReason::Displaced, OwnerRecoveryReason::SameOwnerFailure, OwnerRecoveryReason::TransportFailure, ] { - assert!(!owner_recovery_policy(reason, true).reset_system_proxy); + assert_eq!( + owner_recovery_policy(reason, true).reset_system_proxy, + reason == OwnerRecoveryReason::SameOwnerFailure + ); assert!(owner_recovery_policy(reason, false).reset_system_proxy); } diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index 790a4a371..49cd25edb 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -9,6 +9,7 @@ use crate::{ handle::Handle, hotkey::Hotkey, logger, + manager::CoreFailure, service::{SERVICE_MANAGER, ServiceManager}, tray::Tray, }, @@ -221,7 +222,7 @@ async fn init_core_manager() -> bool { Ok(initialized) => initialized, Err(error) => { logging!(error, Type::Setup, "core manager initialization failed: {error:#}"); - CoreManager::global().record_startup_error(format!("{error:#}")); + CoreManager::global().record_startup_error(CoreFailure::StartFailed(format!("{error:#}"))); Handle::notice_message("core_start::error", ""); false } diff --git a/src/locales/ar/settings.json b/src/locales/ar/settings.json index 1ec7c838a..7816657b3 100644 --- a/src/locales/ar/settings.json +++ b/src/locales/ar/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "تعذّر تغيير وضع الوكيل", "restartFailed": "تعذّر إعادة تشغيل النواة", "startFailed": "تعذّر تشغيل النواة", + "serviceCoreStopped": "توقفت النواة التي تشغّلها الخدمة بشكل غير متوقع", "stopFailed": "تعذّر إيقاف النواة" }, "clashService": { diff --git a/src/locales/de/settings.json b/src/locales/de/settings.json index 37bab10ba..500d5e00a 100644 --- a/src/locales/de/settings.json +++ b/src/locales/de/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Proxy-Modus konnte nicht geändert werden", "restartFailed": "Kern konnte nicht neu gestartet werden", "startFailed": "Kern konnte nicht gestartet werden", + "serviceCoreStopped": "Der vom Dienst ausgeführte Kern wurde unerwartet beendet", "stopFailed": "Kern konnte nicht gestoppt werden" }, "clashService": { diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 328a0e7b0..7b1da70e9 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Failed to change proxy mode", "restartFailed": "Failed to restart the core", "startFailed": "Failed to start the core", + "serviceCoreStopped": "The service's core stopped unexpectedly", "stopFailed": "Failed to stop the core" }, "clashService": { diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index e6c7fa080..61116797c 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "No se pudo cambiar el modo de proxy", "restartFailed": "No se pudo reiniciar el núcleo", "startFailed": "No se pudo iniciar el núcleo", + "serviceCoreStopped": "El núcleo ejecutado por el servicio se detuvo inesperadamente", "stopFailed": "No se pudo detener el núcleo" }, "clashService": { diff --git a/src/locales/fa/settings.json b/src/locales/fa/settings.json index e5c62b553..7f301933e 100644 --- a/src/locales/fa/settings.json +++ b/src/locales/fa/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "تغییر حالت پروکسی ناموفق بود", "restartFailed": "راه‌اندازی مجدد هسته ناموفق بود", "startFailed": "راه‌اندازی هسته ناموفق بود", + "serviceCoreStopped": "هسته‌ای که سرویس اجرا می‌کرد به‌طور غیرمنتظره متوقف شد", "stopFailed": "توقف هسته ناموفق بود" }, "clashService": { diff --git a/src/locales/id/settings.json b/src/locales/id/settings.json index 7d758f695..2543b6711 100644 --- a/src/locales/id/settings.json +++ b/src/locales/id/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Gagal mengubah mode proxy", "restartFailed": "Gagal memulai ulang core", "startFailed": "Gagal memulai core", + "serviceCoreStopped": "Core yang dijalankan layanan berhenti secara tak terduga", "stopFailed": "Gagal menghentikan core" }, "clashService": { diff --git a/src/locales/jp/settings.json b/src/locales/jp/settings.json index 45bdc6eae..85233efce 100644 --- a/src/locales/jp/settings.json +++ b/src/locales/jp/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "プロキシモードの変更に失敗しました", "restartFailed": "コアの再起動に失敗しました", "startFailed": "コアの起動に失敗しました", + "serviceCoreStopped": "サービスで実行中のコアが予期せず停止しました", "stopFailed": "コアの停止に失敗しました" }, "clashService": { diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index 46524a20c..aec37f65a 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "프록시 모드를 변경하지 못했습니다", "restartFailed": "코어를 재시작하지 못했습니다", "startFailed": "코어를 시작하지 못했습니다", + "serviceCoreStopped": "서비스에서 실행 중인 코어가 예기치 않게 중지되었습니다", "stopFailed": "코어를 중지하지 못했습니다" }, "clashService": { diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index 5b808b7f4..f567ec32e 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Не удалось изменить режим прокси", "restartFailed": "Не удалось перезапустить ядро", "startFailed": "Не удалось запустить ядро", + "serviceCoreStopped": "Ядро, запущенное службой, неожиданно остановилось", "stopFailed": "Не удалось остановить ядро" }, "clashService": { diff --git a/src/locales/tr/settings.json b/src/locales/tr/settings.json index 52fcad0ff..4c7e9dff3 100644 --- a/src/locales/tr/settings.json +++ b/src/locales/tr/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Proxy modu değiştirilemedi", "restartFailed": "Çekirdek yeniden başlatılamadı", "startFailed": "Çekirdek başlatılamadı", + "serviceCoreStopped": "Hizmetin çalıştırdığı çekirdek beklenmedik şekilde durdu", "stopFailed": "Çekirdek durdurulamadı" }, "clashService": { diff --git a/src/locales/tt/settings.json b/src/locales/tt/settings.json index 5d7476f41..effbd7596 100644 --- a/src/locales/tt/settings.json +++ b/src/locales/tt/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "Прокси режимын үзгәртеп булмады", "restartFailed": "Ядрону яңадан башлап булмады", "startFailed": "Ядрону башлап булмады", + "serviceCoreStopped": "Хезмәт эшләткән ядро көтмәгәндә туктады", "stopFailed": "Ядрону туктатып булмады" }, "clashService": { diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index f6613f085..792b463df 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "切换代理模式失败", "restartFailed": "重启内核失败", "startFailed": "启动内核失败", + "serviceCoreStopped": "服务运行的内核意外停止", "stopFailed": "停止内核失败" }, "clashService": { diff --git a/src/locales/zhtw/settings.json b/src/locales/zhtw/settings.json index e33bf6a30..a942e67fa 100644 --- a/src/locales/zhtw/settings.json +++ b/src/locales/zhtw/settings.json @@ -655,6 +655,7 @@ "modeUpdateFailed": "切換代理模式失敗", "restartFailed": "重啟內核失敗", "startFailed": "啟動內核失敗", + "serviceCoreStopped": "服務執行的內核意外停止", "stopFailed": "停止內核失敗" }, "clashService": { diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/pages/_layout/hooks/use-layout-events.test.ts index 1c2c1bd98..ee8392117 100644 --- a/src/pages/_layout/hooks/use-layout-events.test.ts +++ b/src/pages/_layout/hooks/use-layout-events.test.ts @@ -1,6 +1,7 @@ import { beforeEach, expect, it, vi } from 'vitest' import { + type RunState, getCoreStartupError, takeDnsOverrideNotice, takeServiceRepairNotice, @@ -53,7 +54,10 @@ beforeEach(async () => { }) it('does not replay a recovered startup error after WebView recreation', async () => { - vi.mocked(getCoreStartupError).mockResolvedValue('startup failed') + vi.mocked(getCoreStartupError).mockResolvedValue({ + kind: 'startFailed', + detail: 'startup failed', + }) handleNoticeMessage('core_start::error', '', (key) => key, vi.fn()) await new Promise((resolve) => setTimeout(resolve, 0)) expect(showNotice.error).toHaveBeenCalledOnce() @@ -75,7 +79,10 @@ it.each([ const detail = 'startup failed' nativeWindow.isVisible.mockResolvedValue(visible) nativeWindow.isMinimized.mockResolvedValue(minimized) - vi.mocked(getCoreStartupError).mockResolvedValue(detail) + vi.mocked(getCoreStartupError).mockResolvedValue({ + kind: 'startFailed', + detail, + }) useLayoutEvents(([status, message]) => { handleNoticeMessage(status, message, (key) => key, vi.fn()) }) @@ -99,7 +106,10 @@ it.each([ it('delivers an early core startup failure once after listeners mount', async () => { const detail = 'could not open core execution mutex: access denied' - vi.mocked(getCoreStartupError).mockResolvedValue(detail) + vi.mocked(getCoreStartupError).mockResolvedValue({ + kind: 'startFailed', + detail, + }) useLayoutEvents(([status, message]) => { handleNoticeMessage(status, message, (key) => key, vi.fn()) }) @@ -113,6 +123,24 @@ it('delivers an early core startup failure once after listeners mount', async () ) }) +it('shows the same core failure again after the core has started in between', async () => { + const { useLayoutEvents } = await import('./use-layout-events') + const failure = { kind: 'serviceCoreStopped', detail: 'stopped' } as const + vi.mocked(getCoreStartupError).mockResolvedValue(failure) + useLayoutEvents(([status, message]) => { + handleNoticeMessage(status, message, (key) => key, vi.fn()) + }) + const [handlers] = vi.mocked(subscribeVergeEvents).mock.calls[0] + handlers['verge://notice-message']?.(['core_start::error', '']) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The core started and stopped again before the window handled either event. + handlers['verge://run-state-changed']?.({ mode: 'Service' } as RunState) + handlers['verge://notice-message']?.(['core_start::error', '']) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(showNotice.error).toHaveBeenCalledTimes(2) +}) + it('drains a DNS notice after listeners mount without duplicating its live event', async () => { vi.mocked(takeDnsOverrideNotice) .mockResolvedValueOnce(true) diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index c7800d981..ef95262c2 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -7,6 +7,8 @@ import type { RunState } from '@/services/cmds' import { subscribeVergeEvents } from '@/services/events' import { revalidateQueries, setCacheData } from '@/services/query-client' +import { forgetShownStartupError } from '../utils/notification-handlers' + export const useLayoutEvents = ( handleNotice: (payload: [string, string]) => void, ) => { @@ -58,6 +60,7 @@ export const useLayoutEvents = ( // Transitions carry the full run-state snapshot, so write it directly to cache. 'verge://run-state-changed': (payload) => { void setCacheData(runStateQueryKey, payload) + if (payload.mode !== 'NotRunning') forgetShownStartupError() }, 'verge://notice-message': handleNotice, }, diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index 5fd340e32..e45fb5568 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -15,8 +15,17 @@ type NavigateFunction = (path: string, options?: any) => void type TranslateFunction = (key: string) => string let shownStartupError: string | null = null +// Reads can settle out of order; only the newest may act, and a reset counts as one. +let startupErrorReads = 0 +let settledStartupErrorRead = 0 let shownOwnerNotice: number | null = null +/** A running core has resolved every failure shown before it. */ +export const forgetShownStartupError = () => { + shownStartupError = null + settledStartupErrorRead = ++startupErrorReads +} + export const handleNoticeMessage = ( status: string, msg: string, @@ -39,9 +48,12 @@ export const handleNoticeMessage = ( }, 'set_config::error': () => showNotice.error(msg), 'core_start::error': () => { + const read = ++startupErrorReads void getCoreStartupError() - .then(async (error) => { - if (!error) { + .then(async (failure) => { + if (read < settledStartupErrorRead) return + settledStartupErrorRead = read + if (!failure) { shownStartupError = null return } @@ -50,11 +62,19 @@ export const handleNoticeMessage = ( window.isVisible(), window.isMinimized(), ]) - if (visible && !minimized && shownStartupError !== error) { - shownStartupError = error + const shown = `${failure.kind}:${failure.detail}` + if ( + read === settledStartupErrorRead && + visible && + !minimized && + shownStartupError !== shown + ) { + shownStartupError = shown showNotice.error( - 'settings.feedback.errors.clash.startFailed', - error, + failure.kind === 'serviceCoreStopped' + ? 'settings.feedback.errors.clash.serviceCoreStopped' + : 'settings.feedback.errors.clash.startFailed', + failure.detail, ) } }) diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 62b993997..b2a057913 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -190,8 +190,13 @@ export async function takeServiceFallbackNotice() { return invoke('take_service_fallback_notice') } +export interface CoreFailure { + kind: 'startFailed' | 'serviceCoreStopped' + detail: string +} + export async function getCoreStartupError() { - return invoke('get_core_startup_error') + return invoke('get_core_startup_error') } export async function takeServiceRepairNotice() { diff --git a/src/types/generated/i18n-keys.ts b/src/types/generated/i18n-keys.ts index 75e5b7cad..ee97efbc9 100644 --- a/src/types/generated/i18n-keys.ts +++ b/src/types/generated/i18n-keys.ts @@ -785,6 +785,7 @@ export const translationKeys = [ 'settings.feedback.errors.clash.modeUpdateFailed', 'settings.feedback.errors.clash.restartFailed', 'settings.feedback.errors.clash.startFailed', + 'settings.feedback.errors.clash.serviceCoreStopped', 'settings.feedback.errors.clash.stopFailed', 'settings.feedback.errors.clashService.installFailed', 'settings.feedback.errors.clashService.uninstallFailed', diff --git a/src/types/generated/i18n-resources.ts b/src/types/generated/i18n-resources.ts index 98206d4e4..5d71ad7b1 100644 --- a/src/types/generated/i18n-resources.ts +++ b/src/types/generated/i18n-resources.ts @@ -815,6 +815,7 @@ export interface TranslationResources { configUpdateFailed: string modeUpdateFailed: string restartFailed: string + serviceCoreStopped: string startFailed: string stopFailed: string }