diff --git a/crates/punktfunk-host/src/detect.rs b/crates/punktfunk-host/src/detect.rs index 5d412ebb..eb5a5c09 100644 --- a/crates/punktfunk-host/src/detect.rs +++ b/crates/punktfunk-host/src/detect.rs @@ -19,6 +19,14 @@ use std::sync::OnceLock; +/// Lowercased executable basenames (no `.exe`) of every running process — the same snapshot the +/// conflicting-host scan uses, exposed for the few callers that need to ask "is X running?" +/// without duplicating a Toolhelp walk. Best-effort: an empty vec means "could not tell", never +/// "nothing is running", so callers must not read absence as proof. +pub(crate) fn running_process_names() -> Vec { + platform::running_processes() +} + #[cfg(target_os = "windows")] #[path = "detect/windows.rs"] mod platform; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 53c2b822..07e6fdc3 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -95,6 +95,10 @@ mod session_status; mod sleep_inhibit; mod spike; mod stats_recorder; +// Start/stop/status for the per-user tray — the recovery path it has never had of its own. +#[cfg(target_os = "windows")] +#[path = "windows/tray.rs"] +mod tray; // The plugin store: signed catalogs, tiered trust, and install/uninstall jobs that run through the // same runner CLI the `plugins` subcommand uses (design/plugin-store.md). mod store; @@ -275,6 +279,7 @@ fn is_management_cli(args: &[String]) -> bool { Some("plugins") | Some("driver") | Some("web") + | Some("tray") | Some("openapi") | Some("library") | Some("detect-conflicts") @@ -666,6 +671,11 @@ fn real_main() -> Result<()> { Some("driver") => install::driver_main(&args[1..]), #[cfg(target_os = "windows")] Some("web") => install::web_main(&args[1..]), + // The tray's only recovery path: it is a per-user GUI process whose HKLM Run value fires + // solely at sign-in, so anything that kills one (an upgrade, a crash) otherwise left the + // operator iconless until the next logon. + #[cfg(target_os = "windows")] + Some("tray") => tray::main(&args[1..]), Some("-h") | Some("--help") | Some("help") | None => { print_usage(); Ok(()) @@ -899,6 +909,8 @@ USAGE: (secure default; add --gamestream for Moonlight compat) punktfunk-host plugins install/run host plugins (add, remove, list, enable, disable, status) — `plugins --help` for details + punktfunk-host tray status-tray lifecycle (start, stop, status) — Windows; + `start` is how you get the icon back without a re-logon punktfunk-host openapi print the management API's OpenAPI document (codegen) punktfunk-host punktfunk1-host [OPTIONS] native punktfunk/1 host (QUIC control + UDP data plane) punktfunk-host probe-compositor exit 0 iff the compositor is up + ready (bringup gate) diff --git a/crates/punktfunk-host/src/windows/tray.rs b/crates/punktfunk-host/src/windows/tray.rs new file mode 100644 index 00000000..529e049e --- /dev/null +++ b/crates/punktfunk-host/src/windows/tray.rs @@ -0,0 +1,154 @@ +//! Tray lifecycle: the one place that knows how to find, start, stop and check the per-user status +//! tray. Shared by the `tray` CLI subcommand and by post-update reconciliation +//! (`update::windows::relaunch_tray`). +//! +//! Why this exists at all: `punktfunk-tray.exe` is a per-USER, per-SESSION GUI process with no +//! recovery path of its own. The HKLM `Run` value only fires at sign-in, and nothing in the product +//! restarts a tray that died — so anything that kills one (an upgrade's `StopTrays`, a crash) left +//! the operator without an icon until they signed out and back in. +//! +//! The launch has to cross a privilege boundary in one direction but not the other, so [`start`] +//! tries the session-crossing path first and falls back to a plain spawn: +//! +//! * **From the host service (SYSTEM)** — the tray must land in the active console session under +//! the *logged-in user's* token, not SYSTEM's. That is +//! [`crate::interactive::spawn_in_active_session`] (`WTSQueryUserToken` + +//! `CreateProcessAsUserW`), and it needs `SE_TCB`, which only SYSTEM holds. +//! * **From an interactive shell** — the caller IS the user in the right session already, so +//! `WTSQueryUserToken` fails (an administrator does not hold `SE_TCB` either) and a plain spawn +//! is both sufficient and correct. +//! +//! Trying the privileged path and falling back on failure discriminates the two without a token +//! inspection, and without adding any `unsafe` to this crate. + +use anyhow::{bail, Context, Result}; +use std::path::PathBuf; + +/// The tray's image name, as the installer lays it down next to `punktfunk-host.exe`. +pub const TRAY_EXE: &str = "punktfunk-tray.exe"; + +pub fn main(args: &[String]) -> Result<()> { + match args.first().map(String::as_str) { + Some("start") => { + let (pid, how) = start()?; + match pid { + Some(pid) => println!("status tray started (pid {pid}, {how})"), + None => println!("status tray is already running"), + } + Ok(()) + } + Some("stop") => { + if stop() { + println!("status tray stopped"); + } else { + println!("no status tray was running"); + } + Ok(()) + } + Some("status") => { + let path = tray_exe(); + println!( + "status tray: {}", + match (&path, is_running()) { + (None, _) => "not installed".to_string(), + (Some(_), true) => "running".to_string(), + (Some(_), false) => "not running".to_string(), + } + ); + if let Some(p) = path { + println!("executable: {}", p.display()); + } + Ok(()) + } + _ => bail!("usage: punktfunk-host tray "), + } +} + +/// `punktfunk-tray.exe` next to this executable, when it is actually installed (the `trayicon` +/// task is optional, so its absence is a legitimate state, not an error). +pub fn tray_exe() -> Option { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join(TRAY_EXE))) + .filter(|p| p.exists()) +} + +/// Is a tray running in ANY session? Reuses the conflicting-host scan's Toolhelp snapshot rather +/// than opening a second one. Best-effort: a failed snapshot reads as "not running", so callers +/// must treat this as a hint — never as proof for a destructive decision. +pub fn is_running() -> bool { + let stem = TRAY_EXE.trim_end_matches(".exe"); + crate::detect::running_process_names() + .iter() + .any(|n| n == stem) +} + +/// Start the tray if it is not already up. +/// +/// `Ok(None)` = one was already running (idempotent by design: the tray also guards itself with a +/// `Local\PunktfunkTray` mutex, so even a lost race merely makes the second instance exit). The +/// `&'static str` names which mechanism launched it, for an honest CLI message. +/// +/// Note for an ELEVATED interactive caller: the fallback spawn inherits this process's token, so +/// the tray then runs elevated. It works, but UIPI stops a medium-integrity `--quit` from closing +/// it later. Prefer `tray start` from a normal shell, or let the host service do it. +pub fn start() -> Result<(Option, &'static str)> { + let Some(exe) = tray_exe() else { + bail!("{TRAY_EXE} is not installed next to this executable"); + }; + if is_running() { + return Ok((None, "already running")); + } + // Quoted: the install directory is operator-chosen and routinely contains spaces. + let quoted = format!("\"{}\"", exe.display()); + let session_err = match crate::interactive::spawn_in_active_session("ed, None) { + Ok(pid) => return Ok((Some(pid), "into the active console session")), + Err(e) => e, + }; + // Only SYSTEM holds SE_TCB, so reaching here means an ordinary (possibly elevated) user — which + // is fine ONLY if we are already sitting in the session the icon has to appear in. Refuse + // loudly otherwise: a plain spawn from an ssh/RDP session would put a tray in a session nobody + // is looking at, report success, and leave the operator staring at an empty notification area. + if let Some((own, console)) = crate::interactive::console_session_mismatch() { + bail!( + "cannot place the tray in session {console} from session {own}: {session_err}\n\ + crossing sessions needs SE_TCB, which only SYSTEM holds — run this from the console \ + session itself, or let the host service do it" + ); + } + let child = std::process::Command::new(&exe) + .spawn() + .with_context(|| format!("spawn {}", exe.display()))?; + Ok((Some(child.id()), "in this session")) +} + +/// Stop every tray instance. Returns whether one was running. +/// +/// Graceful first, mirroring the uninstaller's own order (`[UninstallRun]`): `--quit` posts +/// WM_CLOSE to the tray window, which lets it remove its icon via `NIM_DELETE` instead of leaving a +/// ghost in the notification area until the shell next sweeps it. `--quit` only reaches the session +/// it runs in, so a force-kill reaps any instance in another session. +pub fn stop() -> bool { + let was_running = is_running(); + if !was_running { + return false; + } + if let Some(exe) = tray_exe() { + // Best-effort and short: if the graceful close does not land we force it below anyway. + if let Ok(mut child) = std::process::Command::new(&exe).arg("--quit").spawn() { + let _ = child.wait(); + } + for _ in 0..8 { + if !is_running() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + let _ = std::process::Command::new("taskkill") + .args(["/F", "/IM", TRAY_EXE]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + true +}