Files
punktfunk/crates/punktfunk-host/src/mgmt/host.rs
T
enricobuehler 110eabf281 feat(library/providers): let a provider say how to recognize its games
A plugin's titles launch through the provider's own client, which hands off and
exits — so the host had nothing left to watch, and both lifetime behaviors went
quiet for exactly the entries a provider contributes. A `ProviderEntry` (and a
manual custom entry) may now carry an optional `detect` hint: install dir, exe,
or process name.

It is deliberately a subset of what the host tracks internally. A Steam appid or
a launcher's environment marker are things the host discovers for itself and
would be meaningless — or dangerous — to take on someone's word; where a title
is installed is something only the provider knows. The host's own findings win
where both exist, so a stale export can never redirect the matcher, and a blank
field is treated as absent rather than as "match everything" — an empty install
dir would otherwise prefix-match every process on the box, and this feature can
end processes.

`process_name` is the weakest of the three and the only one typed by hand, so it
is matched case-insensitively against the image's file name and nothing else:
`retroarch` finds RetroArch, not a helper whose name merely starts the same way,
and not a script that happens to live in a `retroarch/` directory. The
never-adopt-a-pre-existing-process rule still bounds it.

Also: the tray summary gains the running-game row (with the closing-in countdown
for a game whose client is gone — visible at the machine without opening the
console), the SDK mirrors the `game.*` events, and its generated client catches
up with the endpoints Phase 1 added.

Gates on .21: check + clippy --all-targets clean, 299 tests, fmt CI-parity,
openapi regenerated (GameEntry still carries no `detect` outbound); SDK tsc +
54 tests green.
2026-07-26 18:28:13 +02:00

508 lines
20 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>,
/// Every launched game the host is tracking: one row per live session that launched a title, plus
/// any game whose session has ended and which is waiting out its reconnect window before being
/// ended (`state: "grace"`). Empty when nothing was launched — a plain desktop stream has no game.
games: Vec<ActiveGame>,
}
/// One launched game, for the console's running-game card.
#[derive(Serialize, ToSchema)]
pub(crate) struct ActiveGame {
/// The session streaming it; `null` for a game waiting out its reconnect window.
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<u64>,
/// Client-supplied device name of the session that launched it; may be empty.
client: String,
/// Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`
/// to show box art. Absent for an operator-typed GameStream command.
#[serde(skip_serializing_if = "Option::is_none")]
app_id: Option<String>,
/// Display title.
title: String,
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
#[serde(skip_serializing_if = "Option::is_none")]
store: Option<String>,
/// `native` or `gamestream`.
plane: crate::events::Plane,
/// `launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is
/// gone and it will be ended when the reconnect window closes).
#[schema(example = "running")]
state: String,
/// Seconds until this game is ended — only present on a `grace` row.
#[serde(skip_serializing_if = "Option::is_none")]
grace_remaining_s: Option<u64>,
}
/// 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>,
/// Launched games the host is tracking, as compact labels (`Hades`, `Hades (closing in 4:12)`).
///
/// The countdown form is the one that matters: it means the game's client is gone and the host
/// will end the game when the window closes — something a user at the machine should be able to
/// see (and stop) without opening the console. Empty when nothing was launched.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
games: 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,
games: crate::session_status::games()
.into_iter()
.map(|g| ActiveGame {
session_id: g.session_id,
client: g.client,
app_id: g.app_id,
title: g.title,
store: g.store,
plane: g.plane,
state: g.state.to_string(),
grace_remaining_s: g.grace_remaining_s,
})
.collect(),
})
}
/// 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()),
games: crate::session_status::games()
.into_iter()
.map(|g| match g.grace_remaining_s {
Some(left) => format!("{} (closing in {}:{:02})", g.title, left / 60, left % 60),
None => g.title,
})
.collect(),
})
}