fix(service): explain a startup core rejection instead of the generic fallback

When the startup probe finds the service refusing the approved core, the
app falls back to Sidecar with only the generic "could not start the
core" notice. The reason and repair guide appeared only after a
reinstall, which cannot fix a writable volume root (#8045).

Carry the fallback reason to the window and show the same permission
notice as a rejected installation when the core was refused.

Refs #8045
This commit is contained in:
wonfen
2026-10-02 10:38:09 +08:00
parent 8ddd99f226
commit 52d616302c
9 changed files with 71 additions and 17 deletions
+4
View File
@@ -22,4 +22,8 @@
<summary><strong> 🚀 优化改进 </strong></summary>
**🖥️ Windows**
- 优化 Windows 服务安全检查未通过时的启动提示:显示具体原因,并提供修复文档链接
</details>
+1 -1
View File
@@ -113,7 +113,7 @@ pub async fn continue_with_sidecar() -> CmdResult {
}
#[tauri::command]
pub fn take_service_fallback_notice() -> bool {
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
crate::core::service::take_service_fallback_notice()
}
+1 -1
View File
@@ -678,7 +678,7 @@ impl CoreManager {
SERVICE_MANAGER.withdraw_sidecar_allowance();
self.rollback_failed_start().await;
} else {
crate::core::service::notify_service_fallback();
crate::core::service::notify_service_fallback(&reason);
}
result.map_err(|error| error.context(format!("Service failure before Sidecar startup: {reason}")))
}
+1 -1
View File
@@ -28,7 +28,7 @@ pub use env::FakeEnv;
pub use env::{RealEnv, RunStateEnv};
pub use health::{PendingAction, RunState, RunStateView, ServiceHealth};
pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch};
pub use probe::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
pub use probe::{CORE_REJECTED_PREFIX, ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply};
use crate::core::manager::RunningMode;
use health::StoredService;
+4 -1
View File
@@ -27,6 +27,9 @@ pub enum ServiceVersionCheck {
CoreUnavailable(String),
}
/// Leads the Unavailable reason when the Service refused the approved core itself.
pub const CORE_REJECTED_PREFIX: &str = "approved core was rejected: ";
/// What a live probe of the currently-installed Service told us.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurrentServiceProbe {
@@ -47,7 +50,7 @@ pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVer
return match &reply.core {
Some(clash_verge_service_ipc::CoreAvailability::Ready) => ServiceVersionCheck::Ready,
Some(clash_verge_service_ipc::CoreAvailability::Rejected { reason }) => {
ServiceVersionCheck::CoreUnavailable(format!("approved core was rejected: {reason}"))
ServiceVersionCheck::CoreUnavailable(format!("{CORE_REJECTED_PREFIX}{reason}"))
}
_ => ServiceVersionCheck::CoreUnavailable(
"the selected approved core is missing or does not match; reinstall the service to repair it".into(),
+21 -8
View File
@@ -9,8 +9,8 @@ use crate::{
owner_identity::current_owner_credentials,
proxy_control,
runstate::{
OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE, ReadyWaitError,
RunState, RunStateEnv, RunStateStore, ServiceHealth,
CORE_REJECTED_PREFIX, OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE,
ReadyWaitError, RunState, RunStateEnv, RunStateStore, ServiceHealth,
},
runtime_bundle::{RemoteProviderRef, collect_runtime_bundle, remote_providers_of},
tray::Tray,
@@ -39,18 +39,31 @@ use std::{
static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0);
static ACTIVE_SERVICE_SESSION: Lazy<Mutex<Option<ActiveServiceSession>>> = Lazy::new(|| Mutex::new(None));
static PENDING_SERVICE_FALLBACK_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_FALLBACK_NOTICE: Mutex<Option<String>> = Mutex::new(None);
static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false);
static PENDING_SERVICE_OWNER_NOTICE: Mutex<Option<String>> = Mutex::new(None);
/// Why the Service was unavailable when the core fell back to Sidecar.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "kind", content = "reason", rename_all = "camelCase")]
pub enum ServiceFallbackNotice {
Unavailable,
CoreRejected(String),
}
#[cfg(target_os = "windows")]
pub(crate) fn notify_service_fallback() {
PENDING_SERVICE_FALLBACK_NOTICE.store(true, Ordering::Relaxed);
pub(crate) fn notify_service_fallback(reason: &str) {
*PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(reason.to_owned());
Handle::notice_message("service_core::sidecar_fallback", "");
}
pub(crate) fn take_service_fallback_notice() -> bool {
PENDING_SERVICE_FALLBACK_NOTICE.swap(false, Ordering::Relaxed)
pub(crate) fn take_service_fallback_notice() -> Option<ServiceFallbackNotice> {
let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?;
Some(if reason.starts_with(CORE_REJECTED_PREFIX) {
ServiceFallbackNotice::CoreRejected(reason)
} else {
ServiceFallbackNotice::Unavailable
})
}
pub(crate) fn take_service_repair_notice() -> bool {
@@ -919,7 +932,7 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()
// PAC follows the Running Mode; the caller opens it via `core_started(Service)`.
start_owner_monitor();
tracing::Span::current().record("outcome", "started");
PENDING_SERVICE_FALLBACK_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_FALLBACK_NOTICE.lock().take();
PENDING_SERVICE_REPAIR_NOTICE.store(false, Ordering::Relaxed);
PENDING_SERVICE_OWNER_NOTICE.lock().take();
logging!(
@@ -4,6 +4,7 @@ import {
type RunState,
getCoreStartupError,
takeDnsOverrideNotice,
takeServiceFallbackNotice,
takeServiceRepairNotice,
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
@@ -31,13 +32,13 @@ vi.mock('@/services/cmds', () => ({
getCoreStartupError: vi.fn().mockResolvedValue(null),
takeDiscardedKeysNotice: vi.fn().mockResolvedValue(null),
takeDnsOverrideNotice: vi.fn().mockResolvedValue(false),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(false),
takeServiceFallbackNotice: vi.fn().mockResolvedValue(null),
takeServiceRepairNotice: vi.fn().mockResolvedValue(false),
takeServiceOwnerNotice: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/services/service-request', () => ({ requestService: vi.fn() }))
vi.mock('@/services/notice-service', () => ({
showNotice: { info: vi.fn(), error: vi.fn() },
showNotice: { info: vi.fn(), error: vi.fn(), warning: vi.fn() },
}))
vi.mock('@/services/query-client', () => ({
revalidateQueries: vi.fn(),
@@ -184,3 +185,26 @@ it('offers service reinstallation for a startup path refusal even before listene
reason: 'serviceLocationRefused',
})
})
it('explains a startup core rejection instead of the generic fallback notice', async () => {
const reason =
'approved core was rejected: core path "C:\\" has an untrusted write ACE'
vi.mocked(takeServiceFallbackNotice)
.mockResolvedValueOnce({ kind: 'coreRejected', reason })
.mockResolvedValue(null)
useLayoutEvents(([status, message]) => {
handleNoticeMessage(status, message, (key) => key, vi.fn())
})
const [handlers, onSubscribed] = vi.mocked(subscribeVergeEvents).mock.calls[0]
onSubscribed?.()
handlers['verge://notice-message']?.(['service_core::sidecar_fallback', ''])
await new Promise((resolve) => setTimeout(resolve, 0))
expect(showNotice.warning).toHaveBeenCalledExactlyOnceWith(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason },
0,
)
})
@@ -117,8 +117,14 @@ export const handleNoticeMessage = (
},
'service_core::sidecar_fallback': () => {
void takeServiceFallbackNotice()
.then((pending) => {
if (pending) {
.then((notice) => {
if (notice?.kind === 'coreRejected') {
showNotice.warning(
'settings.feedback.notifications.clashService.permissionFallback',
{ reason: notice.reason },
0,
)
} else if (notice) {
showNotice.warning(
'settings.feedback.notifications.clashService.sidecarFallback',
)
+5 -1
View File
@@ -186,8 +186,12 @@ export async function takeDnsOverrideNotice() {
return invoke<boolean>('take_dns_override_notice')
}
export type ServiceFallbackNotice =
| { kind: 'unavailable' }
| { kind: 'coreRejected'; reason: string }
export async function takeServiceFallbackNotice() {
return invoke<boolean>('take_service_fallback_notice')
return invoke<ServiceFallbackNotice | null>('take_service_fallback_notice')
}
export interface CoreFailure {