feat(windows/tray): punktfunk-host tray start|stop|status, so a dead tray is one command away

The status tray 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
ever restarts one. Anything that kills a tray — an upgrade's StopTrays, a crash —
therefore left the operator without an icon until they signed out and back in,
with no way to ask for it back.

windows/tray.rs becomes the one place that knows tray lifecycle (find, start,
stop, is-running), so the CLI and post-update reconciliation share an
implementation rather than growing two.

start() cannot simply spawn the exe, because the launch crosses a privilege
boundary in one direction only:

  - from the host service (SYSTEM) the tray must land in the active console
    session under the LOGGED-IN USER's token, which is WTSQueryUserToken +
    CreateProcessAsUserW and needs SE_TCB — SYSTEM-only;
  - from a shell already in the console session that call fails (an administrator
    does not hold SE_TCB either) and a plain spawn is both sufficient and right.

Trying the privileged path and falling back discriminates the two without
inspecting a token, and adds no unsafe to the crate. But a plain fallback from
ssh/RDP would put a tray in a session nobody can see and report success, so on
failure it consults console_session_mismatch() and refuses with an explanation
instead. stop() is graceful first (--quit lets the tray remove its own icon via
NIM_DELETE, as the uninstaller does), then forces any instance in another session.

Verified on Windows: launched from a SYSTEM scheduled task the tray comes up in
the console session owned by the interactive user, not SYSTEM; from an ssh
session (0 -> console 2) it refuses; a second start is a no-op; stop is graceful
and idempotent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 20:42:50 +02:00
co-authored by Claude Opus 5
parent 3bb30cb2f6
commit c77823d800
3 changed files with 174 additions and 0 deletions
+8
View File
@@ -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<String> {
platform::running_processes()
}
#[cfg(target_os = "windows")]
#[path = "detect/windows.rs"]
mod platform;
+12
View File
@@ -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 <CMD> install/run host plugins (add, remove, list, enable,
disable, status) — `plugins --help` for details
punktfunk-host tray <CMD> 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)
+154
View File
@@ -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 <start|stop|status>"),
}
}
/// `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<PathBuf> {
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<u32>, &'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(&quoted, 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
}