Files
punktfunk/crates/punktfunk-tray/src/status.rs
T
enricobuehlerandClaude Opus 5 d49f1bba49
apple / swift (push) Successful in 1m30s
ci / web (push) Successful in 1m1s
windows-host / package (push) Failing after 4m40s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 5m31s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
arch / build-publish (push) Successful in 11m30s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
android / android (push) Successful in 15m55s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m36s
deb / build-publish (push) Successful in 9m54s
deb / build-publish-host (push) Successful in 9m42s
ci / rust (push) Failing after 21m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 9m57s
apple / screenshots (push) Successful in 22m35s
docker / deploy-docs (push) Successful in 25s
fix(tray): count native sessions as streaming, and stop hiding "Open web console"
The tray sat at "Idle" through an entire stream, and its Windows context menu
could come up without its main action. Two separate causes.

`GET /api/v1/local/summary` reported `video_streaming`/`audio_streaming` straight
from `AppState.streaming` — flags only the GameStream media pipeline raises. The
native punktfunk/1 plane is the DEFAULT (GameStream is opt-in) and never touches
them, so every native session read as idle: idle icon, and a tooltip that printed
that session's own resolution next to the word "idle" (the `session` field was
already native-aware). This is the blind spot `/status` was fixed for — see the
`session_status` module doc — which the summary kept. Both flags now OR in a live
native session, and the tray additionally treats a reported session as streaming,
so a new tray reads an older host correctly too.

The menu built "Open web console" (and the pairing entry) only while a live
loopback probe of the console had just succeeded. A console still starting, or an
SSR render slower than the 2 s probe timeout, therefore deleted the tray's
most-wanted action outright — indistinguishable from a tray that never had one,
with left-clicking the icon as the only, undiscoverable, way in. The entry is now
always present and the default item; a failed probe changes its label to
"(not responding)" instead of hiding it, and takes two consecutive misses to say
so, since one timeout is not "down".

