mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-15 05:40:47 +08:00
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:
@@ -15,6 +15,8 @@
|
||||
|
||||
**🖥️ Windows**
|
||||
|
||||
- 修复 Windows 部分未安装服务的用户以普通权限运行时,内核无法启动的问题
|
||||
- 修复 Windows 系统隔离权限被误判,导致服务模式和 TUN 无法使用的问题
|
||||
- 修复 Windows 旧版服务状态残留导致无法重装的问题,自动备份可识别的残留并恢复安装
|
||||
- 优化 Windows 服务安全检查失败提示:用易懂的文字说明原因,并提供修复文档链接
|
||||
- 修复 Windows 服务因路径或目录权限问题无法启动时,缺少重新安装服务的提示
|
||||
@@ -33,6 +35,7 @@
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
- 优化内核启动失败提示:显示具体原因,并在窗口恢复后提示尚未解决的启动错误
|
||||
- 优化扩展覆写配置和脚本编辑窗口,提示作用范围、执行顺序及应用设置优先级,并高亮当前步骤
|
||||
- 优化 DNS 覆写开关:按订阅独立记忆,自动关闭仅影响当前订阅,并提示作用范围
|
||||
- 优化系统服务兼容性检查:升级后服务版本或协议不兼容时,提示重新安装
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user