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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("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
|
||||
}
|
||||
Reference in New Issue
Block a user