From 61c33c6113b9147a2523dd207bfe6ef393567ef9 Mon Sep 17 00:00:00 2001 From: wonfen Date: Thu, 24 Sep 2026 18:42:00 +0800 Subject: [PATCH] fix(core): retain startup diagnostics until recovery Show unresolved startup failures when the window becomes visible and clear them when either core mode starts successfully. Cover recovery followed by exit and WebView recreation, use neutral service permission wording, and document the Windows startup fix. --- Changelog.md | 3 + src-tauri/src/cmd/service.rs | 5 + src-tauri/src/core/manager/mod.rs | 40 ++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/utils/resolve/mod.rs | 2 + src/components/layout/notice-manager.tsx | 5 +- .../_layout/hooks/use-layout-events.test.ts | 91 +++++++++++++++++-- src/pages/_layout/hooks/use-layout-events.ts | 11 ++- .../_layout/utils/notification-handlers.ts | 29 ++++++ src/services/cmds.ts | 4 + 10 files changed, 180 insertions(+), 11 deletions(-) diff --git a/Changelog.md b/Changelog.md index e9e5d8e66..38612d3f1 100644 --- a/Changelog.md +++ b/Changelog.md @@ -15,6 +15,8 @@ **🖥️ Windows** +- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题 +- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题 - 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装 - 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接 - 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示 @@ -33,6 +35,7 @@
🚀 优化改进 +- 优化内核启动失败提示:显示具体原因,并在窗口恢复后提示尚未解决的启动错误 - 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤 - 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围 - 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装 diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 073bfbf19..8abf59eb0 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -114,6 +114,11 @@ pub fn take_service_fallback_notice() -> bool { crate::core::service::take_service_fallback_notice() } +#[tauri::command] +pub fn get_core_startup_error() -> Option { + crate::core::CoreManager::global().get_startup_error() +} + #[tauri::command] pub fn take_service_repair_notice() -> bool { crate::core::service::take_service_repair_notice() diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index db808db77..4af3acdd6 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -87,6 +87,7 @@ pub struct CoreManager { job_handle: ArcSwapOption, config_update_in_progress: AtomicBool, core_readiness_state: AtomicU64, + startup_error: parking_lot::Mutex>, sidecar_exit: tokio::sync::Mutex>>, // 串行化 start/stop/restart 和 sidecar→service 交接。 // 锁序固定为 config_update_in_progress → lifecycle_lock。 @@ -115,6 +116,7 @@ impl Default for CoreManager { job_handle: ArcSwapOption::new(None), config_update_in_progress: AtomicBool::new(false), core_readiness_state: AtomicU64::new(0), + startup_error: parking_lot::Mutex::new(None), sidecar_exit: tokio::sync::Mutex::new(None), lifecycle_lock: tokio::sync::Mutex::new(()), #[cfg(target_os = "windows")] @@ -141,6 +143,17 @@ impl CoreManager { self.run_state.mode_arc() } + pub(crate) fn record_startup_error(&self, error: String) { + let mut startup_error = self.startup_error.lock(); + if matches!(*self.get_running_mode(), RunningMode::NotRunning) { + *startup_error = Some(error); + } + } + + pub(crate) fn get_startup_error(&self) -> Option { + self.startup_error.lock().clone() + } + pub fn take_child_sidecar(&self) -> Option { self.state .load() @@ -166,7 +179,9 @@ impl CoreManager { if previous != mode { logging!(info, Type::Core, "Core running mode changed: {previous} -> {mode}"); } + let mut startup_error = self.startup_error.lock(); self.run_state.core_started(mode); + *startup_error = None; } /// The Core is no longer running, for any reason. @@ -307,3 +322,28 @@ impl CoreManager { } singleton!(CoreManager, CORE_MANAGER); + +#[cfg(test)] +mod startup_error_tests { + use super::{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.core_started(mode); + manager.core_stopped(); + assert_eq!(manager.get_startup_error(), None); + } + } + + #[test] + fn unresolved_startup_error_survives_repeated_reads() { + let manager = CoreManager::isolated(); + manager.record_startup_error("startup failed".into()); + for _ in 0..2 { + assert_eq!(manager.get_startup_error().as_deref(), Some("startup failed")); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55886fe1f..ec3c6ee7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -171,6 +171,7 @@ mod app_init { cmd::set_dns_override, cmd::take_dns_override_notice, cmd::take_service_fallback_notice, + cmd::get_core_startup_error, cmd::take_service_repair_notice, cmd::take_discarded_keys_notice, cmd::get_dns_config_content, diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index ff20b1c97..790a4a371 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -221,6 +221,8 @@ 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:#}")); + Handle::notice_message("core_start::error", ""); false } } diff --git a/src/components/layout/notice-manager.tsx b/src/components/layout/notice-manager.tsx index c2547affd..a032a994c 100644 --- a/src/components/layout/notice-manager.tsx +++ b/src/components/layout/notice-manager.tsx @@ -67,14 +67,11 @@ const resolveNoticeMessage = ( if (!i18n) return bound(notice.message) if (i18n.key === SERVICE_PERMISSION_NOTICE) { - const reason = i18n.params?.reason return ( <> {t(SERVICE_PERMISSION_NOTICE, { reason: t( - typeof reason === 'string' && reason.includes('is writable by') - ? 'settings.feedback.notifications.clashService.permissionWritableReason' - : 'settings.feedback.notifications.clashService.permissionRejectedReason', + 'settings.feedback.notifications.clashService.permissionRejectedReason', ), })} diff --git a/src/pages/_layout/hooks/use-layout-events.test.ts b/src/pages/_layout/hooks/use-layout-events.test.ts index 1d048bfa7..e3a6e2b6f 100644 --- a/src/pages/_layout/hooks/use-layout-events.test.ts +++ b/src/pages/_layout/hooks/use-layout-events.test.ts @@ -1,36 +1,115 @@ import { beforeEach, expect, it, vi } from 'vitest' -import { takeDnsOverrideNotice, takeServiceRepairNotice } from '@/services/cmds' +import { + getCoreStartupError, + takeDnsOverrideNotice, + takeServiceRepairNotice, +} from '@/services/cmds' import { subscribeVergeEvents } from '@/services/events' import { showNotice } from '@/services/notice-service' import { revalidateQueries } from '@/services/query-client' import { requestService } from '@/services/service-request' -import { handleNoticeMessage } from '../utils/notification-handlers' - import { useLayoutEvents } from './use-layout-events' +let handleNoticeMessage: typeof import('../utils/notification-handlers').handleNoticeMessage + +const nativeWindow = vi.hoisted(() => ({ + isVisible: vi.fn().mockResolvedValue(true), + isMinimized: vi.fn().mockResolvedValue(false), + onFocusChanged: vi.fn().mockResolvedValue(() => {}), +})) +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => nativeWindow, +})) 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', () => ({ + getCoreStartupError: vi.fn().mockResolvedValue(null), takeDiscardedKeysNotice: vi.fn().mockResolvedValue(null), - takeDnsOverrideNotice: vi.fn(), + takeDnsOverrideNotice: vi.fn().mockResolvedValue(false), takeServiceFallbackNotice: vi.fn().mockResolvedValue(false), takeServiceRepairNotice: vi.fn().mockResolvedValue(false), })) vi.mock('@/services/service-request', () => ({ requestService: vi.fn() })) vi.mock('@/services/notice-service', () => ({ - showNotice: { info: vi.fn() }, + showNotice: { info: vi.fn(), error: vi.fn() }, })) vi.mock('@/services/query-client', () => ({ revalidateQueries: vi.fn(), setCacheData: vi.fn(), })) -beforeEach(() => { +beforeEach(async () => { + vi.resetModules() + ;({ handleNoticeMessage } = await import('../utils/notification-handlers')) vi.clearAllMocks() + vi.mocked(getCoreStartupError).mockResolvedValue(null) + nativeWindow.isVisible.mockResolvedValue(true) + nativeWindow.isMinimized.mockResolvedValue(false) +}) + +it('does not replay a recovered startup error after WebView recreation', async () => { + vi.mocked(getCoreStartupError).mockResolvedValue('startup failed') + handleNoticeMessage('core_start::error', '', (key) => key, vi.fn()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(showNotice.error).toHaveBeenCalledOnce() + + vi.mocked(getCoreStartupError).mockResolvedValue(null) + vi.resetModules() + const recreated = await import('../utils/notification-handlers') + recreated.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(showNotice.error).toHaveBeenCalledOnce() +}) + +it.each([ + ['hidden', false, false], + ['minimized', true, true], +] as const)( + 'keeps a %s-window startup error until the window is restored', + async (_state, visible, minimized) => { + const detail = 'startup failed' + nativeWindow.isVisible.mockResolvedValue(visible) + nativeWindow.isMinimized.mockResolvedValue(minimized) + vi.mocked(getCoreStartupError).mockResolvedValue(detail) + useLayoutEvents(([status, message]) => { + handleNoticeMessage(status, message, (key) => key, vi.fn()) + }) + const [handlers, onSubscribed] = + vi.mocked(subscribeVergeEvents).mock.calls[0] + onSubscribed?.() + handlers['verge://notice-message']?.(['core_start::error', '']) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(showNotice.error).not.toHaveBeenCalled() + nativeWindow.isVisible.mockResolvedValue(true) + nativeWindow.isMinimized.mockResolvedValue(false) + const onFocus = nativeWindow.onFocusChanged.mock.calls[0][0] + onFocus({ payload: true }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(showNotice.error).toHaveBeenCalledExactlyOnceWith( + 'settings.feedback.errors.clash.startFailed', + detail, + ) + }, +) + +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) + useLayoutEvents(([status, message]) => { + handleNoticeMessage(status, message, (key) => key, vi.fn()) + }) + const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0] + onSubscribed?.() + handlers['verge://notice-message']?.(['core_start::error', '']) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(showNotice.error).toHaveBeenCalledExactlyOnceWith( + 'settings.feedback.errors.clash.startFailed', + detail, + ) }) it('drains a DNS notice after listeners mount without duplicating its live event', async () => { diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index 0ac5e858d..7b5836791 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -1,3 +1,4 @@ +import { getCurrentWindow } from '@tauri-apps/api/window' import { useEffect } from 'react' import { revalidateProfiles } from '@/hooks/use-profiles' @@ -31,7 +32,7 @@ export const useLayoutEvents = ( void revalidateProfiles() } - return subscribeVergeEvents( + const unsubscribe = subscribeVergeEvents( { 'profile-changed': handleProfileChanged, 'verge://refresh-profiles': () => void revalidateProfiles(), @@ -67,7 +68,15 @@ export const useLayoutEvents = ( handleNotice(['enhance::discarded_keys', '']) handleNotice(['service_core::sidecar_fallback', '']) handleNotice(['service_core::repair_required', '']) + handleNotice(['core_start::error', '']) }, ) + const unlistenFocus = getCurrentWindow().onFocusChanged(({ payload }) => { + if (payload) handleNotice(['core_start::error', '']) + }) + return () => { + unsubscribe() + void unlistenFocus.then((unlisten) => unlisten()) + } }, [handleNotice]) } diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index 4baae3895..05c903edd 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -1,4 +1,7 @@ +import { getCurrentWindow } from '@tauri-apps/api/window' + import { + getCoreStartupError, takeDiscardedKeysNotice, takeDnsOverrideNotice, takeServiceFallbackNotice, @@ -10,6 +13,8 @@ import { requestService } from '@/services/service-request' type NavigateFunction = (path: string, options?: any) => void type TranslateFunction = (key: string) => string +let shownStartupError: string | null = null + export const handleNoticeMessage = ( status: string, msg: string, @@ -31,6 +36,30 @@ export const handleNoticeMessage = ( showNotice.error(msg) }, 'set_config::error': () => showNotice.error(msg), + 'core_start::error': () => { + void getCoreStartupError() + .then(async (error) => { + if (!error) { + shownStartupError = null + return + } + const window = getCurrentWindow() + const [visible, minimized] = await Promise.all([ + window.isVisible(), + window.isMinimized(), + ]) + if (visible && !minimized && shownStartupError !== error) { + shownStartupError = error + showNotice.error( + 'settings.feedback.errors.clash.startFailed', + error, + ) + } + }) + .catch((error) => { + console.error('Failed to read the pending core startup error', error) + }) + }, 'service_core::repair_required': () => { void takeServiceRepairNotice() .then((pending) => { diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 0d82f19ba..974a6fdde 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -190,6 +190,10 @@ export async function takeServiceFallbackNotice() { return invoke('take_service_fallback_notice') } +export async function getCoreStartupError() { + return invoke('get_core_startup_error') +} + export async function takeServiceRepairNotice() { return invoke('take_service_repair_notice') }