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.
This commit is contained in:
wonfen
2026-09-24 18:43:28 +08:00
parent ae4f416766
commit 61c33c6113
10 changed files with 180 additions and 11 deletions
+3
View File
@@ -15,6 +15,8 @@
**🖥️ Windows**
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
@@ -33,6 +35,7 @@
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
- 优化内核启动失败提示:显示具体原因,并在窗口恢复后提示尚未解决的启动错误
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
+5
View File
@@ -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<String> {
crate::core::CoreManager::global().get_startup_error()
}
#[tauri::command]
pub fn take_service_repair_notice() -> bool {
crate::core::service::take_service_repair_notice()
+40
View File
@@ -87,6 +87,7 @@ pub struct CoreManager {
job_handle: ArcSwapOption<OwnedHandle>,
config_update_in_progress: AtomicBool,
core_readiness_state: AtomicU64,
startup_error: parking_lot::Mutex<Option<String>>,
sidecar_exit: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
// 串行化 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<String> {
self.startup_error.lock().clone()
}
pub fn take_child_sidecar(&self) -> Option<CommandChild> {
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"));
}
}
}
+1
View File
@@ -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,
+2
View File
@@ -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
}
}
+1 -4
View File
@@ -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',
),
})}
<Box sx={{ mt: 1 }}>
@@ -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 () => {
+10 -1
View File
@@ -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])
}
@@ -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) => {
+4
View File
@@ -190,6 +190,10 @@ export async function takeServiceFallbackNotice() {
return invoke<boolean>('take_service_fallback_notice')
}
export async function getCoreStartupError() {
return invoke<string | null>('get_core_startup_error')
}
export async function takeServiceRepairNotice() {
return invoke<boolean>('take_service_repair_notice')
}