diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs index 895dfed1..db48026a 100644 --- a/crates/punktfunk-host/src/update.rs +++ b/crates/punktfunk-host/src/update.rs @@ -584,11 +584,12 @@ enum PostApply { /// apply. Called once from `mgmt::run` before the API serves. pub(crate) fn reconcile_at_boot() { let path = jobs::intent_path(); - match jobs::reconcile( - jobs::read_intent(&path), - env!("PUNKTFUNK_VERSION"), - now_unix(), - ) { + let intent = jobs::read_intent(&path); + // Read off the intent BEFORE `reconcile` consumes it. Restored on both terminal outcomes: a + // rolled-back or aborted install killed the tray just as thoroughly as a successful one. NOT on + // StillApplying — the installer may still be running and would only kill it again. + let restore_tray = intent.as_ref().is_some_and(|i| i.tray_was_running); + match jobs::reconcile(intent, env!("PUNKTFUNK_VERSION"), now_unix()) { jobs::Reconciled::None | jobs::Reconciled::StillApplying => {} jobs::Reconciled::Success(record) => { tracing::info!(from = %record.from, to = %record.to, "host update applied"); @@ -610,6 +611,12 @@ pub(crate) fn reconcile_at_boot() { let _ = std::fs::remove_file(&path); } } + #[cfg(target_os = "windows")] + if restore_tray { + windows::relaunch_tray(); + } + #[cfg(not(target_os = "windows"))] + let _ = restore_tray; // the Linux packages never kill a running tray } /// What status hands to the API layer. diff --git a/crates/punktfunk-host/src/update/jobs.rs b/crates/punktfunk-host/src/update/jobs.rs index d19dd428..659cb1ab 100644 --- a/crates/punktfunk-host/src/update/jobs.rs +++ b/crates/punktfunk-host/src/update/jobs.rs @@ -42,6 +42,23 @@ pub(crate) struct IntentRecord { /// intent with this flag still present at boot ⟹ the rebuild succeeded. #[serde(default)] pub source_build: bool, + /// A per-user status tray was running when we spawned the installer, so boot reconciliation + /// should put it back. + /// + /// The installer's `StopTrays` force-kills every session's `punktfunk-tray.exe` (it is one of + /// the files being replaced), and its `[Run]` relaunch carries `skipifsilent` — which a + /// console-initiated update, spawned with `/VERYSILENT`, always trips. The tray therefore died + /// on every in-console update and stayed dead until the next sign-in. The installer cannot fix + /// this itself: spawned from the SYSTEM host service, its `runasoriginaluser` resolves to + /// SYSTEM, which would put a SYSTEM-owned tray in the user's session squatting the + /// `Local\PunktfunkTray` mutex and blocking the real one. The host relaunches it instead — it + /// already owns the `WTSQueryUserToken` primitive for landing a process in the interactive + /// session as the logged-in user. + /// + /// `#[serde(default)]`: an intent written by an older host reads as false (no relaunch), which + /// is the pre-existing behaviour. + #[serde(default)] + pub tray_was_running: bool, } /// The durable outcome of the most recent apply attempt. @@ -189,9 +206,21 @@ mod tests { installer_sha256: "ab".repeat(32), log_path: "/logs/update-0.23.200.log".into(), source_build: false, + tray_was_running: false, } } + /// An intent written by a host from before the tray-relaunch field must still load, and read + /// as "no tray to put back" — the behaviour that shipped before it existed. + #[test] + fn an_intent_without_the_tray_field_deserializes_as_false() { + let json = r#"{"from":"0.23.100","to":"0.23.200","serial":42,"started_unix":1000, + "installer_sha256":"ab","log_path":"/logs/x.log"}"#; + let i: IntentRecord = serde_json::from_str(json).expect("older intent still parses"); + assert!(!i.tray_was_running); + assert!(!i.source_build); + } + #[test] fn source_build_intent_is_success_with_the_running_version() { let mut i = intent(0); diff --git a/crates/punktfunk-host/src/update/linux.rs b/crates/punktfunk-host/src/update/linux.rs index 5327abfd..488325ca 100644 --- a/crates/punktfunk-host/src/update/linux.rs +++ b/crates/punktfunk-host/src/update/linux.rs @@ -140,6 +140,9 @@ pub(super) fn run_apply_steamos( installer_sha256: String::new(), log_path: log.display().to_string(), source_build: true, + // Windows-only concern: no Linux package force-kills a running tray, and the desktop + // autostart entry owns bringing it up. + tray_was_running: false, }, ) .map_err(|e| ("applying", format!("write intent record: {e}")))?; @@ -349,6 +352,7 @@ pub(super) fn run_apply( installer_sha256: String::new(), log_path: "journalctl -u punktfunk-update.service".into(), source_build: false, + tray_was_running: false, // Windows-only concern (see the source-build intent above) }, ) .map_err(|e| ("restarting", format!("write intent record: {e}")))?; diff --git a/crates/punktfunk-host/src/update/windows.rs b/crates/punktfunk-host/src/update/windows.rs index 5ee6aa66..8d43510b 100644 --- a/crates/punktfunk-host/src/update/windows.rs +++ b/crates/punktfunk-host/src/update/windows.rs @@ -43,6 +43,24 @@ fn staging_dir() -> PathBuf { pf_paths::config_dir().join("updates") } +/// Put the per-user tray back after an update that killed it. +/// +/// Runs from boot reconciliation, i.e. in the NEW host, as SYSTEM — so `crate::tray::start` takes +/// its session-crossing path and lands the tray in the active console session under the logged-in +/// user's own token. That is the whole reason this is the host's job and not the installer's (see +/// `IntentRecord::tray_was_running`). +/// +/// Best-effort throughout: nobody's update outcome depends on the icon, and the common benign +/// failure is simply that nobody has signed in yet — which the HKLM `Run` value covers at the next +/// logon. Hence `info`, not a warning the operator must act on. +pub(crate) fn relaunch_tray() { + match crate::tray::start() { + Ok((Some(pid), how)) => tracing::info!(pid, how, "status tray relaunched after the update"), + Ok((None, _)) => tracing::debug!("status tray was already running after the update"), + Err(e) => tracing::info!(error = %e, "could not relaunch the status tray after the update"), + } +} + fn log_path(version: &str) -> PathBuf { pf_paths::config_dir() .join("logs") @@ -96,6 +114,10 @@ pub(super) fn run_apply( installer_sha256: asset.sha256.to_ascii_lowercase(), log_path: log.display().to_string(), source_build: false, + // Captured BEFORE the installer runs: it force-kills every tray to unlock + // punktfunk-tray.exe and, under /VERYSILENT, never runs its relaunch entry. See + // `IntentRecord::tray_was_running`; `relaunch_tray` puts it back at reconcile. + tray_was_running: crate::tray::is_running(), }, ) .map_err(|e| ("applying", format!("write intent record: {e}")))?;