Files
punktfunk/crates/punktfunk-host/src/mgmt/host.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

449 lines
18 KiB
Rust

//! Host-tagged management endpoints: identity + capabilities, liveness, compositor list, live
//! runtime status, and the loopback tray summary. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use crate::encode::Codec;
use crate::gamestream::APP_VERSION;
use crate::gamestream::AUDIO_PORT;
use crate::gamestream::CONTROL_PORT;
use crate::gamestream::GFE_VERSION;
use crate::gamestream::RTSP_PORT;
use crate::gamestream::VIDEO_PORT;
use std::sync::atomic::Ordering;
/// Liveness + version probe.
#[derive(Serialize, ToSchema)]
pub(crate) struct Health {
/// Always `"ok"` when the host responds.
#[schema(example = "ok")]
status: String,
/// `punktfunk-host` crate version.
version: String,
/// `punktfunk-core` C ABI version.
abi_version: u32,
}
/// Host identity and advertised capabilities (static for the life of the process).
#[derive(Serialize, ToSchema)]
pub(crate) struct HostInfo {
hostname: String,
/// Stable per-host id (persisted across restarts), matched on pairing.
uniqueid: String,
/// Best-effort primary LAN IP.
local_ip: String,
/// `punktfunk-host` crate version.
version: String,
/// `punktfunk-core` C ABI version.
abi_version: u32,
/// GameStream host version advertised to Moonlight clients.
app_version: String,
/// GFE version advertised to Moonlight clients.
gfe_version: String,
/// Codecs the host can encode (NVENC).
codecs: Vec<ApiCodec>,
/// Whether the GameStream/Moonlight-compat planes are running (`--gamestream`). `false` on the
/// secure default (native punktfunk/1 only) — a console can hide Moonlight-only UI (e.g. the
/// Moonlight PIN pairing card, which could never receive a PIN when this is `false`).
gamestream: bool,
ports: PortMap,
}
/// Every port a client integration may need (Moonlight derives the stream ports from the
/// HTTP base; a control pane should not have to).
#[derive(Serialize, ToSchema)]
pub(crate) struct PortMap {
/// This management API.
mgmt: u16,
/// nvhttp plain HTTP (serverinfo, pairing).
http: u16,
/// nvhttp mutual-TLS HTTPS (post-pairing).
https: u16,
rtsp: u16,
video: u16,
control: u16,
audio: u16,
}
/// Video codec identifier. The wire token matches the codec's canonical name used across the
/// stack (SDP/GameStream advertisement, the stats-capture `CaptureMeta.codec`, and the encoder's
/// [`Codec::label`]) — notably `H.265` serializes as `"hevc"`, not `"h265"`, so the same codec
/// reads identically on every console page.
#[derive(Clone, Copy, Serialize, Deserialize, ToSchema, PartialEq, Eq, Debug)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ApiCodec {
H264,
#[serde(rename = "hevc")]
H265,
Av1,
/// PyroWave — the opt-in wired-LAN intra-only wavelet codec.
PyroWave,
}
impl From<Codec> for ApiCodec {
fn from(c: Codec) -> Self {
match c {
Codec::H264 => ApiCodec::H264,
Codec::H265 => ApiCodec::H265,
Codec::Av1 => ApiCodec::Av1,
Codec::PyroWave => ApiCodec::PyroWave,
}
}
}
/// Live host status (changes as clients launch/end sessions).
#[derive(Serialize, ToSchema)]
pub(crate) struct RuntimeStatus {
/// True while the video stream thread is running.
video_streaming: bool,
/// True while the audio stream thread is running.
audio_streaming: bool,
/// True while a pairing handshake is parked waiting for the user's PIN
/// (submit it via `POST /api/v1/pair/pin`).
pin_pending: bool,
/// Number of pinned (paired) client certificates.
paired_clients: u32,
/// Number of live streaming sessions across BOTH planes (GameStream + native punktfunk/1). The
/// native server admits concurrent sessions, so this can exceed 1; `session`/`stream` below
/// describe a single representative session for the detail card.
active_sessions: u32,
/// A representative active session. GameStream's launch (Moonlight `/launch`) when present, else
/// the first live native session. `null` when nothing is streaming.
session: Option<SessionInfo>,
/// The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's
/// mode/codec/bitrate. `null` when nothing is streaming.
stream: Option<StreamInfo>,
}
/// Client-requested launch parameters (key material is never exposed here).
#[derive(Serialize, ToSchema)]
pub(crate) struct SessionInfo {
width: u32,
height: u32,
fps: u32,
}
/// RTSP-negotiated stream parameters.
#[derive(Serialize, ToSchema)]
pub(crate) struct StreamInfo {
width: u32,
height: u32,
fps: u32,
bitrate_kbps: u32,
/// Video payload size per packet (bytes).
packet_size: u32,
/// Client's parity floor per FEC block (`minRequiredFecPackets`).
min_fec: u8,
codec: ApiCodec,
/// Session bring-up total, hello → first video packet, in ms (native sessions; `null` on the
/// GameStream plane or while the session is still bringing up).
time_to_first_frame_ms: Option<u32>,
/// Most recent mid-stream resize total, reconfigure → pipeline rebuilt, in ms (native sessions;
/// `null` when no resize happened / GameStream).
last_resize_ms: Option<u32>,
}
/// 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)]
pub(crate) struct LocalSummary {
/// Host version (mirrors `/health`).
version: String,
/// True while video is streaming on EITHER plane: the GameStream media pipeline, or a live
/// native (punktfunk/1) session — the default plane, invisible in the GameStream flag alone.
video_streaming: bool,
/// True while audio is streaming on either plane (same rule as `video_streaming`).
audio_streaming: bool,
/// The active session: GameStream's launch (Moonlight `/launch`) when present, else the first
/// live native session. `null` when nothing is streaming.
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,
/// Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned
/// (`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is
/// held; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.
kept_displays: u32,
/// Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —
/// running one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);
/// the tray/console surface them so the clash is visible before pairing silently fails.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
conflicts: Vec<String>,
}
/// Liveness probe
///
/// Always available without authentication.
#[utoipa::path(
get,
path = "/health",
tag = "host",
operation_id = "getHealth",
// Override the document-global bearerAuth: this route is exempt in `require_auth`.
security(()),
responses((status = OK, description = "Host is up", body = Health))
)]
pub(crate) async fn get_health() -> Json<Health> {
Json(Health {
status: "ok".into(),
version: env!("PUNKTFUNK_VERSION").into(),
abi_version: punktfunk_core::ABI_VERSION,
})
}
/// Host identity and capabilities
#[utoipa::path(
get,
path = "/host",
tag = "host",
operation_id = "getHostInfo",
responses(
(status = OK, description = "Host identity, versions, codecs, and port map", body = HostInfo),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_host_info(State(st): State<Arc<MgmtState>>) -> Json<HostInfo> {
let h = &st.app.host;
Json(HostInfo {
hostname: h.hostname.clone(),
uniqueid: h.uniqueid.clone(),
local_ip: h.local_ip.to_string(),
version: env!("PUNKTFUNK_VERSION").into(),
abi_version: punktfunk_core::ABI_VERSION,
app_version: APP_VERSION.into(),
gfe_version: GFE_VERSION.into(),
// What this host can ACTUALLY encode on its resolved backend — GPU-aware, straight from the
// same capability mask that drives GameStream/QUIC negotiation ([`Codec::host_wire_caps`]).
// So an iGPU without AV1 encode won't advertise AV1, a software-only host reports H.264 only,
// and PyroWave appears only when its opt-in feature is built and the backend can open.
codecs: {
let caps = Codec::host_wire_caps();
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
[
(CODEC_H264, ApiCodec::H264),
(CODEC_HEVC, ApiCodec::H265),
(CODEC_AV1, ApiCodec::Av1),
(CODEC_PYROWAVE, ApiCodec::PyroWave),
]
.into_iter()
.filter(|(bit, _)| caps & bit != 0)
.map(|(_, codec)| codec)
.collect()
},
gamestream: st.gamestream_enabled,
ports: PortMap {
mgmt: st.port,
http: h.http_port,
https: h.https_port,
rtsp: RTSP_PORT,
video: VIDEO_PORT,
control: CONTROL_PORT,
audio: AUDIO_PORT,
},
})
}
/// A compositor backend the host can drive a virtual output on, and whether it's usable now.
#[derive(Serialize, ToSchema)]
pub(crate) struct AvailableCompositor {
/// Stable identifier (`"kwin"` | `"wlroots"` | `"mutter"` | `"gamescope"`) — pass this to a
/// client's `--compositor` flag.
id: String,
/// Human-readable label for UIs.
label: String,
/// Usable on this host right now: the live session's own compositor, or gamescope wherever
/// its binary is installed.
available: bool,
/// True for the backend an `Auto` (unspecified) request resolves to right now.
default: bool,
}
/// Available compositor backends
///
/// Lists every backend the host knows how to drive, flags which are usable right now, and marks
/// the one an unspecified (`Auto`) client request resolves to. Clients pass an `id` to their
/// `--compositor` flag (or `PUNKTFUNK_COMPOSITOR_*` over the C ABI) to request it.
#[utoipa::path(
get,
path = "/compositors",
tag = "host",
operation_id = "listCompositors",
responses(
(status = OK, description = "Compositor backends with availability + the auto-detected default", body = [AvailableCompositor]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_compositors() -> Json<Vec<AvailableCompositor>> {
let available = crate::vdisplay::available();
let default = crate::vdisplay::detect().ok();
Json(
crate::vdisplay::Compositor::all()
.into_iter()
.map(|c| AvailableCompositor {
id: c.id().into(),
label: c.label().into(),
available: available.contains(&c),
default: default == Some(c),
})
.collect(),
)
}
/// Live host status
#[utoipa::path(
get,
path = "/status",
tag = "host",
operation_id = "getStatus",
responses(
(status = OK, description = "Streaming/pairing state and the active session, if any", body = RuntimeStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<RuntimeStatus> {
// GameStream plane (set by RTSP/nvhttp on the compat path).
let gs_launch = *st.app.launch.lock().unwrap_or_else(|e| e.into_inner());
let gs_stream = *st.app.stream.lock().unwrap_or_else(|e| e.into_inner());
let gs_video = st.app.streaming.load(Ordering::SeqCst);
let gs_audio = st.app.audio_streaming.load(Ordering::SeqCst);
// Native punktfunk/1 plane (published by the native video loop; the default plane). See
// [`crate::session_status`] for why this lives outside `AppState`.
let native = crate::session_status::snapshot();
// Detail card is singular: prefer a live GameStream session, else the first native one.
// `active_sessions` conveys the true count when several native clients stream at once.
let session = gs_launch
.map(|l| SessionInfo {
width: l.width,
height: l.height,
fps: l.fps,
})
.or_else(|| {
native.first().map(|s| SessionInfo {
width: s.width,
height: s.height,
fps: s.fps,
})
});
let stream = gs_stream
.map(|c| StreamInfo {
width: c.width,
height: c.height,
fps: c.fps,
bitrate_kbps: c.bitrate_kbps,
packet_size: c.packet_size as u32,
min_fec: c.min_fec,
codec: c.codec.into(),
// Transition latencies are traced on the native plane only (latency plan P0.1).
time_to_first_frame_ms: None,
last_resize_ms: None,
})
.or_else(|| {
native.first().map(|s| StreamInfo {
width: s.width,
height: s.height,
fps: s.fps,
bitrate_kbps: s.bitrate_kbps,
// FEC/packetization are RTSP-negotiated (GameStream only); the native QUIC plane
// shards differently, so these are 0 (not applicable) for a native session.
packet_size: 0,
min_fec: 0,
codec: s.codec.into(),
time_to_first_frame_ms: (s.time_to_first_frame_ms > 0)
.then_some(s.time_to_first_frame_ms),
last_resize_ms: (s.last_resize_ms > 0).then_some(s.last_resize_ms),
})
});
Json(RuntimeStatus {
video_streaming: gs_video || !native.is_empty(),
audio_streaming: gs_audio || !native.is_empty(),
pin_pending: st.app.pairing.pin.awaiting_pin(),
paired_clients: st
.app
.paired
.lock()
.unwrap_or_else(|e| e.into_inner())
.len() as u32,
active_sessions: native.len() as u32 + u32::from(gs_video),
session,
stream,
})
}
/// 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),
)
)]
pub(crate) async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummary> {
// Native punktfunk/1 plane (the DEFAULT plane; GameStream is opt-in) — read ONCE and used for
// both the session card and the streaming flags below.
let native = crate::session_status::snapshot();
// GameStream launch, else the first live native session — so the tray reflects a native session
// too (same GameStream-only blind spot the Dashboard `/status` had; see `session_status`).
let session = st
.app
.launch
.lock()
.unwrap_or_else(|e| e.into_inner())
.map(|l| SessionInfo {
width: l.width,
height: l.height,
fps: l.fps,
})
.or_else(|| {
native.first().map(|s| SessionInfo {
width: s.width,
height: s.height,
fps: s.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(),
// Either plane counts, like `/status`: reading only the GameStream flags made the tray say
// "idle" (and wear the idle icon) through an entire native session.
video_streaming: st.app.streaming.load(Ordering::SeqCst) || !native.is_empty(),
audio_streaming: st.app.audio_streaming.load(Ordering::SeqCst) || !native.is_empty(),
session,
paired_clients: st
.app
.paired
.lock()
.unwrap_or_else(|e| e.into_inner())
.len() as u32,
native_paired_clients,
pin_pending: st.app.pairing.pin.awaiting_pin(),
pending_approvals,
kept_displays: crate::vdisplay::registry::snapshot()
.displays
.iter()
.filter(|d| d.state == "lingering" || d.state == "pinned")
.count() as u32,
// Cached at `serve` startup (empty when nothing was detected / never scanned) — no per-poll
// process enumeration.
conflicts: crate::detect::summary_labels(crate::detect::snapshot()),
})
}