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') }