While here: a "Release kept display…" entry when displays are held (the summary
field's own doc promised a one-click release that did not exist), and entries
deep-link to /pairing and /displays instead of all landing on the dashboard. The
Linux SNI menu mirrors all of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 00:46:30 +02:00

546 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Host status model + the poller thread feeding the platform tray implementations.
//!
//! Two sources, service manager FIRST: the SCM (Windows) / systemd user unit (Linux) decides
//! stopped-vs-running — a malicious local process squatting the mgmt port while the service is
//! down can never make the tray say Running. Only when the service manager reports Running does
//! the poller consult the host's loopback-only `GET /api/v1/local/summary` for streaming detail.
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
/// What the service manager reports for the host service.
#[derive(Clone, Debug, PartialEq)]
pub enum ServiceState {
NotInstalled,
Stopped,
StartPending,
StopPending,
Running,
/// Linux `ActiveState=failed` (with the sub-state), or a Windows stop with a failure exit code.
Failed(String),
}
/// `GET /api/v1/local/summary` — the non-sensitive counts/booleans the host serves to loopback
/// peers without authentication (mgmt.rs `LocalSummary`). Unknown fields are ignored so a newer
/// host can grow the summary without breaking an older tray.
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
pub struct Summary {
pub version: String,
pub video_streaming: bool,
pub audio_streaming: bool,
pub session: Option<SessionInfo>,
pub paired_clients: u32,
pub native_paired_clients: u32,
pub pin_pending: bool,
pub pending_approvals: u32,
/// Virtual displays kept with no live session (lingering/pinned). `#[serde(default)]` so an older
/// host that doesn't send it deserializes as 0.
#[serde(default)]
pub kept_displays: u32,
/// Other Moonlight-compatible hosts (Sunshine/Apollo/…) the host detected on this machine at
/// startup — side-by-side use is unsupported. `#[serde(default)]` so an older host omitting it
/// deserializes as empty.
#[serde(default)]
pub conflicts: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
pub struct SessionInfo {
pub width: u32,
pub height: u32,
pub fps: u32,
}
/// What the icon shows.
#[derive(Clone, Debug, PartialEq)]
pub enum TrayStatus {
NotInstalled,
Stopped,
/// Service starting, or running with the mgmt API not answering yet (within [`START_GRACE`]).
Starting,
Running(Summary),
/// Service running but the summary unreachable past the grace period — amber, not red: a
/// custom `PUNKTFUNK_HOST_CMD` (no mgmt API) or a relocated `--mgmt-bind` is legitimate.
Degraded,
Error(String),
}
impl TrayStatus {
/// One-line headline for the tooltip / the disabled menu header.
pub fn headline(&self) -> String {
match self {
TrayStatus::NotInstalled => "punktfunk host — not installed".into(),
TrayStatus::Stopped => "punktfunk host — stopped".into(),
TrayStatus::Starting => "punktfunk host — starting…".into(),
TrayStatus::Degraded => "punktfunk host — running (status unavailable)".into(),
TrayStatus::Error(e) => format!("punktfunk host — failed ({e})"),
TrayStatus::Running(s) => {
let base = match (&s.session, self.is_streaming()) {
(Some(sess), true) => format!(
"punktfunk host {} — streaming {}×{}@{}",
s.version, sess.width, sess.height, sess.fps
),
(_, true) => format!("punktfunk host {} — streaming", s.version),
// Idle, but surface a kept (lingering/pinned) display: it — and, under an
// exclusive topology, your physical monitors — is being held. Release it from
// the console.
_ if s.kept_displays > 0 => format!(
"punktfunk host {} — idle · {} display{} kept",
s.version,
s.kept_displays,
if s.kept_displays == 1 { "" } else { "s" }
),
_ => format!("punktfunk host {} — idle", s.version),
};
// A conflicting Moonlight host (Sunshine/Apollo/…) is the loudest thing to say —
// side-by-side use is unsupported, so lead the tooltip with it.
if s.conflicts.is_empty() {
base
} else {
format!("⚠ conflicting host: {}{base}", s.conflicts.join(", "))
}
}
}
}
/// The host detected another Moonlight-compatible host (Sunshine/Apollo/…) on this machine —
/// unsupported side-by-side. Drives the Linux (ksni) backend's `NeedsAttention` state; the
/// Windows backend surfaces the same conflict through the tooltip `headline()` instead (it has
/// no distinct attention icon), so this accessor is unused there — allow it per-platform rather
/// than gate the shared API out.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn has_conflicts(&self) -> bool {
matches!(self, TrayStatus::Running(s) if !s.conflicts.is_empty())
}
/// A client is streaming: the host's flag, OR a live session in the summary.
///
/// The session is checked too because a host from before the `get_local_summary` fix raised
/// `video_streaming` from the GameStream plane only — through a whole session on the native
/// (default) plane it said false while still reporting that session's mode, which is what left
/// the tray sitting at "idle" mid-stream. This keeps a newer tray honest against such a host.
pub fn is_streaming(&self) -> bool {
matches!(self, TrayStatus::Running(s) if s.video_streaming || s.session.is_some())
}
/// Virtual displays held with no live session (lingering/pinned) — offered as a one-click
/// release in the menu, since holding one can also be keeping physical monitors dark.
pub fn kept_displays(&self) -> u32 {
match self {
TrayStatus::Running(s) => s.kept_displays,
_ => 0,
}
}
/// A pairing attempt is waiting on the operator (shown as an extra menu entry).
pub fn pairing_attention(&self) -> bool {
matches!(self, TrayStatus::Running(s) if s.pin_pending || s.pending_approvals > 0)
}
}
/// How long a running service may leave the summary unreachable before Starting turns Degraded.
/// Also re-applied mid-life: the SYSTEM supervisor relaunching a crashed host child looks like
/// "running, briefly unreachable" — that shows as Starting again, not an alarming flicker to red.
pub const START_GRACE: Duration = Duration::from_secs(15);
/// Pure status mapping (unit-tested): service-manager state first, summary second, grace third.
pub fn map_status(svc: &ServiceState, summary: Option<Summary>, grace_expired: bool) -> TrayStatus {
match svc {
ServiceState::NotInstalled => TrayStatus::NotInstalled,
ServiceState::Stopped | ServiceState::StopPending => TrayStatus::Stopped,
ServiceState::StartPending => TrayStatus::Starting,
ServiceState::Failed(e) => TrayStatus::Error(e.clone()),
ServiceState::Running => match summary {
Some(s) => TrayStatus::Running(s),
None if !grace_expired => TrayStatus::Starting,
None => TrayStatus::Degraded,
},
}
}
// ── Poller ──────────────────────────────────────────────────────────────────────────────────────
pub struct Poller {
shared: Arc<Shared>,
}
struct Shared {
poked: Mutex<bool>,
cv: Condvar,
}
impl Poller {
/// Spawn the poll thread; `on_change(status, console_up)` fires (from that thread) whenever
/// either changes. `console_up` is a live loopback probe of the web console on `web_port`. It
/// annotates the "Open web console" entry ("not responding") rather than hiding it: the entry
/// is the tray's most-wanted action, and a menu that silently drops it — because the console
/// was still starting, or the probe timed out — is indistinguishable from a tray that never
/// had one.
pub fn spawn(
mgmt_addr: String,
mgmt_port: u16,
web_port: u16,
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
) -> Poller {
let shared = Arc::new(Shared {
poked: Mutex::new(false),
cv: Condvar::new(),
});
let thread_shared = shared.clone();
std::thread::Builder::new()
.name("status-poll".into())
.spawn(move || poll_loop(&thread_shared, &mgmt_addr, mgmt_port, web_port, on_change))
.expect("spawn status-poll thread");
Poller { shared }
}
/// Force an immediate re-poll (right after a start/stop/restart menu action).
pub fn poke(&self) {
*self.shared.poked.lock().unwrap() = true;
self.shared.cv.notify_one();
}
}
fn poll_loop(
shared: &Shared,
mgmt_addr: &str,
mgmt_port: u16,
web_port: u16,
on_change: Box<dyn Fn(TrayStatus, bool) + Send>,
) {
// IPv6 literals bracketed, like the Linux client's `base_url`.
let url = if mgmt_addr.contains(':') {
format!("https://[{mgmt_addr}]:{mgmt_port}/api/v1/local/summary")
} else {
format!("https://{mgmt_addr}:{mgmt_port}/api/v1/local/summary")
};
let console_url = format!("https://127.0.0.1:{web_port}/");
let agent = agent(load_pin());
let mut last: Option<(TrayStatus, bool)> = None;
// When the summary became unreachable while the service was running (grace anchor).
// Runs for the process lifetime (the tray exits by process exit; nothing to unwind).
let mut unreachable_since: Option<Instant> = None;
// Consecutive failed console probes. One miss is not "down": the console is a bun/Nitro SSR
// whose cold first render can outrun this agent's 2 s timeout, and a menu entry that changes
// its label every few seconds reads as broken.
let mut console_misses = 0u32;
loop {
let svc = probe_service();
let summary = if svc == ServiceState::Running {
let s = fetch_summary(&agent, &url);
match s {
Some(_) => unreachable_since = None,
None if unreachable_since.is_none() => unreachable_since = Some(Instant::now()),
None => {}
}
s
} else {
unreachable_since = None;
None
};
let grace_expired = unreachable_since.is_some_and(|t| t.elapsed() >= START_GRACE);
let status = map_status(&svc, summary, grace_expired);
let console_up = if probe_console(&agent, &console_url) {
console_misses = 0;
true
} else {
console_misses += 1;
console_misses < 2
};
if last.as_ref() != Some(&(status.clone(), console_up)) {
on_change(status.clone(), console_up);
last = Some((status, console_up));
}
// 3 s while there is anything to watch; back off when the box just doesn't run a host.
let cadence = match last.as_ref().map(|(s, _)| s) {
Some(TrayStatus::Stopped) | Some(TrayStatus::NotInstalled) => Duration::from_secs(10),
_ => Duration::from_secs(3),
};
let mut poked = shared.poked.lock().unwrap();
if !*poked {
(poked, _) = shared.cv.wait_timeout(poked, cadence).unwrap();
}
*poked = false;
}
}
/// Is the web console answering on loopback? Any HTTP response (incl. the login redirect / 401)
/// counts as up — only a transport failure (nothing listening, TLS handshake dead) means down.
fn probe_console(agent: &ureq::Agent, url: &str) -> bool {
match agent.get(url).call() {
Ok(_) => true,
Err(ureq::Error::Status(..)) => true,
Err(_) => false,
}
}
// ── Summary fetch (loopback HTTPS) ──────────────────────────────────────────────────────────────
fn fetch_summary(agent: &ureq::Agent, url: &str) -> Option<Summary> {
let body = agent.get(url).call().ok()?.into_string().ok()?;
serde_json::from_str(&body).ok()
}
/// The host identity cert's SHA-256, when `cert.pem` is readable (Linux: same-user file). On
/// Windows the file is SYSTEM/Administrators-DACL'd, so the per-user tray can't pin — `None` =
/// accept any cert. That is acceptable here: the connection is loopback, carries no credentials,
/// and only *reads* non-sensitive data; stopped-vs-running is decided by the service manager, so
/// a port-squatter gains nothing but a fake "streaming" tooltip on an already-compromised box.
fn load_pin() -> Option<[u8; 32]> {
use rustls::pki_types::pem::PemObject;
let pem = std::fs::read(punktfunk_config_dir()?.join("cert.pem")).ok()?;
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).ok()?;
Some(punktfunk_core::tls::cert_fingerprint(der.as_ref()))
}
/// The host's config dir, mirroring `gamestream::config_dir()` without linking the host crate:
/// `PUNKTFUNK_CONFIG_DIR` override, else `$XDG_CONFIG_HOME`/`~/.config` + `punktfunk` (Linux).
/// `None` on Windows — everything the tray would read there is SYSTEM/Admins-DACL'd anyway.
pub fn punktfunk_config_dir() -> Option<std::path::PathBuf> {
if let Some(d) = std::env::var_os("PUNKTFUNK_CONFIG_DIR") {
if !d.is_empty() {
return Some(std::path::PathBuf::from(d));
}
}
#[cfg(target_os = "linux")]
{
if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") {
if !x.is_empty() {
return Some(std::path::PathBuf::from(x).join("punktfunk"));
}
}
std::env::var_os("HOME").map(|h| {
std::path::PathBuf::from(h)
.join(".config")
.join("punktfunk")
})
}
#[cfg(not(target_os = "linux"))]
None
}
/// A sync HTTPS agent over the same rustls(ring) stack the rest of the workspace uses, with a
/// pin-or-accept-any verifier (the Linux client's `PinVerify` pattern, `library.rs`).
fn agent(pin: Option<[u8; 32]>) -> ureq::Agent {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let cfg = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("rustls default protocol versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::new(pin)))
.with_no_client_auth();
ureq::AgentBuilder::new()
.tls_config(Arc::new(cfg))
.timeout_connect(Duration::from_secs(2))
.timeout(Duration::from_secs(2))
.build()
}
// ── Service-manager probe ───────────────────────────────────────────────────────────────────────
/// The SCM name registered by `punktfunk-host service install` (windows/service.rs SERVICE_NAME).
#[cfg(windows)]
pub const SERVICE_NAME: &str = "PunktfunkHost";
#[cfg(windows)]
pub fn probe_service() -> ServiceState {
use windows_service::service::{ServiceAccess, ServiceExitCode, ServiceState as Scm};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
// CONNECT + QUERY_STATUS work unprivileged. Re-opened every poll on purpose: a reinstall
// (delete + create) invalidates old handles, and this picks the new service up within a poll.
let Ok(manager) = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)
else {
return ServiceState::NotInstalled;
};
let Ok(svc) = manager.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS) else {
return ServiceState::NotInstalled; // ERROR_SERVICE_DOES_NOT_EXIST et al.
};
let Ok(status) = svc.query_status() else {
return ServiceState::NotInstalled;
};
match status.current_state {
Scm::StartPending => ServiceState::StartPending,
Scm::StopPending => ServiceState::StopPending,
Scm::Running | Scm::ContinuePending | Scm::PausePending | Scm::Paused => {
ServiceState::Running
}
Scm::Stopped => match status.exit_code {
// 0 = clean; 1077 = never started since boot (ERROR_SERVICE_NEVER_HAS_BEEN_RUN? no —
// "no attempts to start have been made"): both are an ordinary Stopped, not a failure.
ServiceExitCode::Win32(0) | ServiceExitCode::Win32(1077) => ServiceState::Stopped,
ServiceExitCode::Win32(code) => ServiceState::Failed(format!("exit code {code}")),
ServiceExitCode::ServiceSpecific(code) => {
ServiceState::Failed(format!("service error {code}"))
}
},
}
}
/// The systemd user unit the Linux packages install (scripts/punktfunk-host.service).
#[cfg(target_os = "linux")]
pub const UNIT_NAME: &str = "punktfunk-host.service";
#[cfg(target_os = "linux")]
pub fn probe_service() -> ServiceState {
// `systemctl show` exits 0 even for unknown units (LoadState=not-found) — parse, don't rely
// on the exit code.
let Ok(out) = std::process::Command::new("systemctl")
.args([
"--user",
"show",
UNIT_NAME,
"--property=LoadState,ActiveState,SubState",
])
.output()
else {
return ServiceState::NotInstalled; // no systemctl → nothing to watch
};
let text = String::from_utf8_lossy(&out.stdout);
let prop = |key: &str| {
text.lines()
.find_map(|l| l.strip_prefix(key)?.strip_prefix('='))
.unwrap_or("")
.to_string()
};
if prop("LoadState") == "not-found" {
return ServiceState::NotInstalled;
}
match prop("ActiveState").as_str() {
"active" | "reloading" => ServiceState::Running,
"activating" => ServiceState::StartPending,
"deactivating" => ServiceState::StopPending,
"failed" => ServiceState::Failed(prop("SubState")),
_ => ServiceState::Stopped, // "inactive" and anything new
}
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(streaming: bool) -> Summary {
Summary {
version: "0.5.1".into(),
video_streaming: streaming,
audio_streaming: streaming,
session: streaming.then_some(SessionInfo {
width: 2560,
height: 1440,
fps: 120,
}),
paired_clients: 1,
native_paired_clients: 2,
pin_pending: false,
pending_approvals: 0,
kept_displays: 0,
conflicts: Vec::new(),
}
}
/// The full (service state × summary × grace) table.
#[test]
fn status_mapping_table() {
use ServiceState as S;
use TrayStatus as T;
let cases: Vec<(S, Option<Summary>, bool, T)> = vec![
(S::NotInstalled, None, false, T::NotInstalled),
(S::Stopped, None, false, T::Stopped),
(S::StopPending, None, false, T::Stopped),
(S::StartPending, None, false, T::Starting),
(
S::Failed("code 3".into()),
None,
false,
T::Error("code 3".into()),
),
// Running + summary → Running regardless of grace.
(
S::Running,
Some(summary(false)),
true,
T::Running(summary(false)),
),
// Running + unreachable: Starting within grace, Degraded past it.
(S::Running, None, false, T::Starting),
(S::Running, None, true, T::Degraded),
// A summary while the SCM says Stopped is impossible by construction (the poller only
// fetches when Running) — but the mapping must still trust the service manager.
(S::Stopped, Some(summary(true)), false, T::Stopped),
];
for (svc, sum, grace, want) in cases {
assert_eq!(
map_status(&svc, sum.clone(), grace),
want,
"{svc:?} {sum:?} grace={grace}"
);
}
}
#[test]
fn conflicts_drive_attention_and_lead_the_tooltip() {
let mut s = summary(false);
assert!(!TrayStatus::Running(s.clone()).has_conflicts());
s.conflicts = vec!["Sunshine (running)".into(), "Apollo".into()];
let st = TrayStatus::Running(s);
assert!(st.has_conflicts());
let head = st.headline();
assert!(head.starts_with("⚠ conflicting host: Sunshine (running), Apollo"));
}
#[test]
fn headline_shows_session_and_reason() {
assert_eq!(
TrayStatus::Running(summary(true)).headline(),
"punktfunk host 0.5.1 — streaming 2560×1440@120"
);
assert_eq!(
TrayStatus::Running(summary(false)).headline(),
"punktfunk host 0.5.1 — idle"
);
assert!(TrayStatus::Error("exit code 3".into())
.headline()
.contains("exit code 3"));
assert!(TrayStatus::Degraded
.headline()
.contains("status unavailable"));
}
/// A live session means streaming even if the host's flag says otherwise — a host from before
/// the `get_local_summary` fix only raised `video_streaming` for the GameStream plane, so a
/// native session showed as "idle" with its own mode printed next to it.
#[test]
fn a_live_session_reads_as_streaming_without_the_flag() {
let mut s = summary(true);
s.video_streaming = false; // pre-fix host, native session
let st = TrayStatus::Running(s);
assert!(st.is_streaming());
assert_eq!(
st.headline(),
"punktfunk host 0.5.1 — streaming 2560×1440@120"
);
// No session and no flag is still idle.
assert!(!TrayStatus::Running(summary(false)).is_streaming());
}
#[test]
fn kept_displays_are_reported_for_the_release_action() {
assert_eq!(TrayStatus::Running(summary(false)).kept_displays(), 0);
let mut s = summary(false);
s.kept_displays = 2;
assert_eq!(TrayStatus::Running(s).kept_displays(), 2);
assert_eq!(TrayStatus::Degraded.kept_displays(), 0);
}
#[test]
fn pairing_attention_flags() {
let mut s = summary(false);
assert!(!TrayStatus::Running(s.clone()).pairing_attention());
s.pending_approvals = 1;
assert!(TrayStatus::Running(s.clone()).pairing_attention());
s.pending_approvals = 0;
s.pin_pending = true;
assert!(TrayStatus::Running(s).pairing_attention());
assert!(!TrayStatus::Degraded.pairing_attention());
}
}