feat(tray): system-tray status icon for the host (Windows + Linux)

New crates/punktfunk-tray — a small per-user companion showing the host service
state at a glance (running / stopped / starting / degraded / failed + the live
session in the tooltip) with one-click actions: open web console, approve a
pending pairing request, start/stop/restart, open logs. No more digging through
logs to learn whether the service came back after a reboot or an update.

Status is service-manager-FIRST (SCM / systemd user unit — a port squatter can
never fake Running), then the new loopback-only unauthenticated
GET /api/v1/local/summary (counts/booleans only; the mgmt token and cert.pem
are SYSTEM/Admins-DACL'd on Windows, so a non-elevated tray cannot bearer-auth).

Windows: windows_subsystem binary (a console exe in the Run key would flash a
terminal at sign-in), Shell_NotifyIcon + hidden window, per-session single
instance, TaskbarCreated re-add, --quit for the uninstaller; service actions
elevate per click via ShellExecuteW "runas" onto the new
`punktfunk-host service restart` (stop → wait Stopped → start).
Linux: ksni/StatusNotifierItem over zbus, systemctl --user actions (no polkit),
/etc/xdg/autostart entry whose --autostart self-gates to actual host users.
Icons: scripts/gen-tray-icons.py (pure stdlib) renders the brand lens + status
dot into committed .ico/hicolor assets; deb/rpm/arch ship binary+autostart+icons.

Live-validated: Linux on the headless KDE session (SNI registration, state
transitions, menu-driven start, dbusmenu layout); Windows on the RTX box
(session-1 launch with no NIM_ADD failure, single instance, --quit, restart
round-trip, summary loopback-200/LAN-401).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 12:09:35 +00:00
parent 01fcb01019
commit 8005b11faf
35 changed files with 2166 additions and 19 deletions
+1 -1
View File
@@ -739,7 +739,7 @@ NOTES:
"\nWINDOWS SERVICE (end-user deployment — replaces a manual launch):\n\
\x20 punktfunk-host service install register an auto-start SYSTEM service + firewall rules\n\
\x20 punktfunk-host service uninstall remove the service + firewall rules\n\
\x20 punktfunk-host service start|stop|status\n\
\x20 punktfunk-host service start|stop|restart|status\n\
\x20 config: %ProgramData%\\punktfunk\\host.env\n\
\nWINDOWS DIAGNOSTICS:\n\
\x20 punktfunk-host hdr-p010-selftest GPU colour check for the PUNKTFUNK_HDR_SHADER_P010 path\n\
+141 -1
View File
@@ -157,6 +157,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(list_gpus))
.routes(routes!(set_gpu_preference))
.routes(routes!(get_status))
.routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients))
.routes(routes!(unpair_client))
.routes(routes!(get_pairing_status))
@@ -353,6 +354,30 @@ struct StreamInfo {
codec: ApiCodec,
}
/// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,
/// no fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see
/// `require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the
/// per-user tray process cannot authenticate — this narrow read-only route is its status source.
#[derive(Serialize, ToSchema)]
struct LocalSummary {
/// Host version (mirrors `/health`).
version: String,
/// True while the video stream thread is running.
video_streaming: bool,
/// True while the audio stream thread is running.
audio_streaming: bool,
/// The active launch session (set by Moonlight's `/launch`, cleared on cancel/stop).
session: Option<SessionInfo>,
/// Number of pinned (paired) GameStream client certificates.
paired_clients: u32,
/// Number of paired native (punktfunk/1) devices.
native_paired_clients: u32,
/// True while a GameStream pairing handshake is parked waiting for the user's PIN.
pin_pending: bool,
/// Native pairing knocks awaiting the operator's approval (count only).
pending_approvals: u32,
}
/// A paired (certificate-pinned) Moonlight client.
#[derive(Serialize, ToSchema)]
struct PairedClient {
@@ -488,13 +513,34 @@ where
/// Auth gate on the `/api/v1` routes: a paired client cert (mTLS, from anywhere) or the bearer token
/// (from a **loopback** peer only) — required always (the host runs with a token by construction).
/// `/api/v1/health` stays open for probes. The cert path authorizes only the read-only allowlist
/// `/api/v1/health` stays open for probes; `/api/v1/local/summary` is open to loopback peers only
/// (the tray icon's status source). The cert path authorizes only the read-only allowlist
/// ([`cert_may_access`]); the bearer path authorizes the full admin surface and is therefore confined
/// to loopback so it is never LAN-exposed even when the listener binds all interfaces by default.
async fn require_auth(State(st): State<Arc<MgmtState>>, req: Request, next: Next) -> Response {
if req.uri().path() == "/api/v1/health" {
return next.run(req).await; // liveness probe is always open
}
// The tray icon's status source: non-sensitive counts/booleans only, unauthenticated but
// confined to LOOPBACK peers. The bearer-token file (and cert.pem) are SYSTEM/Administrators-
// DACL'd on Windows, so the per-user tray process cannot authenticate — this one narrow
// read-only route is deliberately all it needs. Not on the cert allowlist: LAN mTLS clients
// already have the richer `/status`. (No PeerAddr ⇒ a unit test → treat as loopback, matching
// the bearer path below.)
if req.uri().path() == "/api/v1/local/summary" {
let from_loopback = req
.extensions()
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
return if from_loopback {
next.run(req).await
} else {
api_error(
StatusCode::UNAUTHORIZED,
"the local summary is loopback-only",
)
};
}
// A paired native client authenticates by its mTLS certificate — the same identity + trust the
// QUIC data plane uses. But "paired to STREAM" is not "paired to ADMINISTER": a streaming cert
// authorizes only the safe, read-only status routes, NOT state-changing or pairing-administration
@@ -944,6 +990,45 @@ async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
})
}
/// Local status summary for the tray icon
///
/// Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device
/// names). Unauthenticated, but served to loopback peers only.
#[utoipa::path(
get,
path = "/local/summary",
tag = "host",
operation_id = "getLocalSummary",
// Override the document-global bearerAuth: loopback peers are exempt in `require_auth`.
security(()),
responses(
(status = OK, description = "Non-sensitive local host status (loopback peers only)", body = LocalSummary),
(status = UNAUTHORIZED, description = "Non-loopback peer", body = ApiError),
)
)]
async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
let session = st.app.launch.lock().unwrap().map(|l| SessionInfo {
width: l.width,
height: l.height,
fps: l.fps,
});
let (native_paired_clients, pending_approvals) = st
.native
.as_ref()
.map(|n| (n.status().paired_clients, n.pending().len() as u32))
.unwrap_or((0, 0));
Json(LocalSummary {
version: env!("PUNKTFUNK_VERSION").into(),
video_streaming: st.app.streaming.load(Ordering::SeqCst),
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst),
session,
paired_clients: st.app.paired.lock().unwrap().len() as u32,
native_paired_clients,
pin_pending: st.app.pairing.pin.awaiting_pin(),
pending_approvals,
})
}
/// List paired clients
#[utoipa::path(
get,
@@ -2031,6 +2116,61 @@ mod tests {
assert_eq!(body["abi_version"], punktfunk_core::ABI_VERSION);
}
/// The tray's `/local/summary` is unauthenticated for LOOPBACK peers only — a LAN peer is
/// rejected even though the route needs no bearer token, and the body never carries secret
/// material (no PIN values, no fingerprints, no device names — counts/booleans only).
#[tokio::test]
async fn local_summary_is_loopback_only_and_non_sensitive() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir()
.join(format!("pf-mgmt-summary-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
np.add("secret-device-name", "deadbeefcafe0123").unwrap();
let app = test_app_native(test_state(), np);
// Loopback peer, NO auth header → 200 with the expected shape.
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("127.0.0.1:40000".parse().unwrap()));
let (status, body) = send(&app, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["video_streaming"], false);
assert_eq!(body["native_paired_clients"], 1);
assert_eq!(body["pending_approvals"], 0);
assert!(body["version"].is_string());
// No secret material anywhere in the body (paired name / fingerprint must not leak).
let raw = body.to_string();
assert!(
!raw.contains("deadbeefcafe0123") && !raw.contains("secret-device-name"),
"summary must not leak fingerprints or device names: {raw}"
);
// The same request from a LAN peer → rejected (route is loopback-gated, not just tokenless).
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("192.168.1.50:40000".parse().unwrap()));
let (status, _) = send(&app, req).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"the local summary must be rejected for a LAN peer"
);
// IPv6 loopback counts as loopback.
let mut req = get_req("/api/v1/local/summary");
req.extensions_mut()
.insert(PeerAddr("[::1]:40000".parse().unwrap()));
let (status, _) = send(&app, req).await;
assert_eq!(status, StatusCode::OK, "::1 is a loopback peer");
}
#[tokio::test]
async fn bearer_token_is_enforced() {
let app = test_app(test_state(), Some("sekrit"));
@@ -91,6 +91,7 @@ pub fn main(args: &[String]) -> Result<()> {
Some("uninstall") => uninstall(),
Some("start") => sc(&["start", SERVICE_NAME]),
Some("stop") => sc(&["stop", SERVICE_NAME]),
Some("restart") => restart(),
Some("status") => sc(&["query", SERVICE_NAME]),
_ => {
eprintln!(
@@ -102,6 +103,7 @@ pub fn main(args: &[String]) -> Result<()> {
\x20 punktfunk-host service uninstall stop + remove the service + firewall rules\n\
\x20 punktfunk-host service start start the service now\n\
\x20 punktfunk-host service stop stop the service\n\
\x20 punktfunk-host service restart stop, wait for exit, start again\n\
\x20 punktfunk-host service status query the service\n\n\
Config: %ProgramData%\\punktfunk\\host.env Logs: %ProgramData%\\punktfunk\\logs\\"
);
@@ -691,6 +693,40 @@ fn install(args: &[String]) -> Result<()> {
Ok(())
}
/// `service restart`: stop, wait for the service to actually reach Stopped (a bare
/// `sc stop && sc start` races the stop — START fails with "instance already running" while the
/// old process winds down), then start. The tray icon's Restart action runs this, elevated.
fn restart() -> Result<()> {
use windows_service::service::{ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
.context("open Service Control Manager (run elevated)")?;
let svc = manager
.open_service(
SERVICE_NAME,
ServiceAccess::STOP | ServiceAccess::QUERY_STATUS | ServiceAccess::START,
)
.context("open service (run elevated)")?;
// Best-effort stop: ERROR_SERVICE_NOT_ACTIVE just means restart == start.
let _ = svc.stop();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
let state = svc.query_status().context("query service status")?;
if state.current_state == ServiceState::Stopped {
break;
}
if std::time::Instant::now() >= deadline {
anyhow::bail!("service did not stop within 30 s");
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
svc.start(&[] as &[&std::ffi::OsStr])
.context("start service")?;
println!("Restarted service '{SERVICE_NAME}'.");
Ok(())
}
fn uninstall() -> Result<()> {
use windows_service::service::ServiceAccess;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};