diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 592df0417..f081c4efd 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -141,6 +141,13 @@ pub fn take_service_repair_notice() -> bool { crate::core::service::take_service_repair_notice() } +#[tauri::command] +#[specta::specta] +#[cfg_attr(not(target_os = "linux"), allow(clippy::missing_const_for_fn))] +pub fn take_service_upgrade_state_notice() -> Option { + crate::core::service::take_service_upgrade_state_notice() +} + #[tauri::command] #[specta::specta] pub fn take_service_owner_notice() -> Option { diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index c1bc9bab4..f27b71384 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -42,6 +42,8 @@ static ACTIVE_SERVICE_SESSION: Mutex> = Mutex::new( static PENDING_SERVICE_FALLBACK_NOTICE: Mutex> = Mutex::new(None); static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false); static PENDING_SERVICE_OWNER_NOTICE: Mutex> = Mutex::new(None); +#[cfg(target_os = "linux")] +static PENDING_SERVICE_UPGRADE: Mutex> = Mutex::new(None); /// Why the Service was unavailable when the core fell back to Sidecar. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)] @@ -93,6 +95,17 @@ pub(crate) async fn notify_incomplete_service_upgrade() { } let status = SERVICE_MANAGER.current().await; let notice = service_upgrade_notice(&marker, matches!(status, ServiceStatus::Ready)); + if !notice.reinstall && notice.unrestored_state.is_none() { + acknowledge_service_upgrade(&ack, id); + return; + } + // Without a window (silent start) the events are dropped; the frontend replays them on subscription. + *PENDING_SERVICE_UPGRADE.lock() = Some(PendingServiceUpgrade { + ack, + id: id.to_owned(), + reinstall: notice.reinstall, + unrestored_state: notice.unrestored_state.map(str::to_owned), + }); if notice.reinstall { logging!(warn, Type::Service, "安装包升级后服务未就绪: {status:?}"); *PENDING_SERVICE_FALLBACK_NOTICE.lock() = Some(SERVICE_UPGRADE_INCOMPLETE.to_owned()); @@ -100,13 +113,51 @@ pub(crate) async fn notify_incomplete_service_upgrade() { } if let Some(state) = notice.unrestored_state { logging!(warn, Type::Service, "安装包升级后未能恢复服务原状态: {state}"); - Handle::notice(NoticeStatus::ServiceCoreUpgradeStateNotRestored, state); + Handle::notice(NoticeStatus::ServiceCoreUpgradeStateNotRestored, ""); } - if let Err(error) = std::fs::write(&ack, id) { +} + +#[cfg(target_os = "linux")] +fn acknowledge_service_upgrade(ack: &Path, id: &str) { + if let Err(error) = std::fs::write(ack, id) { logging!(warn, Type::Service, "无法记录服务升级确认: {error}"); } } +/// An upgrade whose notices are acknowledged on disk only after the frontend has taken all of them. +#[cfg(target_os = "linux")] +struct PendingServiceUpgrade { + ack: PathBuf, + id: String, + reinstall: bool, + unrestored_state: Option, +} + +#[cfg(target_os = "linux")] +fn settle_service_upgrade(take: impl FnOnce(&mut PendingServiceUpgrade) -> T) -> Option { + let (taken, settled) = { + let mut pending = PENDING_SERVICE_UPGRADE.lock(); + let upgrade = pending.as_mut()?; + let taken = take(upgrade); + let settled = !upgrade.reinstall && upgrade.unrestored_state.is_none(); + (taken, if settled { pending.take() } else { None }) + }; + if let Some(upgrade) = settled { + acknowledge_service_upgrade(&upgrade.ack, &upgrade.id); + } + Some(taken) +} + +#[cfg(target_os = "linux")] +pub(crate) fn take_service_upgrade_state_notice() -> Option { + settle_service_upgrade(|upgrade| upgrade.unrestored_state.take()).flatten() +} + +#[cfg(not(target_os = "linux"))] +pub(crate) const fn take_service_upgrade_state_notice() -> Option { + None +} + #[cfg(target_os = "linux")] #[derive(Debug, PartialEq, Eq)] struct ServiceUpgradeNotice<'a> { @@ -126,6 +177,8 @@ fn service_upgrade_notice(marker: &str, ready: bool) -> ServiceUpgradeNotice<'_> pub(crate) fn take_service_fallback_notice() -> Option { let reason = PENDING_SERVICE_FALLBACK_NOTICE.lock().take()?; Some(if reason == SERVICE_UPGRADE_INCOMPLETE { + #[cfg(target_os = "linux")] + settle_service_upgrade(|upgrade| upgrade.reinstall = false); ServiceFallbackNotice::UpgradeIncomplete } else if reason.starts_with(CORE_REJECTED_PREFIX) { ServiceFallbackNotice::CoreRejected(reason) @@ -2183,13 +2236,16 @@ pub static SERVICE_MANAGER: ServiceManager = ServiceManager; #[cfg(test)] #[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] mod tests { + #[cfg(target_os = "linux")] + use super::{ + PENDING_SERVICE_UPGRADE, PendingServiceUpgrade, ServiceUpgradeNotice, service_upgrade_notice, + settle_service_upgrade, take_service_upgrade_state_notice, + }; use super::{ ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation, generate_service_session_token, mark_service_unavailable_after_owner_loss, owner_recovery_policy, service_core_path_for, session_matches_status, }; - #[cfg(target_os = "linux")] - use super::{ServiceUpgradeNotice, service_upgrade_notice}; #[cfg(unix)] use super::{service_core_path_for_with_publisher, service_tool_path_for}; use crate::core::runstate::{FakeEnv, OwnerRecoveryReason, PendingAction, RunStateStore}; @@ -2868,4 +2924,26 @@ mod tests { notice(true, Some("disabled,active")) ); } + + #[cfg(target_os = "linux")] + #[test] + fn upgrade_marker_is_acknowledged_only_after_every_notice_is_taken() { + let ack = std::env::temp_dir().join(format!("verge-service-upgrade-ack-{}", std::process::id())); + let _ = std::fs::remove_file(&ack); + *PENDING_SERVICE_UPGRADE.lock() = Some(PendingServiceUpgrade { + ack: ack.clone(), + id: "7".to_owned(), + reinstall: true, + unrestored_state: Some("disabled,active".to_owned()), + }); + + assert_eq!(take_service_upgrade_state_notice().as_deref(), Some("disabled,active")); + assert!(!ack.exists()); + assert_eq!(take_service_upgrade_state_notice(), None); + + settle_service_upgrade(|upgrade| upgrade.reinstall = false); + assert_eq!(std::fs::read_to_string(&ack).ok().as_deref(), Some("7")); + assert!(PENDING_SERVICE_UPGRADE.lock().is_none()); + let _ = std::fs::remove_file(&ack); + } } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 334ef1ccb..24d5dfdf0 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -56,6 +56,7 @@ pub fn builder() -> tauri_specta::Builder { cmd::get_core_startup_error, cmd::take_service_repair_notice, cmd::take_service_owner_notice, + cmd::take_service_upgrade_state_notice, cmd::take_discarded_keys_notice, cmd::get_dns_config_content, cmd::validate_dns_config, diff --git a/src/services/bindings.ts b/src/services/bindings.ts index 1db027d5b..f08e1951f 100644 --- a/src/services/bindings.ts +++ b/src/services/bindings.ts @@ -111,6 +111,8 @@ export const commands = { __TAURI_INVOKE('take_service_repair_notice'), takeServiceOwnerNotice: () => __TAURI_INVOKE('take_service_owner_notice'), + takeServiceUpgradeStateNotice: () => + __TAURI_INVOKE('take_service_upgrade_state_notice'), takeDiscardedKeysNotice: () => __TAURI_INVOKE('take_discarded_keys_notice'), getDnsConfigContent: () => diff --git a/src/services/bus/create-bus-handlers.test.ts b/src/services/bus/create-bus-handlers.test.ts index 844e40d33..5f394c321 100644 --- a/src/services/bus/create-bus-handlers.test.ts +++ b/src/services/bus/create-bus-handlers.test.ts @@ -6,6 +6,7 @@ import { takeDnsOverrideNotice, takeServiceFallbackNotice, takeServiceRepairNotice, + takeServiceUpgradeStateNotice, } from '@/services/cmds' import { showNotice } from '@/services/notice-service' import { revalidateQueries } from '@/services/query-client' @@ -27,6 +28,7 @@ vi.mock('@/services/cmds', () => ({ takeServiceFallbackNotice: vi.fn().mockResolvedValue(null), takeServiceRepairNotice: vi.fn().mockResolvedValue(false), takeServiceOwnerNotice: vi.fn().mockResolvedValue(null), + takeServiceUpgradeStateNotice: vi.fn().mockResolvedValue(null), })) vi.mock('@/services/service-request', () => ({ requestService: vi.fn() })) vi.mock('@/services/notice-service', () => ({ @@ -198,3 +200,23 @@ it('explains a startup core rejection instead of the generic fallback notice', a 0, ) }) + +it('replays a pending Service upgrade state warning once the frontend subscribes', async () => { + vi.mocked(takeServiceUpgradeStateNotice) + .mockResolvedValueOnce('disabled,active') + .mockResolvedValue(null) + + const bus = await mountBus() + bus.onSubscribed() + bus.handlers['verge://notice-message']([ + 'service_core::upgrade_state_not_restored', + '', + ]) + await flush() + + expect(showNotice.warning).toHaveBeenCalledExactlyOnceWith( + 'settings.feedback.notifications.clashService.upgradeStateNotRestored', + { state: 'disabled,active' }, + 0, + ) +}) diff --git a/src/services/bus/create-bus-handlers.ts b/src/services/bus/create-bus-handlers.ts index c41442704..59f0c045c 100644 --- a/src/services/bus/create-bus-handlers.ts +++ b/src/services/bus/create-bus-handlers.ts @@ -105,6 +105,7 @@ export const createEventBus = (deps: BusHandlerDeps) => { deps.handleNotice(['service_core::sidecar_fallback', '']) deps.handleNotice(['service_core::repair_required', '']) deps.handleNotice(['service_core::app_data_not_owned', '']) + deps.handleNotice(['service_core::upgrade_state_not_restored', '']) deps.handleNotice(['core_start::error', '']) } diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 6b118d34a..ddc48ba64 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -248,6 +248,10 @@ export async function takeServiceOwnerNotice() { return commands.takeServiceOwnerNotice() } +export async function takeServiceUpgradeStateNotice() { + return commands.takeServiceUpgradeStateNotice() +} + export async function takeDiscardedKeysNotice() { return commands.takeDiscardedKeysNotice() } diff --git a/src/services/notice-handlers.ts b/src/services/notice-handlers.ts index e9051ce38..19dbf3a29 100644 --- a/src/services/notice-handlers.ts +++ b/src/services/notice-handlers.ts @@ -7,6 +7,7 @@ import { takeServiceFallbackNotice, takeServiceOwnerNotice, takeServiceRepairNotice, + takeServiceUpgradeStateNotice, } from '@/services/cmds' import type { NoticeStatus } from '@/services/contract' import { hideNotice, showNotice } from '@/services/notice-service' @@ -140,12 +141,24 @@ const handlers: Record< ) }) }, - 'service_core::upgrade_state_not_restored': (msg) => - showNotice.warning( - 'settings.feedback.notifications.clashService.upgradeStateNotRestored', - { state: msg }, - 0, - ), + 'service_core::upgrade_state_not_restored': () => { + void takeServiceUpgradeStateNotice() + .then((state) => { + if (state) { + showNotice.warning( + 'settings.feedback.notifications.clashService.upgradeStateNotRestored', + { state }, + 0, + ) + } + }) + .catch((error) => { + console.error( + 'Failed to read the pending service upgrade state notice', + error, + ) + }) + }, 'dns_override::auto_disabled': () => { void takeDnsOverrideNotice() .then((pending) => {