chore: consolidate all in-progress parallel-session WIP
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s

Wholesale commit of every uncommitted change across the tree, at the user's
explicit request — host refactor-campaign W1 (native.rs facade + native/ dir,
library/ + mgmt/ splits), Android, core. These streams were mid-flight and not
individually built/tested together; this supersedes the per-session HOLD
markers. Consolidating so everything lands on main in one pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:08:29 +02:00
parent 07e2836601
commit ecfa71212d
67 changed files with 9456 additions and 8062 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
/// Sized for 1 Gbps+: at ~1.2 Gbps on the wire an 8 MB buffer is only ~49 ms of steady state, and a
/// single multi-MB IDR keyframe (~4 MB ≈ 3300 packets) instantly fills most of it. 32 MB gives ~200 ms
/// of headroom and absorbs a keyframe burst without EAGAIN/ENOBUFS drops. (Paced sending —
/// `punktfunk1.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
/// `native.rs::paced_submit` — spreads a big frame's overflow, so this buffer mostly absorbs the
/// immediate microburst rather than a whole unpaced frame.)
pub(crate) const TARGET_SOCKBUF: usize = 32 * 1024 * 1024;
+1 -1
View File
@@ -4,7 +4,7 @@
//! ([`Transport::recv_batch`], ≤128/syscall into a reused ring) on Linux AND Android (which is
//! `target_os = "android"`, not `"linux"` — it needs its own bionic binding, see [`android_mmsg`])
//! — the 1 Gbps+ syscall lever (~125k → a few-k syscalls/sec at line rate). The host additionally
//! paces each frame's send across the frame interval (see `punktfunk1.rs::paced_submit`) so a real
//! paces each frame's send across the frame interval (see `native.rs::paced_submit`) so a real
//! NIC doesn't drop a line-rate burst. All three layer on this same [`Transport`] seam (scalar
//! fallbacks for loopback and the remaining targets).
+1 -1
View File
@@ -77,7 +77,7 @@ src/
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
audio/ · audio.rs Opus out + virtual mic (PipeWire / WASAPI)
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
punktfunk1.rs the native punktfunk/1 host (QUIC control + native-thread UDP data plane)
native.rs the native punktfunk/1 host (QUIC control + native-thread UDP data plane)
mgmt.rs · native_pairing.rs · stats_recorder.rs management API, pairing, perf capture
hdr.rs · library.rs HDR metadata; multi-store game library
linux/ · windows/ platform-confined backends
@@ -1035,6 +1035,19 @@ impl IddPushCapturer {
// there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session.
let display_hdr = enabled_hdr
|| crate::win_display::advanced_color_enabled(target.target_id).unwrap_or(false);
// Downgrade point D (design/hdr-10bit-default-and-av1.md item 2d): the session was
// NEGOTIATED 10-bit (the client was told HDR in the Welcome), but the virtual display
// could not enable advanced color — the ring sizes SDR and the encoder will emit 8-bit
// BT.709, so the client's label overstates the stream until the descriptor poller sees
// HDR come on. Loud, because every frame of this session is affected.
if client_10bit && !display_hdr {
tracing::error!(
target = target.target_id,
"IDD push: 10-bit HDR was negotiated but enabling advanced color on the \
virtual display FAILED — encoding 8-bit SDR while the client was told HDR \
(check the display driver / Windows HDR support on this box)"
);
}
let ring_fmt = if display_hdr {
DXGI_FORMAT_R16G16B16A16_FLOAT
} else {
+16 -2
View File
@@ -49,7 +49,12 @@ pub struct HostConfig {
/// `PUNKTFUNK_ZEROCOPY` — Windows D3D11 zero-copy encode input override. `None` (unset) defers to
/// the per-vendor default (AMF on, QSV off — see module docs and `encode/ffmpeg_win.rs`).
pub zerocopy: Option<bool>,
/// `PUNKTFUNK_10BIT` — host policy gate for HEVC Main10 (only honored when the client also advertised 10-bit).
/// `PUNKTFUNK_10BIT` — host policy gate for 10-bit encode (HEVC Main10 / AV1 10-bit).
/// **Default ON** (since 10-bit went probe-gated end-to-end, 2026-07-16): the host merely
/// *allows* 10-bit — a session only becomes 10-bit when the client advertised `VIDEO_CAP_10BIT`
/// (behind its HDR setting + display-capability gate), the codec supports it (HEVC/AV1), and
/// the GPU/backend passed the encode probe (`can_encode_10bit`) — otherwise 8-bit SDR.
/// `PUNKTFUNK_10BIT=0`/`false`/`off`/`no` disables. Independent of `four_four_four` (depth vs chroma).
pub ten_bit: bool,
/// `PUNKTFUNK_444` — host policy gate for full-chroma HEVC 4:4:4 (Range Extensions).
/// **Default ON** (since the pipeline went zero-copy + honest end-to-end, 2026-07-10): the
@@ -108,7 +113,16 @@ impl HostConfig {
"0" | "false" | "off" | "no"
)
}),
ten_bit: flag("PUNKTFUNK_10BIT"),
// Default ON, explicit-off grammar (mirrors `four_four_four`: the client's HDR setting
// is the real per-session switch; the encode probe keeps incapable GPUs honest at 8-bit).
ten_bit: val("PUNKTFUNK_10BIT")
.map(|s| {
!matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(true),
// Default ON, explicit-off grammar (the client's own 4:4:4 setting — default OFF —
// is the real switch; see the field doc).
four_four_four: val("PUNKTFUNK_444")
+70
View File
@@ -845,6 +845,76 @@ pub fn can_encode_444(_codec: Codec) -> bool {
false
}
/// Whether the active GPU encode backend can actually produce a **10-bit** stream for `codec`
/// (HEVC Main10 / AV1 10-bit). Resolved (and cached per selected GPU) *before* the Welcome so the
/// negotiated bit depth — and the HDR/SDR colour label derived from it — matches what the encoder
/// will really emit: the honest-downgrade channel, exactly like [`can_encode_444`]. Without this
/// gate a default-on `PUNKTFUNK_10BIT` would negotiate 10-bit on a GPU/backend that then silently
/// falls back to 8-bit post-Welcome (label HDR / stream SDR).
///
/// Backend truth: Windows **NVENC** queries the per-codec `NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` cap;
/// native **AMF** `Init`s a tiny P010 encoder with the 10-bit profile props (the driver rejects
/// what the VCN can't do). **QSV** stays `false` until validated on Intel glass — the libavcodec
/// Main10 incantation can silently encode 8-bit, the same stance as its 4:4:4 probe. Every
/// **Linux** backend is `false` today: direct-NVENC/CUDA pins 8-bit until a P010 capture path
/// exists (Phase 5.1), libav `hevc_nvenc` needs a 10-bit input format the capturer never feeds,
/// VAAPI 10-bit isn't wired, and Vulkan-video hardcodes 8-bit — so Linux hosts honestly negotiate
/// 8-bit SDR.
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub fn can_encode_10bit(codec: Codec) -> bool {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
if !codec.supports_10bit() {
return false;
}
// Cached per (selected GPU, codec) — a web-console preference change re-probes on the newly
// selected adapter before the next Welcome, mirroring `can_encode_444`.
static CACHE: OnceLock<Mutex<HashMap<(String, &'static str), bool>>> = OnceLock::new();
let key = (crate::gpu::selection_key(), codec.label());
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(v) = cache.lock().unwrap().get(&key) {
return *v;
}
let supported = {
#[cfg(target_os = "linux")]
{
// No Linux backend encodes 10-bit yet (see the fn doc) — never negotiate it.
false
}
#[cfg(target_os = "windows")]
{
match windows_resolved_backend() {
WindowsBackend::Nvenc => {
#[cfg(feature = "nvenc")]
{
nvenc::probe_can_encode_10bit(codec)
}
#[cfg(not(feature = "nvenc"))]
{
false
}
}
WindowsBackend::Amf => amf::probe_can_encode_10bit(codec),
// QSV: deferred like its 4:4:4 probe (`ffmpeg_win::probe_can_encode_444`) — no
// Intel Windows box in the lab to validate that the libavcodec profile really
// emits Main10 rather than silently 8-bit.
WindowsBackend::Qsv => false,
WindowsBackend::Software => false,
}
}
};
tracing::info!(codec = ?codec, supported, "10-bit encode capability probed");
cache.lock().unwrap().insert(key, supported);
supported
}
/// Non-Linux/Windows (the macOS dev/test build of the host — synthetic-source loopback only):
/// no GPU encode backend exists here, so 10-bit is never negotiated.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn can_encode_10bit(_codec: Codec) -> bool {
false
}
// ---------------------------------------------------------------------------------------------
// Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi
// logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the vendor of the
@@ -104,6 +104,15 @@ impl Codec {
}
}
/// Whether this codec has a negotiable **10-bit** encode path (HEVC Main10 / AV1 10-bit).
/// H.264 is always 8-bit (High10 is neither an NVENC nor a VCN encode mode — negotiation
/// never asks), and PyroWave's wavelet path ingests 8-bit. `true` here is only the
/// *codec-level* gate: the active GPU/backend must still pass
/// [`can_encode_10bit`](crate::encode::can_encode_10bit) before the host negotiates 10-bit.
pub fn supports_10bit(self) -> bool {
matches!(self, Codec::H265 | Codec::Av1)
}
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
/// pick the software encoder).
pub fn nvenc_name(self) -> &'static str {
@@ -1770,6 +1770,30 @@ pub fn probe_can_encode(codec: Codec) -> bool {
/// [`probe_can_encode`] against an explicit device (separated so the live tests can pin the AMD
/// adapter on a hybrid box).
fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
probe_open_on(device, codec, false)
}
/// Native factory probe for **10-bit** encode: can this GPU's AMF runtime `Init` a `codec`
/// encoder at 10-bit (Main10 profile / `*ColorBitDepth` 10, P010 input)? The driver rejects the
/// profile/depth props on VCN generations that can't encode them, so a successful tiny `Init` is
/// the honest per-codec answer — read *before* the Welcome by
/// [`crate::encode::can_encode_10bit`] so the negotiated bit depth matches what the session's
/// encoder will really open. H.264 is always `false` (High10 is not a VCN mode — the session
/// open bails on it too).
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
if !codec.supports_10bit() {
return false;
}
let Some(device) = selected_adapter_device() else {
return false;
};
probe_open_on(&device, codec, true)
}
/// Shared probe body: a context on `device`, the codec's component, the usage preset, optionally
/// the 10-bit profile/depth props, then a tiny `Init` (P010 surface when `ten_bit`, NV12
/// otherwise). Everything is torn down before returning; `false` on any failure.
fn probe_open_on(device: &ID3D11Device, codec: Codec, ten_bit: bool) -> bool {
if try_factory().is_err() {
return false;
}
@@ -1778,7 +1802,8 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
// object is moved into a guard (`Ctx`/`Component`) immediately, so each early return releases
// exactly once; `InitDX11` borrows the live `device` for the synchronous call (AMF holds its
// own device reference until the guard's Terminate). Usage must be set before `Init` (the
// header marks its default "N/A") — the probe mirrors the session's open order.
// header marks its default "N/A") — the probe mirrors the session's open order, including
// the 10-bit profile/depth props (`configure`'s required set_props) when probing 10-bit.
unsafe {
let Ok(lib) = try_factory() else { return false };
let mut ctx: *mut sys::AmfContext = ptr::null_mut();
@@ -1811,7 +1836,32 @@ fn probe_can_encode_on(device: &ID3D11Device, codec: Codec) -> bool {
{
return false;
}
((*(*comp.0).vtbl).init)(comp.0, sys::AMF_SURFACE_NV12, 640, 480) == sys::AMF_OK
if ten_bit {
// The same required props `configure` sets for a 10-bit session — a driver that can't
// honor them rejects here, which is exactly the probe's answer.
let depth_props: &[(PCWSTR, i64)] = match codec {
Codec::H265 => &[
(w!("HevcProfile"), HEVC_PROFILE_MAIN_10),
(w!("HevcColorBitDepth"), COLOR_BIT_DEPTH_10),
],
// 10-bit is part of AV1 Main profile — only the surface depth needs forcing.
Codec::Av1 => &[(w!("Av1ColorBitDepth"), COLOR_BIT_DEPTH_10)],
Codec::H264 | Codec::PyroWave => return false,
};
for (name, value) in depth_props {
if ((*(*comp.0).vtbl).set_property)(comp.0, name.0, AmfVariant::from_i64(*value))
!= sys::AMF_OK
{
return false;
}
}
}
let surface = if ten_bit {
sys::AMF_SURFACE_P010
} else {
sys::AMF_SURFACE_NV12
};
((*(*comp.0).vtbl).init)(comp.0, surface, 640, 480) == sys::AMF_OK
}
}
@@ -2701,7 +2751,7 @@ mod tests {
}
/// Drive `enc` at the real frame cadence and return each frame's **submit→AU** wall-clock
/// (µs) — the `encode_us` the punktfunk1 loop records. Mirrors the depth-1 loop exactly:
/// (µs) — the `encode_us` the native loop records. Mirrors the depth-1 loop exactly:
/// pace to `1/fps`, timestamp the submit, then drain whatever AUs are ready and FIFO-pair
/// them to their submit stamps. The libavcodec AMF wrapper's ~2-frame output hold therefore
/// shows up here as ~2 frame periods (the AU for frame N emerges only once N+2 is submitted),
@@ -321,7 +321,7 @@ fn retrieve_loop(
work_rx: mpsc::Receiver<RetrieveJob>,
done_tx: mpsc::Sender<RetrieveDone>,
) {
crate::punktfunk1::boost_thread_priority(false);
crate::native::boost_thread_priority(false);
while let Ok(job) = work_rx.recv() {
// SAFETY: `job.event` is one of the auto-reset events `init_session` created and
// registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
@@ -1119,7 +1119,7 @@ impl Encoder for NvencD3d11Encoder {
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
// NOW so `caps().chroma_444` (and punktfunk1's post-open cross-check) reports what
// NOW so `caps().chroma_444` (and native's post-open cross-check) reports what
// the stream really carries instead of silently claiming full chroma.
if self.chroma_444
&& !matches!(
@@ -1333,7 +1333,11 @@ impl Encoder for NvencD3d11Encoder {
// session is in HDR mode. Both are the real capabilities the session glue routes on.
EncoderCaps {
supports_rfi: self.rfi_supported,
supports_hdr_metadata: self.hdr,
// In-band mastering/CLL is attached as keyframe SEI on HEVC/H.264 only — AV1 carries
// it in METADATA OBUs (`HDR_MDCV`/`HDR_CLL`), which this backend doesn't emit yet
// (see `submit`); the grade still reaches punktfunk clients out-of-band via the 0xCE
// datagram. Don't claim a capability the AV1 path doesn't have.
supports_hdr_metadata: self.hdr && self.codec != Codec::Av1,
// Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
// YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
chroma_444: self.chroma_444,
@@ -1564,10 +1568,34 @@ impl Drop for NvencD3d11Encoder {
}
/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
/// Creates a throwaway hardware D3D11 device + NVENC session, queries the cap, and tears down. HEVC-only;
/// the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before* the Welcome
/// so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a card without it).
/// HEVC-only; the result is cached by the caller ([`crate::encode::can_encode_444`]) and read *before*
/// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a
/// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics.
pub fn probe_can_encode_444(codec: Codec) -> bool {
if codec != Codec::H265 {
return false;
}
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE)
}
/// Probe whether the active NVIDIA GPU can encode `codec` at **10-bit**
/// (`NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` against the codec's own GUID — HEVC Main10 / AV1 10-bit).
/// The result is cached by the caller ([`crate::encode::can_encode_10bit`]) and read *before* the
/// Welcome so the negotiated bit depth — and the HDR label derived from it — matches what NVENC
/// will really emit. The session-open path re-checks the same cap as a belt-and-braces guard
/// ([`NvencD3d11Encoder::probe_caps`]'s 8-bit fallback).
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
if !codec.supports_10bit() {
return false;
}
probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE)
}
/// Query ONE NVENC capability for `codec`: creates a throwaway hardware D3D11 device + NVENC
/// session on the **selected render adapter**, reads the cap, and tears everything down. `false`
/// on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
/// capability that couldn't be confirmed.
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D::{
D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
@@ -1576,9 +1604,6 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
if codec != Codec::H265 {
return false;
}
// No loadable NVENC on this box (non-NVIDIA / no driver) → the honest 4:4:4 answer is "no".
// This is also the `api()` gate for every NVENC call below.
if try_api().is_err() {
@@ -1651,11 +1676,11 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
}
let mut param = nv::NV_ENC_CAPS_PARAM {
version: nv::NV_ENC_CAPS_PARAM_VER,
capsToQuery: nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE,
capsToQuery: cap,
reserved: [0; 62],
};
let mut val: i32 = 0;
let ok = (api().get_encode_caps)(enc, nv::NV_ENC_CODEC_HEVC_GUID, &mut param, &mut val)
let ok = (api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
.nv_ok()
.is_ok()
&& val != 0;
+449
View File
@@ -0,0 +1,449 @@
//! Host lifecycle event bus (scripting-and-hooks RFC §4, phase 0).
//!
//! A process-wide broadcast bus + bounded catch-up ring for **lifecycle events**: client
//! connect/disconnect, session and stream start/end, pairing decisions, virtual-display
//! create/release, library mutations, host start/stop. Fire sites on BOTH planes call
//! [`emit`]; consumers ([`EventBus::subscribe`]) get a ring-backed catch-up plus a live
//! tail — the shape `GET /api/v1/events` (SSE, phase 1) and the hook runner (phase 2)
//! consume. Until those land, the bus is host-internal.
//!
//! Design notes (mirrors [`crate::log_capture`], the shipped ring precedent):
//! - Events carry a monotonically increasing `seq` (1-based) and a wall-clock `ts_ms`.
//! A consumer resumes with `since = last seen seq`; one that fell off the ring gets
//! `dropped = true` and should resync via the REST snapshots.
//! - The wire shape is **versioned and additive-only** within a major ([`SCHEMA_VERSION`]):
//! fields and kinds may be added, never removed or renamed. The JSON snapshot tests below
//! are the review gate — a failing snapshot IS a schema change.
//! - **Payload hygiene: events never carry secrets** — no PINs, no tokens, no key material.
//! Client names and fingerprints are fine (already exposed via the management API).
//! - Emission is fire-and-forget and cheap (a mutex push + a non-blocking broadcast send);
//! nothing here sits in a streaming hot path. Slow consumers lag (`RecvError::Lagged`)
//! rather than buffering unboundedly; the SSE layer disconnects them.
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast;
use utoipa::ToSchema;
/// Wire-shape major version, carried on every event. Additive-only within a major; removing
/// or renaming a field is a new major (and, at the API layer, a new endpoint negotiation).
pub const SCHEMA_VERSION: u32 = 1;
/// Catch-up ring capacity. Events are small (a few hundred bytes) and low-rate (lifecycle,
/// not per-frame), so 1024 spans hours of ordinary host activity.
const RING_CAPACITY: usize = 1024;
/// Live-tail channel depth per subscriber before a slow consumer starts lagging.
const BROADCAST_CAPACITY: usize = 256;
/// One host lifecycle event, as it will appear on the wire (`data:` of one SSE frame).
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct HostEvent {
/// Monotonic sequence number (1-based) — a consumer resumes with `since = last seen`.
pub seq: u64,
/// Unix timestamp in milliseconds (the [`crate::log_capture::LogEntry`] convention).
pub ts_ms: u64,
/// Wire-shape version ([`SCHEMA_VERSION`]).
pub schema: u32,
/// The event kind + payload, flattened: `"kind": "stream.started", …payload…`.
#[serde(flatten)]
pub kind: EventKind,
}
/// Which protocol plane an event originated from. Hooks and scripts filter on it — a hook
/// that fires for native clients but not Moonlight clients is a bug, not a v2 feature.
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Plane {
/// The native punktfunk/1 plane (QUIC).
Native,
/// The GameStream/Moonlight compat plane (`--gamestream`).
Gamestream,
}
/// Why a client went away. `Quit` is a deliberate user "stop" (the typed close code);
/// `Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.
#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DisconnectReason {
Quit,
Timeout,
Error,
}
/// The connecting/disconnecting client's identity.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct ClientRef {
/// Client-supplied device name; may be empty (an anonymous or compat-plane client).
pub name: String,
/// Hex SHA-256 certificate fingerprint, when the client presented one.
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
pub plane: Plane,
}
/// A live A/V session (the plane-neutral notion the Dashboard shows).
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct SessionRef {
/// Host-local session id (unique within this host process).
pub id: u64,
/// Short client label (cert-fingerprint prefix, or peer IP for an anonymous client).
pub client: String,
/// Negotiated mode, `WxH@Hz` (e.g. `"3840x2160@120"`).
pub mode: String,
pub hdr: bool,
}
/// A live video stream (what the stream marker file reflects).
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct StreamRef {
/// Negotiated mode, `WxH@Hz`.
pub mode: String,
pub hdr: bool,
/// Client-supplied device name; may be empty.
pub client: String,
/// The launched app/title for this stream, when one was requested (store-qualified id on
/// the native plane, app title on the GameStream plane).
#[serde(skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
pub plane: Plane,
}
/// A device in the pairing flow.
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
pub struct DeviceRef {
/// Sanitized device name (the pairing store's copy).
pub name: String,
/// Hex certificate fingerprint.
pub fingerprint: String,
pub plane: Plane,
}
/// The event catalog (RFC §4). Serialized internally tagged as `"kind": "<domain>.<verb>"`,
/// flattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`].
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
#[serde(tag = "kind")]
pub enum EventKind {
#[serde(rename = "client.connected")]
ClientConnected { client: ClientRef },
#[serde(rename = "client.disconnected")]
ClientDisconnected {
client: ClientRef,
reason: DisconnectReason,
},
#[serde(rename = "session.started")]
SessionStarted { session: SessionRef },
#[serde(rename = "session.ended")]
SessionEnded { session: SessionRef },
#[serde(rename = "stream.started")]
StreamStarted { stream: StreamRef },
#[serde(rename = "stream.stopped")]
StreamStopped { stream: StreamRef },
#[serde(rename = "pairing.pending")]
PairingPending { device: DeviceRef },
#[serde(rename = "pairing.completed")]
PairingCompleted { device: DeviceRef },
#[serde(rename = "pairing.denied")]
PairingDenied { device: DeviceRef },
#[serde(rename = "display.created")]
DisplayCreated {
/// The virtual-display backend that minted it (`VirtualDisplay::name`).
backend: String,
/// `WxH@Hz`.
mode: String,
},
#[serde(rename = "display.released")]
DisplayReleased {
/// How many kept displays this release retired.
count: u32,
},
#[serde(rename = "library.changed")]
LibraryChanged {
/// What mutated the library: `"manual"` today; a provider id once the provider
/// API (RFC §8) lands.
source: String,
},
#[serde(rename = "host.started")]
HostStarted {
version: String,
/// Whether the GameStream/Moonlight compat plane is enabled.
gamestream: bool,
},
#[serde(rename = "host.stopping")]
HostStopping,
}
impl EventKind {
/// The wire kind string (`"stream.started"`, …) — for filters and log lines.
pub fn name(&self) -> &'static str {
match self {
EventKind::ClientConnected { .. } => "client.connected",
EventKind::ClientDisconnected { .. } => "client.disconnected",
EventKind::SessionStarted { .. } => "session.started",
EventKind::SessionEnded { .. } => "session.ended",
EventKind::StreamStarted { .. } => "stream.started",
EventKind::StreamStopped { .. } => "stream.stopped",
EventKind::PairingPending { .. } => "pairing.pending",
EventKind::PairingCompleted { .. } => "pairing.completed",
EventKind::PairingDenied { .. } => "pairing.denied",
EventKind::DisplayCreated { .. } => "display.created",
EventKind::DisplayReleased { .. } => "display.released",
EventKind::LibraryChanged { .. } => "library.changed",
EventKind::HostStarted { .. } => "host.started",
EventKind::HostStopping => "host.stopping",
}
}
}
/// Formats a mode as the wire's `WxH@Hz` string.
pub fn mode_str(width: u32, height: u32, hz: u32) -> String {
format!("{width}x{height}@{hz}")
}
/// One consumer's view: the ring-backed catch-up plus a live-tail receiver, taken atomically
/// (no event can fall between `catch_up` and the first `rx.recv()`, and none is in both).
pub struct Subscription {
/// Events with `seq > since`, oldest first.
pub catch_up: Vec<HostEvent>,
/// True when events between `since` and the first caught-up one were already evicted —
/// the consumer should resync via the REST snapshots (the `LogPage.dropped` contract).
pub dropped: bool,
/// The live tail. A consumer that can't keep up sees `RecvError::Lagged`.
pub rx: broadcast::Receiver<HostEvent>,
}
/// The process-wide event bus: a bounded seq-numbered ring (catch-up) + a broadcast channel
/// (live tail).
pub struct EventBus {
inner: Mutex<Ring>,
tx: broadcast::Sender<HostEvent>,
}
struct Ring {
events: VecDeque<HostEvent>,
next_seq: u64,
}
impl EventBus {
fn new() -> Self {
let (tx, _) = broadcast::channel(BROADCAST_CAPACITY);
Self {
inner: Mutex::new(Ring {
events: VecDeque::with_capacity(RING_CAPACITY),
next_seq: 1,
}),
tx,
}
}
/// Stamp, ring-buffer, and broadcast one event. Fire-and-forget: no receivers is fine
/// (the ring still records it for a later subscriber's catch-up).
pub fn emit(&self, kind: EventKind) {
let ts_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let mut ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let ev = HostEvent {
seq: ring.next_seq,
ts_ms,
schema: SCHEMA_VERSION,
kind,
};
ring.next_seq += 1;
if ring.events.len() == RING_CAPACITY {
ring.events.pop_front();
}
ring.events.push_back(ev.clone());
// Send while still holding the ring lock: it serializes with `subscribe` (which also
// takes the lock), so an event lands either in a subscriber's catch-up or on its live
// tail — never both, never neither. `send` is non-blocking, the hold is trivial.
let _ = self.tx.send(ev);
}
/// Subscribe with a resume cursor: events with `seq > since` come back as catch-up, the
/// returned receiver carries everything after. `since = 0` means "from the ring start".
pub fn subscribe(&self, since: u64) -> Subscription {
let ring = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let rx = self.tx.subscribe();
let first_seq = ring.events.front().map_or(ring.next_seq, |e| e.seq);
let dropped = since != 0 && since + 1 < first_seq;
let catch_up = ring
.events
.iter()
.filter(|e| e.seq > since)
.cloned()
.collect();
Subscription {
catch_up,
dropped,
rx,
}
}
}
/// The process-wide bus — a `OnceLock` singleton (the [`crate::log_capture::ring`] shape) so
/// fire sites across both planes and the API layer share it without threading an `Arc`.
pub fn bus() -> &'static EventBus {
static BUS: OnceLock<EventBus> = OnceLock::new();
BUS.get_or_init(EventBus::new)
}
/// Emit one lifecycle event on the process-wide bus. Cheap and non-blocking — safe from any
/// thread, including RAII `Drop` paths.
pub fn emit(kind: EventKind) {
bus().emit(kind);
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(name: &str) -> EventKind {
EventKind::LibraryChanged {
source: name.to_string(),
}
}
#[test]
fn seq_is_monotonic_and_catch_up_resumes() {
let bus = EventBus::new();
for i in 0..5 {
bus.emit(ev(&format!("m{i}")));
}
let sub = bus.subscribe(0);
assert_eq!(
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5]
);
assert!(!sub.dropped);
assert!(sub.catch_up.iter().all(|e| e.schema == SCHEMA_VERSION));
// Resume from a cursor mid-ring.
let sub = bus.subscribe(3);
assert_eq!(
sub.catch_up.iter().map(|e| e.seq).collect::<Vec<_>>(),
vec![4, 5]
);
assert!(!sub.dropped);
// Cursor at the tip: empty catch-up, not a gap.
let sub = bus.subscribe(5);
assert!(sub.catch_up.is_empty());
assert!(!sub.dropped);
}
#[test]
fn eviction_reports_dropped() {
let bus = EventBus::new();
for i in 0..(RING_CAPACITY + 50) {
bus.emit(ev(&format!("m{i}")));
}
// Seqs 1..=50 were evicted; a cursor inside the gap must flag it.
let sub = bus.subscribe(10);
assert!(sub.dropped);
assert_eq!(sub.catch_up.first().map(|e| e.seq), Some(51));
// A fresh consumer (since = 0) is a backfill, not a gap.
let sub = bus.subscribe(0);
assert!(!sub.dropped);
assert_eq!(sub.catch_up.len(), RING_CAPACITY);
}
#[tokio::test]
async fn live_tail_continues_exactly_after_catch_up() {
let bus = EventBus::new();
bus.emit(ev("before-1"));
bus.emit(ev("before-2"));
let mut sub = bus.subscribe(0);
assert_eq!(sub.catch_up.len(), 2);
// Emitted after subscribe → on the live tail only, starting at exactly seq 3.
bus.emit(ev("after"));
let live = sub.rx.recv().await.expect("live event");
assert_eq!(live.seq, 3);
assert_eq!(live.kind.name(), "library.changed");
// Nothing duplicated: the tail holds only what wasn't in the catch-up.
assert!(sub.rx.try_recv().is_err());
}
/// The wire shape IS the contract (additive-only, RFC §4): these snapshots are the review
/// gate — if one fails, the change renames/removes a field and needs a schema-version bump,
/// not a test update.
#[test]
fn wire_shape_snapshots() {
let ev = HostEvent {
seq: 4182,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::StreamStarted {
stream: StreamRef {
mode: mode_str(3840, 2160, 120),
hdr: true,
client: "Living Room TV".into(),
app: Some("steam:570".into()),
plane: Plane::Native,
},
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":4182,"ts_ms":1700000000000,"schema":1,"kind":"stream.started","stream":{"mode":"3840x2160@120","hdr":true,"client":"Living Room TV","app":"steam:570","plane":"native"}}"#
);
let ev = HostEvent {
seq: 1,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::ClientDisconnected {
client: ClientRef {
name: "Deck".into(),
fingerprint: Some("b1c2".into()),
plane: Plane::Gamestream,
},
reason: DisconnectReason::Timeout,
},
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":1,"ts_ms":1700000000000,"schema":1,"kind":"client.disconnected","client":{"name":"Deck","fingerprint":"b1c2","plane":"gamestream"},"reason":"timeout"}"#
);
let ev = HostEvent {
seq: 2,
ts_ms: 1_700_000_000_000,
schema: 1,
kind: EventKind::HostStopping,
};
assert_eq!(
serde_json::to_string(&ev).unwrap(),
r#"{"seq":2,"ts_ms":1700000000000,"schema":1,"kind":"host.stopping"}"#
);
}
#[test]
fn wire_shape_roundtrips() {
let ev = HostEvent {
seq: 7,
ts_ms: 3,
schema: 1,
kind: EventKind::PairingPending {
device: DeviceRef {
name: "iPad Pro".into(),
fingerprint: "ab12".into(),
plane: Plane::Native,
},
},
};
let json = serde_json::to_string(&ev).unwrap();
let back: HostEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back.seq, 7);
assert_eq!(back.kind.name(), "pairing.pending");
match back.kind {
EventKind::PairingPending { device } => {
assert_eq!(device.name, "iPad Pro");
assert_eq!(device.plane, Plane::Native);
}
other => panic!("wrong kind: {other:?}"),
}
}
}
@@ -61,7 +61,7 @@ pub struct GamepadFrame {
// These are `pub const` aliases rather than a `pub use` re-export on purpose: on Windows the sole
// consumer (the Linux uinput map) is cfg'd out, and an unused re-export lints as an error there,
// whereas an unused `pub const` does not. The values still come only from core, so they can't drift;
// the exact wire values are pinned by `punktfunk1.rs::gamepad_wire_bits_are_pinned`.
// the exact wire values are pinned by `native.rs::gamepad_wire_bits_are_pinned`.
use punktfunk_core::input::gamepad as wire;
pub const BTN_DPAD_UP: u32 = wire::BTN_DPAD_UP;
pub const BTN_DPAD_DOWN: u32 = wire::BTN_DPAD_DOWN;
+20 -10
View File
@@ -194,13 +194,13 @@ impl AppState {
/// #5/#9) — so it is **opt-in** (`serve --gamestream`) and gated on a trusted LAN.
pub fn serve(
mgmt: crate::mgmt::Options,
native: crate::punktfunk1::NativeServe,
native: crate::native::NativeServe,
gamestream: bool,
) -> Result<()> {
let host = Host::detect()?;
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
// The shared streaming-stats recorder: one handle for the mgmt API, the GameStream encode loop
// (via `AppState`), and the native punktfunk/1 loops (passed to `punktfunk1::serve`).
// (via `AppState`), and the native punktfunk/1 loops (passed to `native::serve`).
let stats = crate::stats_recorder::StatsRecorder::new(crate::stats_recorder::default_dir());
let state = Arc::new(AppState::new(host, identity, stats.clone()));
// The native plane always runs, so the shared native-pairing handle (linking the QUIC ceremony
@@ -241,8 +241,15 @@ pub fn serve(
rt.block_on(async move {
// rustls needs a process-wide crypto provider before any TLS config is built.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let native_opts = crate::punktfunk1::native_serve_opts(&native);
if gamestream {
let native_opts = crate::native::native_serve_opts(&native);
// Lifecycle events (RFC §4): `host.started` as the serve planes come up; `host.stopping`
// when they wind down (clean end OR error exit) — the ring holds it for a consumer that
// reconnects, and a graceful-signal path can move the emit earlier when one exists.
crate::events::emit(crate::events::EventKind::HostStarted {
version: env!("CARGO_PKG_VERSION").to_string(),
gamestream,
});
let served: anyhow::Result<()> = if gamestream {
// Unified host: GameStream compat planes + native + mgmt. The `_nvstream` advert is
// fatal on failure when enabled (Moonlight clients can't find the host without it) —
// `--no-mdns` / PUNKTFUNK_MDNS=0 skips it for multicast-dead environments (stock
@@ -270,8 +277,9 @@ pub fn serve(
stats.clone(),
gamestream
),
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
)?;
crate::native::serve(native_opts, native.mgmt_port, np, stats.clone()),
)
.map(|_| ())
} else {
// Secure default: native punktfunk/1 + management API only (no GameStream surface).
tracing::info!(
@@ -287,10 +295,12 @@ pub fn serve(
stats.clone(),
gamestream
),
crate::punktfunk1::serve(native_opts, native.mgmt_port, np, stats.clone()),
)?;
}
Ok(())
crate::native::serve(native_opts, native.mgmt_port, np, stats.clone()),
)
.map(|_| ())
};
crate::events::emit(crate::events::EventKind::HostStopping);
served
})
}
@@ -266,6 +266,15 @@ impl Pairing {
super::save_paired(&store);
}
tracing::info!(uniqueid, "pairing phase 4 complete — client cert pinned");
// Lifecycle event, plane parity with `NativePairing::add` (RFC §4). GameStream
// pairing has no device name — the client's uniqueid is the identity it presents.
crate::events::emit(crate::events::EventKind::PairingCompleted {
device: crate::events::DeviceRef {
name: uniqueid.to_string(),
fingerprint: hex::encode(crypto::sha256(&[s.client_cert_der.as_slice()])),
plane: crate::events::Plane::Gamestream,
},
});
Ok(paired_xml("", true))
} else {
tracing::warn!(
+44 -7
View File
@@ -58,9 +58,31 @@ pub fn start(
.spawn(move || {
// Same scheduling posture as the native path's capture/encode thread (Linux nice -10 /
// Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux.
crate::punktfunk1::boost_thread_priority(true);
crate::native::boost_thread_priority(true);
tracing::info!(?cfg, "video stream starting");
if let Err(e) = run(
// Lifecycle events, plane parity with the native loop (RFC §4): the RTSP layer
// carries no client device name, so `client` is empty here — the `plane` field is
// what hooks key on. `client.connected` fires alongside `stream.started` because a
// Moonlight client has no persistent connection to anchor it to.
let event_stream = crate::events::StreamRef {
mode: crate::events::mode_str(cfg.width, cfg.height, cfg.fps),
hdr: cfg.hdr,
client: String::new(),
app: app.as_ref().map(|a| a.title.clone()),
plane: crate::events::Plane::Gamestream,
};
let event_client = crate::events::ClientRef {
name: String::new(),
fingerprint: None,
plane: crate::events::Plane::Gamestream,
};
crate::events::emit(crate::events::EventKind::StreamStarted {
stream: event_stream.clone(),
});
crate::events::emit(crate::events::EventKind::ClientConnected {
client: event_client.clone(),
});
let result = run(
cfg,
app.as_ref(),
&running,
@@ -68,10 +90,25 @@ pub fn start(
&rfi_range,
&video_cap,
&stats,
) {
);
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
// an error return is `error`. The compat plane can't tell a user stop from an idle
// vanish the way the native plane's typed close code can.
let reason = match &result {
Ok(()) => crate::events::DisconnectReason::Quit,
Err(_) => crate::events::DisconnectReason::Error,
};
if let Err(e) = result {
tracing::error!(error = %format!("{e:#}"), "video stream failed");
}
running.store(false, Ordering::SeqCst);
crate::events::emit(crate::events::EventKind::StreamStopped {
stream: event_stream,
});
crate::events::emit(crate::events::EventKind::ClientDisconnected {
client: event_client,
reason,
});
tracing::info!("video stream stopped");
});
}
@@ -227,7 +264,7 @@ fn run(
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
/// retargeting) exactly like the native plane (punktfunk1.rs resolve_compositor), create a virtual
/// retargeting) exactly like the native plane (native.rs resolve_compositor), create a virtual
/// output at the client's mode, and capture it. Returns the capturer (it owns the output's keepalive;
/// the stateless VirtualDisplay factory is dropped here) plus the resolved compositor. An apps.json
/// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to
@@ -242,7 +279,7 @@ fn open_gs_virtual_source(
} else {
// Windows has a single virtual-display backend (pf-vdisplay); `vdisplay::open` ignores the
// compositor arg there, so short-circuit the Linux session-detection state machine with a
// placeholder — mirrors `punktfunk1::resolve_compositor`. Without this, the Linux `detect()`
// placeholder — mirrors `native::resolve_compositor`. Without this, the Linux `detect()`
// below bails on Windows ("could not detect compositor … XDG_CURRENT_DESKTOP=''"), which
// killed the GameStream video thread → black screen (the native plane was already guarded).
#[cfg(target_os = "windows")]
@@ -460,7 +497,7 @@ fn spawn_packetizer(
.name("punktfunk-pkt".into())
.spawn(move || {
// Above-normal, like the send thread — this stage is on the per-frame critical path.
crate::punktfunk1::boost_thread_priority(false);
crate::native::boost_thread_priority(false);
while let Ok(frame) = rx.recv() {
let mut batch: PacketBatch = Vec::new();
for (au, ft, idx) in frame.aus {
@@ -498,7 +535,7 @@ fn spawn_sender(
.spawn(move || {
// Transmit thread: above-normal, matching the native path's send thread (includes the
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
crate::punktfunk1::boost_thread_priority(false);
crate::native::boost_thread_priority(false);
let budget = frame_interval.mul_f32(0.75);
let cfg = crate::send_pacing::PaceCfg {
burst_bytes: None, // no microburst stage — the whole frame spreads
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
//! Artwork cache + background warmer: the on-disk poster cache, the per-store fetchers, and the
//! `fetch_box_art` dispatch the management art proxy serves from. Split out of the `library` facade (plan §W5).
use super::*;
/// The persisted art cache: GameEntry id → resolved [`Artwork`]. An entry's PRESENCE means "already
/// resolved" (even an empty Artwork = fetched, none found) so the warmer never re-fetches it.
fn art_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Artwork>> {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, Artwork>>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
let loaded = std::fs::read_to_string(art_cache_path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
std::sync::Mutex::new(loaded)
})
}
/// The art cache lives in the canonical HOST config dir (`%ProgramData%\punktfunk` on Windows /
/// `~/.config/punktfunk` on Linux — gamestream::config_dir, NOT the legacy XDG/HOME `config_dir`
/// below that the custom store still uses).
fn art_cache_path() -> PathBuf {
crate::gamestream::config_dir().join("library-art-cache.json")
}
/// The cached art for a library id, if it has been resolved (positive or negative). `None` = not yet
/// warmed → the provider shows title-only until the warmer fills it in.
pub(crate) fn cached_art(id: &str) -> Option<Artwork> {
art_cache().lock().unwrap().get(id).cloned()
}
/// Record resolved art for a library id + persist the cache (write-then-rename; best-effort).
fn store_art(id: &str, art: Artwork) {
let mut cache = art_cache().lock().unwrap();
cache.insert(id.to_string(), art);
if let Ok(json) = serde_json::to_string(&*cache) {
let path = art_cache_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
/// Start the host-lifetime cover-art warmer: every few minutes, fetch + cache art for any library
/// entry whose store needs a network lookup (GOG / Xbox) and isn't cached yet. Idempotent — once
/// everything is cached a pass makes no network calls (and a host with only self-art stores never
/// fetches at all). Call once from `serve()`; the returned handle can be dropped to detach it.
pub fn start_art_warmer() -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("pf-art-warmer".into())
.spawn(|| loop {
warm_art_once();
std::thread::sleep(std::time::Duration::from_secs(300));
})
.expect("spawn art warmer thread")
}
/// One warming pass: resolve uncached GOG/Xbox art. Other stores carry their own art (Steam CDN
/// template, Heroic CDN URLs, Lutris data: URLs, custom user URLs) and are skipped.
fn warm_art_once() {
for g in all_games() {
if cached_art(&g.id).is_some() {
continue;
}
let Some((store, localid)) = g.id.split_once(':') else {
continue;
};
let art = match store {
"gog" => fetch_gog_art(localid),
// The xbox id is the StoreId when present, else the PFN (contains '_', no displaycatalog
// entry) → cache empty for those so they aren't retried every pass.
"xbox" if !localid.contains('_') => fetch_xbox_art(localid),
"xbox" => Artwork::default(),
_ => continue, // steam/heroic/lutris/custom resolve their own art
};
store_art(&g.id, art);
}
}
/// HTTP GET + parse JSON with a bounded timeout. `None` on any network/parse failure (best-effort —
/// art is non-essential, so a failure just leaves the title-only card).
fn fetch_json(url: &str) -> Option<serde_json::Value> {
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build();
let body = agent.get(url).call().ok()?.into_string().ok()?;
serde_json::from_str(&body).ok()
}
/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles
/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET
/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme,
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
pub(crate) fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
use base64::Engine as _;
use std::io::Read as _;
if let Some(rest) = url.strip_prefix("data:") {
// data:[<mediatype>][;base64],<payload>
let (meta, data) = rest.split_once(',')?;
let ctype = meta
.split(';')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("image/jpeg")
.to_string();
let bytes = if meta.contains(";base64") {
base64::engine::general_purpose::STANDARD
.decode(data)
.ok()?
} else {
data.as_bytes().to_vec()
};
return (!bytes.is_empty()).then_some((bytes, ctype));
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return None;
}
let agent = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(10))
.build();
let resp = agent.get(url).call().ok()?;
let ctype = resp
.header("Content-Type")
.unwrap_or("image/jpeg")
.to_string();
let mut bytes = Vec::new();
resp.into_reader()
.take(8 * 1024 * 1024)
.read_to_end(&mut bytes)
.ok()?;
(!bytes.is_empty()).then_some((bytes, ctype))
}
/// Resolve + fetch the best box-art cover for a library id (the GameStream `/appasset` proxy — Moonlight
/// fetches per-app covers from the HOST, not the CDN, so we proxy the bytes). Tries the portrait (tall
/// capsule Moonlight wants) → header → hero → logo, returning the first that fetches as
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
/// async runtime (e.g. `spawn_blocking`).
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
// Steam's `Artwork` fields are now relative proxy paths (see `steam_art`) the *client* resolves
// against the host — meaningless to `fetch_image`, which expects an absolute URL. Resolve
// those kinds directly instead of going through the URL fields.
if let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
{
return [
ArtKind::Portrait,
ArtKind::Header,
ArtKind::Hero,
ArtKind::Logo,
]
.into_iter()
.find_map(|kind| steam_art_bytes(appid, kind));
}
let g = all_games().into_iter().find(|g| g.id == id)?;
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
.into_iter()
.flatten()
.find_map(|url| fetch_image(&url))
}
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
fn abs_url(u: &str) -> String {
u.strip_prefix("//")
.map(|rest| format!("https://{rest}"))
.unwrap_or_else(|| u.to_string())
}
/// GOG cover art via the public (no-auth) product API. Field names / URL shapes are GOG-specific and
/// best-effort (worth on-box confirmation); a wrong URL just degrades to the title card client-side.
fn fetch_gog_art(product_id: &str) -> Artwork {
let Some(v) = fetch_json(&format!(
"https://api.gog.com/products/{product_id}?expand=images"
)) else {
return Artwork::default();
};
let img = |k: &str| {
v.get("images")
.and_then(|i| i.get(k))
.and_then(|u| u.as_str())
.map(abs_url)
};
Artwork {
portrait: img("verticalCover"),
hero: img("background"),
logo: img("logo2x"),
header: img("logo"),
}
}
/// Xbox cover art via the (unofficial, no-auth) Microsoft display catalog, keyed by StoreId. Best-
/// effort: the endpoint is internal/unstable, so on drift this just yields no art (title-only).
fn fetch_xbox_art(store_id: &str) -> Artwork {
let Some(v) = fetch_json(&format!(
"https://displaycatalog.mp.microsoft.com/v7.0/products/{store_id}?market=US&languages=en-us&fieldsTemplate=Details"
)) else {
return Artwork::default();
};
let images = v
.get("Products")
.and_then(|p| p.as_array())
.and_then(|a| a.first())
.and_then(|p| p.get("LocalizedProperties"))
.and_then(|l| l.as_array())
.and_then(|a| a.first())
.and_then(|lp| lp.get("Images"))
.and_then(|i| i.as_array());
let mut art = Artwork::default();
for img in images.into_iter().flatten() {
let (Some(purpose), Some(uri)) = (
img.get("ImagePurpose").and_then(|v| v.as_str()),
img.get("Uri").and_then(|v| v.as_str()),
) else {
continue;
};
let url = abs_url(uri);
match purpose {
"Poster" => art.portrait = Some(url),
"SuperHeroArt" | "Hero" => art.hero = Some(url),
"Logo" => art.logo = Some(url),
"BoxArt" => art.header = Some(url),
_ => {}
}
}
art
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn art_kind_parses_known_names_only() {
assert_eq!(ArtKind::parse("portrait"), Some(ArtKind::Portrait));
assert_eq!(ArtKind::parse("hero"), Some(ArtKind::Hero));
assert_eq!(ArtKind::parse("logo"), Some(ArtKind::Logo));
assert_eq!(ArtKind::parse("header"), Some(ArtKind::Header));
assert_eq!(ArtKind::parse("background"), None);
}
#[test]
fn fetch_image_decodes_data_url() {
// "Hi" base64 == "SGk=" — the data: branch is pure (no network), so it's deterministic.
let (bytes, ctype) = fetch_image("data:image/png;base64,SGk=").expect("data url decodes");
assert_eq!(bytes, b"Hi");
assert_eq!(ctype, "image/png");
// A non-image scheme is rejected (no launcher art ever points at file://, but be defensive).
assert!(fetch_image("file:///etc/passwd").is_none());
// Empty payload → None (never serve a 0-byte cover).
assert!(fetch_image("data:image/png;base64,").is_none());
}
}
+159
View File
@@ -0,0 +1,159 @@
//! User-curated custom store: CRUD (add/update/delete) over the persisted custom entries the web
//! console manages, and their mapping onto the uniform `GameEntry`. Split out of the `library` facade (plan §W5).
use super::*;
/// A user-added title, persisted in `~/.config/punktfunk/library.json`. Same shape the API
/// returns and the web console edits.
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct CustomEntry {
/// Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).
pub id: String,
pub title: String,
#[serde(default)]
pub art: Artwork,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launch: Option<LaunchSpec>,
}
/// Request body to create or replace a custom entry (no `id` — the host owns it).
#[derive(Clone, Debug, Deserialize, ToSchema)]
pub struct CustomInput {
pub title: String,
#[serde(default)]
pub art: Artwork,
#[serde(default)]
pub launch: Option<LaunchSpec>,
}
impl From<CustomEntry> for GameEntry {
fn from(c: CustomEntry) -> Self {
GameEntry {
id: format!("custom:{}", c.id),
store: "custom".into(),
title: c.title,
art: c.art,
launch: c.launch,
}
}
}
fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("punktfunk")
}
fn custom_path() -> PathBuf {
config_dir().join("library.json")
}
/// Load the custom entries (empty + non-fatal if the file is absent or malformed).
pub fn load_custom() -> Vec<CustomEntry> {
match std::fs::read_to_string(custom_path()) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "library.json malformed — ignoring custom entries");
Vec::new()
}),
Err(_) => Vec::new(),
}
}
fn save_custom(entries: &[CustomEntry]) -> Result<()> {
let dir = config_dir();
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(entries)?;
// Write-then-rename so a crash mid-write never truncates the catalog.
let tmp = custom_path().with_extension("json.tmp");
std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, custom_path()).context("rename library.json")?;
Ok(())
}
/// 12 hex chars from the title + wall-clock nanos — collision-free in practice, no uuid dep.
fn new_id(title: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6])
}
/// Create a custom entry, returning it with its assigned id.
pub fn add_custom(input: CustomInput) -> Result<CustomEntry> {
let mut entries = load_custom();
let entry = CustomEntry {
id: new_id(&input.title),
title: input.title,
art: input.art,
launch: input.launch,
};
entries.push(entry.clone());
save_custom(&entries)?;
emit_changed();
Ok(entry)
}
/// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id.
pub fn update_custom(id: &str, input: CustomInput) -> Result<Option<CustomEntry>> {
let mut entries = load_custom();
let Some(slot) = entries.iter_mut().find(|e| e.id == id) else {
return Ok(None);
};
slot.title = input.title;
slot.art = input.art;
slot.launch = input.launch;
let updated = slot.clone();
save_custom(&entries)?;
emit_changed();
Ok(Some(updated))
}
/// Delete a custom entry. `false` ⇒ no entry with that id.
pub fn delete_custom(id: &str) -> Result<bool> {
let mut entries = load_custom();
let before = entries.len();
entries.retain(|e| e.id != id);
if entries.len() == before {
return Ok(false);
}
save_custom(&entries)?;
emit_changed();
Ok(true)
}
/// The custom-entry mutations are the only library writes today, all operator-driven — hence
/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands).
fn emit_changed() {
crate::events::emit(crate::events::EventKind::LibraryChanged {
source: "manual".to_string(),
});
}
/// A digits-only Steam appid: the sole client-influenced part of a Steam launch, validated before it
/// is interpolated into any command / URI (so a client-sent id can never carry shell or URI syntax).
/// Cross-platform — used by the Linux shell mapping ([`command_for`]) and the Windows spawn mapping
/// ([`windows_launch_for`]).
pub(crate) fn valid_steam_appid(value: &str) -> bool {
!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_entry_maps_to_game_entry() {
let g: GameEntry = CustomEntry {
id: "abc123".into(),
title: "My ROM".into(),
art: Artwork::default(),
launch: None,
}
.into();
assert_eq!(g.id, "custom:abc123");
assert_eq!(g.store, "custom");
}
}
+241
View File
@@ -0,0 +1,241 @@
//! Epic Games Store provider: installed manifests + the catalog-cache art index + launch URIs. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads the Epic Games Launcher's local install manifests. Windows-only. Best-effort: empty when
/// the launcher (or its manifest dir) isn't present.
#[cfg(windows)]
pub struct EpicProvider;
#[cfg(windows)]
impl LibraryProvider for EpicProvider {
fn store(&self) -> &'static str {
"epic"
}
fn list(&self) -> Vec<GameEntry> {
let data = epic_data_dir();
let Ok(rd) = std::fs::read_dir(data.join("Manifests")) else {
return Vec::new();
};
// Parse the (best-effort) artwork cache ONCE: catalogItemId -> Artwork.
let art = epic_art_index(&data.join("Catalog").join("catcache.bin"));
let mut games = Vec::new();
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("item") {
continue;
}
// `.item` manifests are small JSON; cap the read so a planted giant can't OOM the host.
let Some(bytes) = read_capped(&p, 1024 * 1024) else {
continue;
};
let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
if let Some(g) = epic_entry(&v, &art) {
games.push(g);
}
}
games
}
}
/// `%ProgramData%\Epic\EpicGamesLauncher\Data` (machine-wide, SYSTEM-readable).
#[cfg(windows)]
fn epic_data_dir() -> PathBuf {
std::env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("Epic")
.join("EpicGamesLauncher")
.join("Data")
}
/// Map one `.item` manifest to a [`GameEntry`], or `None` if it isn't a launchable game. Uses
/// Playnite's proven EXCLUSION filter (skip `UE_*` Unreal components; skip a DLC/addon unless it is
/// `addons/launchable`) rather than a positive `games`-category match, which can drop legit titles.
#[cfg(windows)]
fn epic_entry(
v: &serde_json::Value,
art: &std::collections::HashMap<String, Artwork>,
) -> Option<GameEntry> {
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
let app_name = s("AppName")?.to_string();
if app_name.starts_with("UE_") {
return None; // Unreal Engine component, not a game
}
let cats: Vec<&str> = v
.get("AppCategories")
.and_then(|c| c.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str()).collect())
.unwrap_or_default();
if cats.contains(&"addons") && !cats.contains(&"addons/launchable") {
return None; // non-launchable DLC/addon
}
// Drop stale records whose install dir is gone.
let install = s("InstallLocation")?;
if !Path::new(install).is_dir() {
return None;
}
let title = s("DisplayName").unwrap_or(&app_name).to_string();
let namespace = s("CatalogNamespace").unwrap_or("");
let catalog = s("CatalogItemId").unwrap_or("");
// The robust launch form is the namespace:catalogItemId:appName triple; fall back to the bare
// appName when those ids are absent (some manifests lack them) — never drop the launch entirely.
let value = if !namespace.is_empty() && !catalog.is_empty() {
format!("{namespace}:{catalog}:{app_name}")
} else {
app_name.clone()
};
Some(GameEntry {
id: format!("epic:{app_name}"),
store: "epic".into(),
title,
art: art.get(catalog).cloned().unwrap_or_default(),
launch: Some(LaunchSpec {
kind: "epic".into(),
value,
}),
})
}
/// Read a launcher cache/manifest with a hard size cap, so a local unprivileged user can't plant a
/// multi-GB file under the launcher's (Users-writable) data dir that OOMs the privileged host when
/// it's loaded — then base64/JSON-decoded into further copies — during library enumeration
/// (security-review 2026-06-28 S4). Returns `None` if missing, empty, or over `max`. Mirrors the
/// Linux lutris-art reader's 1 MiB cap.
#[cfg(windows)]
fn read_capped(path: &Path, max: u64) -> Option<Vec<u8>> {
let meta = std::fs::metadata(path).ok()?;
if meta.len() == 0 || meta.len() > max {
if meta.len() > max {
tracing::warn!(path = %path.display(), len = meta.len(), max, "launcher cache exceeds size cap — skipping");
}
return None;
}
std::fs::read(path).ok()
}
/// Best-effort parse of `catcache.bin` (base64-encoded JSON array of catalog items) into
/// catalogItemId → [`Artwork`] from each item's `keyImages`. Empty map on any read/decode failure
/// (the format is community-reverse-engineered + can lag a fresh install → titles just show no art).
#[cfg(windows)]
fn epic_art_index(catcache: &Path) -> std::collections::HashMap<String, Artwork> {
use base64::Engine as _;
let mut map = std::collections::HashMap::new();
// 32 MiB cap: comfortably fits a real catalog cache, blocks a planted giant (S4).
let Some(raw) = read_capped(catcache, 32 * 1024 * 1024) else {
return map;
};
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(raw) else {
return map;
};
let Ok(items) = serde_json::from_slice::<serde_json::Value>(&decoded) else {
return map;
};
let Some(arr) = items.as_array() else {
return map;
};
for item in arr {
let Some(cat) = item
.get("id")
.or_else(|| item.get("catalogItemId"))
.and_then(|v| v.as_str())
else {
continue;
};
let Some(images) = item.get("keyImages").and_then(|v| v.as_array()) else {
continue;
};
let mut art = Artwork::default();
for img in images {
let (Some(ty), Some(url)) = (
img.get("type").and_then(|v| v.as_str()),
img.get("url").and_then(|v| v.as_str()),
) else {
continue;
};
if !(url.starts_with("http://") || url.starts_with("https://")) {
continue;
}
match ty {
"DieselGameBoxTall" => art.portrait = Some(url.to_string()),
"DieselGameBox" => art.hero = Some(url.to_string()),
"DieselGameBoxLogo" => art.logo = Some(url.to_string()),
_ => {}
}
}
if art.portrait.is_some() || art.hero.is_some() || art.logo.is_some() {
map.insert(cat.to_string(), art);
}
}
map
}
/// Build the `com.epicgames.launcher://` launch URI from a stored launch value — the triple
/// `<namespace>:<catalogItemId>:<appName>` (colons URL-encoded), or a bare `<appName>` fallback.
/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection.
#[cfg(windows)]
pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
let ok = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
let inner = match value.split(':').collect::<Vec<_>>().as_slice() {
[ns, cat, app] if ok(ns) && ok(cat) && ok(app) => format!("{ns}%3A{cat}%3A{app}"),
[app] if ok(app) => (*app).to_string(),
_ => return None,
};
Some(format!(
"com.epicgames.launcher://apps/{inner}?action=launch&silent=true"
))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn epic_filters_and_builds_launch() {
let dir = std::env::temp_dir().join(format!("pf-epic-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let inst = dir.to_string_lossy().into_owned();
let empty = std::collections::HashMap::new();
// Normal game with the full triple → kept, triple launch value.
let game = serde_json::json!({
"AppName": "Fortnite", "DisplayName": "Fortnite", "CatalogNamespace": "fn",
"CatalogItemId": "abc123", "InstallLocation": inst.clone(),
"AppCategories": ["public", "games", "applications"]
});
let e = epic_entry(&game, &empty).expect("game kept");
assert_eq!(e.id, "epic:Fortnite");
assert_eq!(e.launch.as_ref().unwrap().value, "fn:abc123:Fortnite");
// UE component, non-launchable addon, and a missing install dir are all skipped.
let ue = serde_json::json!({"AppName":"UE_5.3","InstallLocation":inst.clone(),"AppCategories":["engines"]});
assert!(epic_entry(&ue, &empty).is_none());
let dlc =
serde_json::json!({"AppName":"DLC","InstallLocation":inst,"AppCategories":["addons"]});
assert!(epic_entry(&dlc, &empty).is_none());
let gone = serde_json::json!({"AppName":"Gone","InstallLocation":"C:\\nope-xyz","AppCategories":["games"]});
assert!(epic_entry(&gone, &empty).is_none());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(windows)]
#[test]
fn epic_launch_uri_triple_bare_and_guard() {
assert_eq!(
epic_launch_uri("fn:abc:Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/fn%3Aabc%3AFortnite?action=launch&silent=true")
);
assert_eq!(
epic_launch_uri("Fortnite").as_deref(),
Some("com.epicgames.launcher://apps/Fortnite?action=launch&silent=true")
);
assert!(epic_launch_uri("bad part:x:y").is_none()); // a space → rejected
assert!(epic_launch_uri("").is_none());
}
}
+159
View File
@@ -0,0 +1,159 @@
//! GOG Galaxy store provider: installed games from the Galaxy DB + play-task launch resolution. Split out of the `library` facade (plan §W5).
use super::art::cached_art;
use super::*;
/// Reads the GOG.com install registry + per-game `.info` files. Windows-only. Best-effort: empty
/// when GOG isn't installed.
#[cfg(windows)]
pub struct GogProvider;
#[cfg(windows)]
impl LibraryProvider for GogProvider {
fn store(&self) -> &'static str {
"gog"
}
fn list(&self) -> Vec<GameEntry> {
gog_games()
}
}
#[cfg(windows)]
fn gog_games() -> Vec<GameEntry> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
// 32-bit GOG writes under WOW6432Node; a 64-bit process reads the explicit path directly.
let Ok(games_key) =
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey("SOFTWARE\\WOW6432Node\\GOG.com\\Games")
else {
return Vec::new();
};
let mut out = Vec::new();
for sub in games_key.enum_keys().flatten() {
// The subkey name IS the GOG product id.
let Ok(k) = games_key.open_subkey(&sub) else {
continue;
};
let Ok(path) = k.get_value::<String, _>("PATH") else {
continue;
};
if !Path::new(&path).is_dir() {
continue;
}
let title = k
.get_value::<String, _>("GAMENAME")
.unwrap_or_else(|_| sub.clone());
// Resolve the primary play task (exe + args + workdir) from goggame-<id>.info; skip if absent.
let Some((exe, args, workdir)) = gog_play_task(&path, &sub) else {
continue;
};
let id = format!("gog:{sub}");
// Art (public api.gog.com) is resolved off the hot path by the background warmer; read
// whatever it has cached (title-only until warmed).
let art = cached_art(&id).unwrap_or_default();
out.push(GameEntry {
id,
store: "gog".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "gog".into(),
value: format!("{exe}\t{args}\t{workdir}"),
}),
});
}
out
}
/// The primary play task from `<install>\goggame-<id>.info`: `(absolute exe, args, working dir)`.
/// Prefers `isPrimary` + `FileTask`, else the first `FileTask`. Paths are resolved against `install`.
#[cfg(windows)]
fn gog_play_task(install: &str, id: &str) -> Option<(String, String, String)> {
let text =
std::fs::read_to_string(Path::new(install).join(format!("goggame-{id}.info"))).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
let tasks = v.get("playTasks")?.as_array()?;
let is_file =
|t: &serde_json::Value| t.get("type").and_then(|s| s.as_str()) == Some("FileTask");
let pick = tasks
.iter()
.find(|t| {
t.get("isPrimary")
.and_then(|b| b.as_bool())
.unwrap_or(false)
&& is_file(t)
})
.or_else(|| tasks.iter().find(|t| is_file(t)))?;
let rel = pick.get("path").and_then(|s| s.as_str())?;
let exe = Path::new(install).join(rel);
let args = pick
.get("arguments")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let workdir = pick
.get("workingDir")
.and_then(|s| s.as_str())
.map(|w| Path::new(install).join(w))
.unwrap_or_else(|| Path::new(install).to_path_buf());
Some((
exe.to_string_lossy().into_owned(),
args,
workdir.to_string_lossy().into_owned(),
))
}
/// Build the spawn `(command line, working dir)` for a `gog` launch value (`exe \t args \t workdir`,
/// all host-resolved from the operator's own disk). Direct exe — no shell, no Galaxy.
#[cfg(windows)]
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
format!("\"{exe}\" {args}")
};
Some((cmdline, workdir))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
}
#[cfg(windows)]
#[test]
fn gog_play_task_picks_primary_filetask() {
let dir = std::env::temp_dir().join(format!("pf-gog-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let id = "1207658924";
std::fs::write(
dir.join(format!("goggame-{id}.info")),
r#"{"playTasks":[
{"isPrimary":false,"type":"FileTask","path":"other.exe"},
{"isPrimary":true,"type":"FileTask","path":"bin\\game.exe","arguments":"-w","workingDir":"bin"}
]}"#,
)
.unwrap();
let (exe, args, wd) = gog_play_task(&dir.to_string_lossy(), id).unwrap();
std::fs::remove_dir_all(&dir).ok();
assert!(exe.ends_with("bin\\game.exe"), "exe={exe}");
assert_eq!(args, "-w");
assert!(wd.ends_with("bin"), "wd={wd}");
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Heroic (Epic/GOG) store provider: installed games from Heroic's JSON stores + CDN art. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads Heroic Games Launcher's local library cache. One provider surfaces all three of Heroic's
/// backends (legendary=Epic, gog=GOG, nile=Amazon). Linux-only for now (Heroic on Windows uses a
/// different config path and the launch path isn't wired there yet).
#[cfg(target_os = "linux")]
pub struct HeroicProvider;
#[cfg(target_os = "linux")]
impl LibraryProvider for HeroicProvider {
fn store(&self) -> &'static str {
"heroic"
}
fn list(&self) -> Vec<GameEntry> {
let Some(root) = heroic_root() else {
return Vec::new();
};
let mut games = Vec::new();
// (cache file, runner id, the electron-store data key holding the games array)
for (file, runner, key) in [
("legendary_library.json", "legendary", "library"),
("gog_library.json", "gog", "games"),
("nile_library.json", "nile", "library"),
] {
let path = root.join("store_cache").join(file);
match heroic_games(&path, runner, key) {
Ok(mut g) => games.append(&mut g),
Err(e) => {
tracing::debug!(error = %e, file, "heroic store_cache not read (store unused?)")
}
}
}
games
}
}
/// The first existing Heroic config root: `$XDG_CONFIG_HOME/heroic`, classic `~/.config/heroic`, or
/// the Flatpak path.
#[cfg(target_os = "linux")]
fn heroic_root() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") {
candidates.push(PathBuf::from(d).join("heroic"));
}
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
candidates.push(home.join(".config/heroic"));
candidates.push(home.join(".var/app/com.heroicgameslauncher.hgl/config/heroic"));
}
candidates.into_iter().find(|p| p.is_dir())
}
/// Parse one runner's `store_cache/*_library.json` (an electron-store object whose `key` holds the
/// games array). Keeps only installed titles whose install dir still exists (the latter works around
/// Heroic's gog `is_installed` bug, #2691). Art comes straight from the cached public CDN URLs.
#[cfg(target_os = "linux")]
fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<GameEntry>> {
let raw = std::fs::read_to_string(path)?;
let root: serde_json::Value = serde_json::from_str(&raw)?;
let arr = root
.get(key)
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("no '{key}' array in {}", path.display()))?;
let mut games = Vec::new();
for g in arr {
if !g
.get("is_installed")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
continue; // the cache also lists owned-but-not-installed titles
}
let install_ok = g
.get("install")
.and_then(|i| i.get("install_path"))
.and_then(|p| p.as_str())
.is_some_and(|p| Path::new(p).is_dir());
if !install_ok {
continue;
}
let Some(app_name) = g
.get("app_name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
else {
continue;
};
let title = g
.get("title")
.and_then(|v| v.as_str())
.unwrap_or(app_name)
.to_string();
// Only emit http(s) art (sideloaded titles can carry local file:// paths the client can't fetch).
let http = |k: &str| {
g.get(k)
.and_then(|v| v.as_str())
.filter(|s| s.starts_with("http://") || s.starts_with("https://"))
.map(String::from)
};
let art = Artwork {
portrait: http("art_square"),
header: http("art_cover"),
hero: http("art_background").or_else(|| http("art_cover")),
logo: http("art_logo"),
};
games.push(GameEntry {
id: format!("heroic:{runner}:{app_name}"),
store: "heroic".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "heroic".into(),
value: format!("{runner}:{app_name}"),
}),
});
}
Ok(games)
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
/// renders into that gamescope) and stays hidden via `--no-gui`; but if a Heroic GUI is ALREADY
/// running on the box, the spawned process forwards the URI and exits, which would tear the session
/// down. The validated path is the fresh-session case; needs live confirmation on a box with Heroic.
#[cfg(target_os = "linux")]
pub(crate) fn heroic_command(value: &str) -> Option<String> {
let (runner, app) = value.split_once(':')?;
if !matches!(runner, "legendary" | "gog" | "nile") {
return None;
}
// appName charset (Epic alnum, GOG digits, Amazon alnum) — keep the URI a single safe token.
if app.is_empty()
|| !app
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return None;
}
let prefix = heroic_launch_prefix()?;
// No quotes: gamescope spawns the app by `split_whitespace()`, and the URI has no spaces (appName
// is validated above) so it stays a single argv token; `&` is fine (exec'd, not shell-parsed).
Some(format!(
"{prefix} --no-gui heroic://launch?appName={app}&runner={runner}"
))
}
/// How to invoke Heroic: the native `heroic` binary if on `PATH`, else the Flatpak app if its data
/// root is present. `None` ⇒ Heroic not found, so no launch command.
#[cfg(target_os = "linux")]
fn heroic_launch_prefix() -> Option<String> {
let on_path = std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|d| d.join("heroic").is_file()));
if on_path {
return Some("heroic".into());
}
let flatpak = std::env::var_os("HOME")
.map(PathBuf::from)
.is_some_and(|h| h.join(".var/app/com.heroicgameslauncher.hgl").is_dir());
flatpak.then(|| "flatpak run com.heroicgameslauncher.hgl".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn heroic_games_parses_installed_with_cdn_art() {
let dir = std::env::temp_dir().join(format!("pf-heroic-test-{}", std::process::id()));
let install = dir.join("game-install");
std::fs::create_dir_all(&install).unwrap();
let path = dir.join("legendary_library.json");
let json = format!(
r#"{{"library":[
{{"app_name":"Quail","title":"Quail","is_installed":true,
"install":{{"install_path":"{inst}"}},
"art_square":"https://cdn/quail_tall.jpg","art_cover":"https://cdn/quail_wide.jpg",
"art_logo":"file:///local/logo.png"}},
{{"app_name":"Owned","title":"Owned Only","is_installed":false,
"install":{{"install_path":"{inst}"}}}}
]}}"#,
inst = install.display()
);
std::fs::write(&path, json).unwrap();
let games = heroic_games(&path, "legendary", "library").unwrap();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(games.len(), 1); // the uninstalled title is filtered out
assert_eq!(games[0].id, "heroic:legendary:Quail");
assert_eq!(games[0].title, "Quail");
assert_eq!(
games[0].art.portrait.as_deref(),
Some("https://cdn/quail_tall.jpg")
);
assert_eq!(
games[0].art.header.as_deref(),
Some("https://cdn/quail_wide.jpg")
);
assert!(games[0].art.logo.is_none()); // file:// art is dropped (client can't fetch it)
let l = games[0].launch.as_ref().unwrap();
assert_eq!(
(l.kind.as_str(), l.value.as_str()),
("heroic", "legendary:Quail")
);
}
}
+363
View File
@@ -0,0 +1,363 @@
//! Title launch: resolve a library id / raw command into an executable command line (per-store +
//! per-OS), and the gamescope-session launch helpers. Split out of the `library` facade (plan §W5).
use super::custom::valid_steam_appid;
#[cfg(target_os = "linux")]
use super::heroic::heroic_command;
use super::*;
#[cfg(windows)]
use super::{epic::epic_launch_uri, gog::gog_spawn};
/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell
/// command the host should run for it — looked up in the host's OWN library so a client can only
/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a
/// malformed Steam appid.
///
/// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows
/// there is no gamescope to nest into; the host launches a title into the interactive user session
/// via [`launch_title`] instead.
///
/// - `steam_appid` → `steam steam://rungameid/<appid>` (appid validated as digits).
/// - `command` → the stored command verbatim. This string comes from the host's own custom store
/// (added by the host operator via the admin UI), never from the client, so it is trusted.
#[cfg(not(windows))]
pub fn launch_command(id: &str) -> Option<String> {
let spec = all_games().into_iter().find(|g| g.id == id)?.launch?;
command_for(&spec)
}
/// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of
/// [`launch_command`], split out so the appid-validation can be tested without a Steam install).
#[cfg(not(windows))]
fn command_for(spec: &LaunchSpec) -> Option<String> {
match spec.kind.as_str() {
"steam_appid" => valid_steam_appid(&spec.value)
.then(|| format!("steam steam://rungameid/{}", spec.value)),
// Lutris: a digits-only pga.db game id (same guard as steam_appid) → its run URI.
#[cfg(target_os = "linux")]
"lutris_id" => (!spec.value.is_empty() && spec.value.bytes().all(|b| b.is_ascii_digit()))
.then(|| format!("lutris lutris:rungameid/{}", spec.value)),
// Heroic: `<runner>:<appName>` → the validated heroic://launch command (see heroic_command).
#[cfg(target_os = "linux")]
"heroic" => heroic_command(&spec.value),
// Trusted: the command comes from the host's own custom store, never the client.
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
_ => None,
}
}
/// Windows: launch a store-qualified library id into the **interactive user session** — the Windows
/// analogue of the Linux gamescope-nested [`launch_command`]. The id is resolved against the host's
/// OWN library (the client never sends a command), mapped to a concrete process by
/// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`].
///
/// Wired into the data plane *after* capture is live, so the title renders onto the already-captured
/// desktop and grabs foreground.
#[cfg(windows)]
pub fn launch_title(id: &str) -> Result<()> {
let spec = all_games()
.into_iter()
.find(|g| g.id == id)
.and_then(|g| g.launch)
.ok_or_else(|| anyhow::anyhow!("no launchable library entry '{id}'"))?;
let (cmdline, workdir) = windows_launch_for(&spec).ok_or_else(|| {
anyhow::anyhow!(
"library entry '{id}' has no Windows launch recipe (kind '{}')",
spec.kind
)
})?;
let pid = crate::interactive::spawn_in_active_session(&cmdline, workdir.as_deref())
.with_context(|| format!("launch '{id}' in the interactive session"))?;
tracing::info!(launch_id = id, %cmdline, pid, "launched library title in the interactive session");
Ok(())
}
/// Windows: map a resolved [`LaunchSpec`] to a `(command line, working dir)` to spawn into the
/// interactive session. Pure + unit-testable. `None` = no Windows recipe for this kind.
///
/// CreateProcessAsUserW does NO shell or protocol resolution, so the URI/flags are handed to a
/// concrete EXE as plain arguments — a (host-derived) URI string can never reach a command interpreter.
#[cfg(windows)]
fn windows_launch_for(spec: &LaunchSpec) -> Option<(String, Option<std::path::PathBuf>)> {
match spec.kind.as_str() {
"steam_appid" => {
if !valid_steam_appid(&spec.value) {
return None;
}
let uri = format!("steam://rungameid/{}", spec.value);
// Prefer launching Steam.exe with the URI as an argument; fall back to explorer.exe, which
// resolves the steam:// handler from the user hive. (The appid is digits-validated, so the
// only variable part of the line is a number either way.)
let cmdline = match steam_exe() {
Some(exe) => format!("\"{}\" \"{uri}\"", exe.display()),
None => format!("explorer.exe \"{uri}\""),
};
Some((cmdline, None))
}
// Epic: open the (host-built, validated) com.epicgames.launcher:// URI via explorer.exe — a
// concrete EXE that resolves the registered protocol handler as the user; the URI is a single
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
"epic" => epic_launch_uri(&spec.value).map(|uri| (format!("explorer.exe \"{uri}\""), None)),
// GOG: spawn the resolved game exe directly (host-derived from goggame-<id>.info), no Galaxy.
"gog" => gog_spawn(&spec.value),
// Xbox/Game Pass: activate the UWP/GDK package by its AUMID (<PFN>!<AppId>) via explorer's
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
// SYSTEM/session-0; spawn_in_active_session uses the user token). Guard the charset (the value
// is host-derived from MicrosoftGame.config + AppRepository, but belt-and-suspenders).
"aumid" => {
let valid = spec.value.split_once('!').is_some_and(|(pfn, app)| {
let part = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
};
part(pfn) && part(app)
});
valid.then(|| {
(
format!("explorer.exe \"shell:AppsFolder\\{}\"", spec.value),
None,
)
})
}
// Operator-typed custom command (host-owned, never client-set): run it through the shell in the
// interactive session. `cmd.exe /c` is acceptable here precisely because the value is operator
// input — the same trust as the operator typing it — not a client-influenced string.
"command" => {
let v = spec.value.trim();
(!v.is_empty()).then(|| (format!("cmd.exe /c {v}"), None))
}
_ => None,
}
}
/// Windows: the default Steam install's `steam.exe`, if present. A non-default Steam install dir
/// (registry `Valve\Steam\InstallPath`) isn't covered — the explorer.exe protocol fallback handles
/// that case. Mirrors [`steam_roots`]' "default Program Files dirs" approach.
#[cfg(windows)]
fn steam_exe() -> Option<std::path::PathBuf> {
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
let p = std::path::PathBuf::from(pf).join("Steam").join("steam.exe");
if p.is_file() {
return Some(p);
}
}
}
None
}
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// through the compositor-aware [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
// client-influenced string (same trust as the custom-store `command` kind).
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
.context("spawn gamestream command in the interactive session")?;
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
Ok(())
}
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
/// only ever pick an existing title — never inject a command. Linux resolves the id via
/// [`launch_command`] and goes through [`launch_session_command`] instead.
#[cfg(windows)]
pub fn launch_gamestream_library(id: &str) -> Result<()> {
launch_title(id)
}
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
/// desktop/session rather than tearing the stream down.
///
/// * **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (the process
/// env was retargeted at it by `apply_session_env`, and the per-session virtual output is
/// promoted primary), so a plain spawn lands the app on the streamed output.
/// * **gamescope (managed / SteamOS / attach)** — the app must open *inside* the running gamescope
/// session: spawned with the session's own `DISPLAY`/Wayland env
/// ([`crate::vdisplay::launch_into_gamescope_session`]). A `steam steam://…` command additionally
/// forwards over the running Steam instance's own pipe, so the dominant Steam case is
/// env-independent.
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
#[cfg(target_os = "linux")]
pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> {
let cmd = cmd.trim();
anyhow::ensure!(!cmd.is_empty(), "empty command");
let child = match compositor {
crate::vdisplay::Compositor::Gamescope => {
crate::vdisplay::launch_into_gamescope_session(cmd)?
}
_ => std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.spawn()
.context("spawn launch command")?,
};
tracing::info!(
command = %cmd,
pid = child.id(),
compositor = compositor.id(),
"launched app into the live session"
);
Ok(())
}
/// Resolve the launch command for a session app selection on Linux: a store-qualified library id
/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an
/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started).
#[cfg(target_os = "linux")]
pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option<String> {
if let Some(id) = library_id {
match launch_command(id) {
Some(cmd) => return Some(cmd),
None => tracing::warn!(
launch_id = id,
"requested launch id not in this host's library (or no launch recipe) — ignoring"
),
}
}
command
.map(str::trim)
.filter(|c| !c.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(windows))]
#[test]
fn launch_command_resolves_and_guards() {
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "570".into(),
};
assert_eq!(
command_for(&steam).as_deref(),
Some("steam steam://rungameid/570")
);
// A non-numeric "appid" (e.g. a client trying to inject) is rejected, never interpolated.
let evil = LaunchSpec {
kind: "steam_appid".into(),
value: "570; rm -rf ~".into(),
};
assert_eq!(command_for(&evil), None);
// Custom commands (from the host's own store) pass through verbatim.
let custom = LaunchSpec {
kind: "command".into(),
value: "dolphin-emu --batch".into(),
};
assert_eq!(command_for(&custom).as_deref(), Some("dolphin-emu --batch"));
// Empty / unknown kinds → no command.
assert_eq!(
command_for(&LaunchSpec {
kind: "command".into(),
value: " ".into()
}),
None
);
assert_eq!(
command_for(&LaunchSpec {
kind: "wat".into(),
value: "x".into()
}),
None
);
}
#[cfg(target_os = "linux")]
#[test]
fn command_for_lutris_and_heroic_guards() {
// Lutris: digits → its run URI; a non-numeric id (injection attempt) is rejected.
assert_eq!(
command_for(&LaunchSpec {
kind: "lutris_id".into(),
value: "42".into()
})
.as_deref(),
Some("lutris lutris:rungameid/42")
);
assert_eq!(
command_for(&LaunchSpec {
kind: "lutris_id".into(),
value: "42; rm -rf ~".into()
}),
None
);
// Heroic guards (independent of whether Heroic is installed): bad runner / appName → None.
assert_eq!(heroic_command("badrunner:Quail"), None);
assert_eq!(heroic_command("legendary:bad name"), None);
assert_eq!(heroic_command("nile:"), None);
// When Heroic IS resolvable (a dev box), a valid id yields the launch URI; on CI (no Heroic)
// it's None — assert the URI shape only when a launcher prefix exists.
if let Some(cmd) = heroic_command("legendary:Quail-1.2_x") {
assert!(cmd.contains("heroic://launch?appName=Quail-1.2_x&runner=legendary"));
assert!(cmd.contains("--no-gui"));
}
}
#[cfg(windows)]
#[test]
fn windows_launch_for_maps_and_guards() {
// Steam: a digits-only appid → a steam:// URI line (via Steam.exe or explorer.exe, depending
// on the box) with no working dir.
let steam = LaunchSpec {
kind: "steam_appid".into(),
value: "570".into(),
};
let (line, wd) = windows_launch_for(&steam).expect("steam recipe");
assert!(line.contains("steam://rungameid/570"), "line was {line:?}");
assert!(wd.is_none());
// A non-numeric "appid" (a client trying to inject) is rejected, never interpolated.
let evil = LaunchSpec {
kind: "steam_appid".into(),
value: "570\" & calc".into(),
};
assert!(windows_launch_for(&evil).is_none());
// Operator command → cmd /c passthrough (trusted host input).
let cmd = LaunchSpec {
kind: "command".into(),
value: "notepad.exe".into(),
};
assert_eq!(
windows_launch_for(&cmd).unwrap().0,
"cmd.exe /c notepad.exe"
);
// Xbox AUMID → explorer shell:AppsFolder activation; a value without '!' is rejected.
let aumid = LaunchSpec {
kind: "aumid".into(),
value: "Microsoft.X_8wekyb3d8bbwe!Game".into(),
};
assert_eq!(
windows_launch_for(&aumid).unwrap().0,
"explorer.exe \"shell:AppsFolder\\Microsoft.X_8wekyb3d8bbwe!Game\""
);
assert!(windows_launch_for(&LaunchSpec {
kind: "aumid".into(),
value: "no-bang".into()
})
.is_none());
// Empty / unknown kinds → no recipe.
assert!(windows_launch_for(&LaunchSpec {
kind: "command".into(),
value: " ".into()
})
.is_none());
assert!(windows_launch_for(&LaunchSpec {
kind: "wat".into(),
value: "x".into()
})
.is_none());
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Lutris store provider: installed games from the Lutris SQLite DB + lutris.net CDN art. Split out of the `library` facade (plan §W5).
use super::*;
/// Reads the **local** Lutris library DB (`pga.db`) — no network. Installed titles only; cover art
/// from Lutris's on-disk cache, inlined as `data:` URLs. Linux-only (Lutris is Linux-only).
#[cfg(target_os = "linux")]
pub struct LutrisProvider;
#[cfg(target_os = "linux")]
impl LibraryProvider for LutrisProvider {
fn store(&self) -> &'static str {
"lutris"
}
fn list(&self) -> Vec<GameEntry> {
let Some(db) = lutris_db() else {
return Vec::new();
};
lutris_games(&db).unwrap_or_else(|e| {
tracing::warn!(error = %e, db = %db.display(), "lutris pga.db read failed — skipping");
Vec::new()
})
}
}
/// The first existing Lutris `pga.db`: XDG data dir, the classic `~/.local/share`, or Flatpak.
#[cfg(target_os = "linux")]
fn lutris_db() -> Option<PathBuf> {
let mut candidates = Vec::new();
if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
candidates.push(PathBuf::from(d).join("lutris/pga.db"));
}
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
candidates.push(home.join(".local/share/lutris/pga.db"));
candidates.push(home.join(".var/app/net.lutris.Lutris/data/lutris/pga.db"));
}
candidates.into_iter().find(|p| p.is_file())
}
/// Installed games from a Lutris `pga.db`. Opened **read-only + immutable** (via a SQLite URI) so a
/// running Lutris holding the file can't make us block or fail, and we never write to it.
#[cfg(target_os = "linux")]
fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
use rusqlite::OpenFlags;
// `immutable=1` treats the DB as read-only-and-unchanging → no locking against a live Lutris. The
// path goes into the URI literally; a `?`/`#` in it (vanishingly rare on Linux) would mis-parse,
// so fall back to a plain read-only open in that case.
let path = db.to_string_lossy();
let conn = if path.contains('?') || path.contains('#') {
rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?
} else {
rusqlite::Connection::open_with_flags(
format!("file:{path}?immutable=1"),
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)?
};
let mut stmt = conn.prepare(
"SELECT id, slug, name FROM games \
WHERE installed = 1 AND name IS NOT NULL AND name <> '' \
ORDER BY name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
))
})?;
let mut games = Vec::new();
for (id, slug, name) in rows.flatten() {
games.push(GameEntry {
id: format!("lutris:{id}"),
store: "lutris".into(),
title: name,
art: slug.as_deref().map(lutris_art).unwrap_or_default(),
launch: Some(LaunchSpec {
kind: "lutris_id".into(),
value: id.to_string(),
}),
});
}
Ok(games)
}
/// Lutris cover art (local files keyed by slug) inlined as `data:` URLs — Lutris has no public CDN
/// keyed by a stable id (unlike Steam/Heroic), and `Artwork` fields are URLs the client fetches, so a
/// self-contained `data:` URL needs no host-served endpoint. `coverart` → portrait, `banners` → header.
#[cfg(target_os = "linux")]
fn lutris_art(slug: &str) -> Artwork {
Artwork {
portrait: lutris_image("coverart", slug),
header: lutris_image("banners", slug),
..Default::default()
}
}
/// Find `<kind>/<slug>.jpg` across the current (0.5.18+), legacy (`~/.cache`), and Flatpak Lutris
/// dirs and inline it as `data:image/jpeg;base64,…`. Skips a missing or implausibly large file (a
/// 1 MiB cap bounds the catalog JSON so a few big files can't bloat it).
#[cfg(target_os = "linux")]
fn lutris_image(kind: &str, slug: &str) -> Option<String> {
use base64::Engine as _;
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let roots = [
home.join(".local/share/lutris"),
home.join(".cache/lutris"),
home.join(".var/app/net.lutris.Lutris/data/lutris"),
home.join(".var/app/net.lutris.Lutris/cache/lutris"),
];
for root in roots {
let p = root.join(kind).join(format!("{slug}.jpg"));
let Ok(meta) = std::fs::metadata(&p) else {
continue;
};
if meta.len() == 0 || meta.len() > 1024 * 1024 {
continue;
}
if let Ok(bytes) = std::fs::read(&p) {
let enc = base64::engine::general_purpose::STANDARD.encode(&bytes);
return Some(format!("data:image/jpeg;base64,{enc}"));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn lutris_games_reads_installed_only() {
use rusqlite::Connection;
let dir = std::env::temp_dir().join(format!("pf-lutris-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("pga.db");
{
let c = Connection::open(&db).unwrap();
c.execute_batch(
"CREATE TABLE games (id INTEGER PRIMARY KEY, slug TEXT, name TEXT, installed INTEGER);
INSERT INTO games (id,slug,name,installed) VALUES (42,'elden-ring','ELDEN RING',1);
INSERT INTO games (id,slug,name,installed) VALUES (7,'owned','Owned Only',0);
INSERT INTO games (id,slug,name,installed) VALUES (9,'noname',NULL,1);",
)
.unwrap();
}
let games = lutris_games(&db).unwrap();
std::fs::remove_dir_all(&dir).ok();
// Only the installed, named row; the uninstalled + NULL-name rows are filtered out.
assert_eq!(games.len(), 1);
assert_eq!(games[0].id, "lutris:42");
assert_eq!(games[0].store, "lutris");
assert_eq!(games[0].title, "ELDEN RING");
let l = games[0].launch.as_ref().unwrap();
assert_eq!((l.kind.as_str(), l.value.as_str()), ("lutris_id", "42"));
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Steam store provider: installed-title scan (local `libraryfolders.vdf` + app manifests, no
//! API key) and Steam-CDN / local-`librarycache` artwork. Split out of the `library` facade (plan §W5).
use super::art::fetch_image;
use super::*;
/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come
/// from `steamapps/appmanifest_<appid>.acf`; extra library folders from
/// `steamapps/libraryfolders.vdf`; artwork from the public Steam CDN by appid.
pub struct SteamProvider;
impl LibraryProvider for SteamProvider {
fn store(&self) -> &'static str {
"steam"
}
fn list(&self) -> Vec<GameEntry> {
let mut by_appid: std::collections::BTreeMap<u32, String> = Default::default();
for steamapps in steam_library_dirs() {
for (appid, name) in scan_manifests(&steamapps) {
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
}
}
by_appid
.into_iter()
.filter(|(appid, name)| !is_steam_tool(*appid, name))
.map(|(appid, title)| GameEntry {
id: format!("steam:{appid}"),
store: "steam".into(),
title,
art: steam_art(appid),
launch: Some(LaunchSpec {
kind: "steam_appid".into(),
value: appid.to_string(),
}),
})
.collect()
}
}
/// The Steam CDN poster/hero/logo/header for an appid — relative proxy paths the *client* resolves
/// against the host it just talked to (so they work the same whichever interface/port the client
/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a
/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it.
fn steam_art(appid: u32) -> Artwork {
let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}"));
Artwork {
portrait: url("portrait"),
hero: url("hero"),
logo: url("logo"),
header: url("header"),
}
}
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
/// literally what the user's Steam client already shows for this title), the legacy flat CDN URL
/// as a fallback. `None` when neither has it (the client then falls through to its next art
/// candidate). Blocking (disk + network) — call off the async runtime.
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
steam_local_art_bytes(appid, kind).or_else(|| {
let url = format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
kind.cdn_filename()
);
fetch_image(&url)
})
}
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
/// (these run well under 2 MiB in practice) while bounding a pathological file.
const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024;
/// `appcache/librarycache/<appid>/<hash>/<filename>` across every Steam root, for whichever
/// `<hash>` subdirectory actually has this kind's file (Steam reuses one hash dir per asset
/// version, so there's normally exactly one candidate per kind).
fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
steam_roots()
.into_iter()
.find_map(|root| find_local_art_file(&root, appid, kind))
.and_then(|path| {
let bytes = std::fs::read(&path).ok()?;
let ctype = if path.extension().is_some_and(|e| e == "png") {
"image/png"
} else {
"image/jpeg"
};
Some((bytes, ctype.to_string()))
})
}
/// Find this kind's cached file under one Steam root's `appcache/librarycache/<appid>/<hash>/`,
/// trying each hash subdirectory (normally just one) and each candidate filename in priority
/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain
/// directory fixture.
fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
let cache_dir = root
.join("appcache")
.join("librarycache")
.join(appid.to_string());
let hash_dirs = std::fs::read_dir(&cache_dir).ok()?;
for hash_dir in hash_dirs.flatten() {
for name in kind.local_filenames() {
let path = hash_dir.path().join(name);
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
return Some(path);
}
}
}
None
}
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
#[cfg(not(target_os = "windows"))]
fn steam_roots() -> Vec<PathBuf> {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Vec::new();
};
let candidates = [
home.join(".local/share/Steam"),
home.join(".steam/steam"),
home.join(".steam/root"),
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), // Flatpak Steam
];
steam_roots_existing(candidates)
}
/// Windows Steam roots: the default install dirs under Program Files. Games installed on other
/// drives are still found via each root's `libraryfolders.vdf` (see [`steam_library_dirs`]). A
/// non-default Steam install dir (registry `Valve\Steam\InstallPath`) isn't covered yet.
#[cfg(target_os = "windows")]
fn steam_roots() -> Vec<PathBuf> {
let mut candidates = Vec::new();
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
candidates.push(PathBuf::from(pf).join("Steam"));
}
}
steam_roots_existing(candidates)
}
/// Keep only the candidate roots that exist (have a `steamapps` dir), canonicalized + deduped.
fn steam_roots_existing(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut roots = Vec::new();
for c in candidates {
if let Ok(canon) = c.canonicalize() {
if canon.join("steamapps").is_dir() && seen.insert(canon.clone()) {
roots.push(canon);
}
}
}
roots
}
/// Every `steamapps` dir holding installed titles: each root's own, plus the extra library
/// folders listed in `libraryfolders.vdf` (Steam lets you install games on other drives).
fn steam_library_dirs() -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut dirs = Vec::new();
let mut push = |steamapps: PathBuf, dirs: &mut Vec<PathBuf>| {
if let Ok(canon) = steamapps.canonicalize() {
if canon.is_dir() && seen.insert(canon.clone()) {
dirs.push(canon);
}
}
};
for root in steam_roots() {
let steamapps = root.join("steamapps");
if let Ok(text) = std::fs::read_to_string(steamapps.join("libraryfolders.vdf")) {
for path in vdf_paths(&text) {
push(PathBuf::from(path).join("steamapps"), &mut dirs);
}
}
push(steamapps, &mut dirs);
}
dirs
}
/// Pull every `"path" "<dir>"` value out of a `libraryfolders.vdf`. We don't need a full VDF
/// parser for the two flat fields we read. On Windows the values are backslash-escaped
/// (`D:\\SteamLibrary`), so unescape `\\` → `\`; Linux paths need no unescaping.
fn vdf_paths(text: &str) -> Vec<String> {
text.lines()
.filter_map(|l| vdf_value(l.trim(), "path"))
.map(|p| {
#[cfg(target_os = "windows")]
{
p.replace("\\\\", "\\")
}
#[cfg(not(target_os = "windows"))]
{
p.to_string()
}
})
.collect()
}
/// `"<key>" "<value>"` on a single line → `<value>`. Used for both VDF and ACF flat fields.
fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let rest = line.strip_prefix(&format!("\"{key}\""))?;
let after = &rest[rest.find('"')? + 1..];
Some(&after[..after.find('"')?])
}
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles.
fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
let Ok(rd) = std::fs::read_dir(steamapps) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let fname = entry.file_name();
let fname = fname.to_string_lossy();
if !(fname.starts_with("appmanifest_") && fname.ends_with(".acf")) {
continue;
}
if let Ok(text) = std::fs::read_to_string(entry.path()) {
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
out.push((appid, name.to_string()));
}
}
}
out
}
/// Steam installs runtimes/redistributables as "apps" too — keep them out of a *game* library.
fn is_steam_tool(appid: u32, name: &str) -> bool {
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
const TOOL_IDS: &[u32] = &[228980, 1070560, 1391110, 1628350, 1493710];
if TOOL_IDS.contains(&appid) {
return true;
}
let n = name.to_ascii_lowercase();
n.contains("proton")
|| n.starts_with("steam linux runtime")
|| n.contains("steamworks common")
|| n.contains("steamvr")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vdf_value_extracts_quoted_field() {
assert_eq!(
vdf_value("\"path\"\t\t\"/mnt/games/SteamLibrary\"", "path"),
Some("/mnt/games/SteamLibrary")
);
assert_eq!(vdf_value("\"appid\"\t\t\"570\"", "appid"), Some("570"));
assert_eq!(vdf_value("\"name\"\t\t\"Dota 2\"", "name"), Some("Dota 2"));
assert_eq!(vdf_value("\"installdir\"\t\t\"x\"", "appid"), None);
}
#[test]
fn vdf_paths_pulls_all_library_folders() {
let vdf = r#"
"libraryfolders"
{
"0"
{
"path" "/home/u/.local/share/Steam"
"apps" { "570" "123" }
}
"1"
{
"path" "/mnt/ssd/SteamLibrary"
}
}
"#;
assert_eq!(
vdf_paths(vdf),
vec![
"/home/u/.local/share/Steam".to_string(),
"/mnt/ssd/SteamLibrary".to_string()
]
);
}
#[test]
fn tools_are_filtered_but_games_kept() {
assert!(is_steam_tool(228980, "Steamworks Common Redistributables"));
assert!(is_steam_tool(1493710, "Proton Experimental"));
assert!(is_steam_tool(0, "Steam Linux Runtime 3.0 (sniper)"));
assert!(!is_steam_tool(570, "Dota 2"));
assert!(!is_steam_tool(1245620, "ELDEN RING"));
}
#[test]
fn steam_art_points_at_the_host_art_proxy() {
let art = steam_art(570);
assert_eq!(
art.portrait.as_deref(),
Some("/api/v1/library/art/steam:570/portrait")
);
assert_eq!(
art.header.as_deref(),
Some("/api/v1/library/art/steam:570/header")
);
}
#[test]
fn find_local_art_file_matches_the_hashed_librarycache_layout() {
let dir = tempfile::tempdir().unwrap();
let cache = dir
.path()
.join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap();
let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900.jpg"));
// A kind with no cached file, and an appid with no cache dir at all, both miss cleanly.
assert_eq!(
find_local_art_file(dir.path(), 3527290, ArtKind::Hero),
None
);
assert_eq!(
find_local_art_file(dir.path(), 570, ArtKind::Portrait),
None
);
}
#[test]
fn find_local_art_file_prefers_the_2x_portrait() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("appcache/librarycache/570/somehash");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap();
std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap();
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Xbox / Microsoft Store (UWP) provider: installed packages, PFN resolution, and store art. Split out of the `library` facade (plan §W5).
use super::art::cached_art;
use super::*;
/// Reads installed Xbox / Game Pass / Store GDK games from the flat-file install dirs. Windows-only.
/// Best-effort: empty when no `XboxGames` dir exists.
#[cfg(windows)]
pub struct XboxProvider;
#[cfg(windows)]
impl LibraryProvider for XboxProvider {
fn store(&self) -> &'static str {
"xbox"
}
fn list(&self) -> Vec<GameEntry> {
xbox_games()
}
}
/// Scan each fixed drive's default `<drive>:\XboxGames` for GDK games — the presence of
/// `Content\MicrosoftGame.config` is the game marker (so we list games, not ordinary UWP apps). A
/// custom install folder (set via the undocumented `.GamingRoot`) isn't covered; the default folder
/// is the common case. Non-GDK pure-UWP Store games (under the ACL-locked WindowsApps) are missed too.
#[cfg(windows)]
fn xbox_games() -> Vec<GameEntry> {
let mut games = Vec::new();
for letter in b'C'..=b'Z' {
let root = PathBuf::from(format!("{}:\\XboxGames", letter as char));
let Ok(rd) = std::fs::read_dir(&root) else {
continue;
};
for entry in rd.flatten() {
let title_dir = entry.path();
let cfg = title_dir.join("Content").join("MicrosoftGame.config");
if !cfg.is_file() {
continue;
}
let Ok(text) = std::fs::read_to_string(&cfg) else {
continue;
};
let folder = title_dir
.file_name()
.map(|f| f.to_string_lossy().into_owned());
let Some((name, app_id, title, store_id)) = xbox_parse_config(&text, folder.as_deref())
else {
continue;
};
let Some(pfn) = xbox_pfn(&name) else {
tracing::debug!(package = %name, "xbox: no AppRepository entry → can't resolve PFN, skipping");
continue;
};
let id_key = if store_id.is_empty() {
pfn.clone()
} else {
store_id
};
let id = format!("xbox:{id_key}");
// Art (unofficial displaycatalog, keyed by StoreId) is resolved off the hot path by the
// background warmer; read whatever it has cached (title-only until warmed / if no StoreId).
let art = cached_art(&id).unwrap_or_default();
games.push(GameEntry {
id,
store: "xbox".into(),
title,
art,
launch: Some(LaunchSpec {
kind: "aumid".into(),
value: format!("{pfn}!{app_id}"),
}),
});
}
}
games.sort_by(|a, b| a.id.cmp(&b.id));
games.dedup_by(|a, b| a.id == b.id); // same game on two drives → one entry
games
}
/// Parse the fields we need from a `MicrosoftGame.config`: `(Identity Name, AppId, title, StoreId)`.
/// AppId is the `<Executable>`'s `Id` (the AUMID app id, typically "Game"). The title prefers
/// `ShellVisuals@DefaultDisplayName`, but that can be an unresolved `ms-resource:` ref → fall back to
/// the install folder name, then the package name.
#[cfg(windows)]
fn xbox_parse_config(text: &str, folder: Option<&str>) -> Option<(String, String, String, String)> {
let doc = roxmltree::Document::parse(text).ok()?;
let root = doc.root_element();
let name = root
.children()
.find(|n| n.has_tag_name("Identity"))?
.attribute("Name")?
.to_string();
let app_id = root
.children()
.find(|n| n.has_tag_name("ExecutableList"))
.and_then(|el| {
el.children()
.filter(|n| n.has_tag_name("Executable"))
.find_map(|e| e.attribute("Id"))
})?
.to_string();
let ddn = root
.children()
.find(|n| n.has_tag_name("ShellVisuals"))
.and_then(|sv| sv.attribute("DefaultDisplayName"))
.filter(|s| !s.is_empty() && !s.starts_with("ms-resource"));
let title = ddn
.map(String::from)
.or_else(|| folder.map(String::from))
.unwrap_or_else(|| name.clone());
let store_id = root
.children()
.find(|n| n.has_tag_name("StoreId"))
.and_then(|n| n.text())
.unwrap_or("")
.to_string();
Some((name, app_id, title, store_id))
}
/// Resolve a package's PackageFamilyName by finding its
/// `AppRepository\Packages\<PackageFullName>` dir (machine-wide, SYSTEM-readable) and reducing the
/// full name to `Name_PublisherHash`. This READS the authoritative PFN — never compute the hash.
#[cfg(windows)]
fn xbox_pfn(identity: &str) -> Option<String> {
let pkgs = PathBuf::from(std::env::var_os("ProgramData")?)
.join("Microsoft")
.join("Windows")
.join("AppRepository")
.join("Packages");
let prefix = format!("{identity}_");
for e in std::fs::read_dir(&pkgs).ok()?.flatten() {
let dn = e.file_name().to_string_lossy().into_owned();
if dn.starts_with(&prefix) {
if let Some(pfn) = pfn_from_full(&dn, identity) {
return Some(pfn);
}
}
}
None
}
/// PackageFamilyName from a PackageFullName dir name
/// (`Name_Version_Arch_ResourceId_PublisherHash`) → `Name_PublisherHash`. The hash is the last
/// `_`-segment; `Name` is the caller's identity.
#[cfg(windows)]
fn pfn_from_full(dir_name: &str, identity: &str) -> Option<String> {
let hash = dir_name.rsplit('_').next()?;
(!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn xbox_parse_config_and_pfn() {
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<Game configVersion="1">
<Identity Name="Microsoft.624F8B84B80" Publisher="CN=Microsoft" Version="1.0.0.0" />
<ExecutableList>
<Executable Name="gamelaunchhelper.exe" Id="Game" />
</ExecutableList>
<StoreId>9NBLGGH4R315</StoreId>
<ShellVisuals DefaultDisplayName="Halo Infinite" Square150x150Logo="x.png" />
</Game>"#;
let (name, app_id, title, store_id) = xbox_parse_config(xml, Some("HaloInfinite")).unwrap();
assert_eq!(name, "Microsoft.624F8B84B80");
assert_eq!(app_id, "Game");
assert_eq!(title, "Halo Infinite");
assert_eq!(store_id, "9NBLGGH4R315");
// An ms-resource DefaultDisplayName is unresolvable → fall back to the install folder name.
let xml2 = r#"<Game><Identity Name="Pkg.Name"/>
<ExecutableList><Executable Id="App"/></ExecutableList>
<ShellVisuals DefaultDisplayName="ms-resource:DisplayName"/></Game>"#;
let (_, app2, title2, sid2) = xbox_parse_config(xml2, Some("MyGameFolder")).unwrap();
assert_eq!(app2, "App");
assert_eq!(title2, "MyGameFolder");
assert_eq!(sid2, "");
// PackageFamilyName reduced from a PackageFullName dir name (the hash is the last segment).
assert_eq!(
pfn_from_full(
"Microsoft.624F8B84B80_1.0.0.0_x64__8wekyb3d8bbwe",
"Microsoft.624F8B84B80"
)
.as_deref(),
Some("Microsoft.624F8B84B80_8wekyb3d8bbwe")
);
assert!(pfn_from_full("NoUnderscore", "NoUnderscore").is_none());
}
}
+9 -8
View File
@@ -43,6 +43,7 @@ mod dmabuf_fence;
#[path = "linux/drm_sync.rs"]
mod drm_sync;
mod encode;
mod events;
mod gamestream;
mod gpu;
#[cfg(target_os = "linux")]
@@ -64,9 +65,9 @@ mod mgmt_token;
#[cfg(target_os = "windows")]
#[path = "windows/monitor_devnode.rs"]
mod monitor_devnode;
mod native;
mod native_pairing;
mod pipeline;
mod punktfunk1;
mod pwinit;
mod send_pacing;
#[cfg(target_os = "windows")]
@@ -296,8 +297,8 @@ fn real_main() -> Result<()> {
.map(String::as_str)
};
let source = match get("--source") {
Some("virtual") => punktfunk1::Punktfunk1Source::Virtual,
_ => punktfunk1::Punktfunk1Source::Synthetic,
Some("virtual") => native::Punktfunk1Source::Virtual,
_ => native::Punktfunk1Source::Synthetic,
};
// Fixed pairing PIN for test harnesses/CI (deterministic ceremony instead of scraping
// the logged random PIN). An empty value would arm SPAKE2 with an empty password —
@@ -306,7 +307,7 @@ fn real_main() -> Result<()> {
Some(p) if p.trim().is_empty() => bail!("--pairing-pin must not be empty"),
p => p.map(str::to_string),
};
punktfunk1::run(punktfunk1::Punktfunk1Options {
native::run(native::Punktfunk1Options {
port: get("--port").and_then(|s| s.parse().ok()).unwrap_or(9777),
source,
seconds: get("--seconds").and_then(|s| s.parse().ok()).unwrap_or(30),
@@ -316,7 +317,7 @@ fn real_main() -> Result<()> {
.unwrap_or(0),
max_concurrent: get("--max-concurrent")
.and_then(|s| s.parse().ok())
.unwrap_or(punktfunk1::DEFAULT_MAX_CONCURRENT),
.unwrap_or(native::DEFAULT_MAX_CONCURRENT),
// Secure by default: REQUIRE PIN pairing (reject unpaired clients) unless
// --allow-tofu opts into trust-on-first-use — the host then accepts unpaired
// clients and advertises pair=optional. Pairing is always armed so a PIN is
@@ -339,7 +340,7 @@ fn real_main() -> Result<()> {
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&ms| ms > 0)
.map(std::time::Duration::from_millis)
.or_else(punktfunk1::idle_timeout_from_env),
.or_else(native::idle_timeout_from_env),
mdns: !args.iter().any(|a| a == "--no-mdns") && discovery::mdns_enabled(),
})
}
@@ -369,7 +370,7 @@ fn real_main() -> Result<()> {
/// carry the inherent on-path #5/#9 weaknesses, so only on a trusted LAN). Returns the mgmt options,
/// the native host config, and whether GameStream is enabled. Native pairing is **required by default**
/// (an open host any LAN device can stream from is insecure); `--open` turns it off.
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, native::NativeServe, bool)> {
let mut opts = mgmt::Options::default();
let mut native_port: u16 = 9777; // the native plane always runs now
@@ -459,7 +460,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
if !mgmt_bind_explicit {
opts.bind = std::net::SocketAddr::from(([0, 0, 0, 0], mgmt::DEFAULT_PORT));
}
let native = punktfunk1::NativeServe {
let native = native::NativeServe {
port: native_port,
require_pairing: !open,
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
+1 -1
View File
@@ -4,7 +4,7 @@
//! disturbance on a stable multi-second period (display-topology churn, display-poller software,
//! virtual-display present timing). Random network loss is bursty and irregular; a stable period is
//! a machine, and saying so in the host log turns a "nothing in the logs :/" report into a
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`punktfunk1`) and IDD-push capture
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`native`) and IDD-push capture
//! stalls (`capture::windows::idd_push`).
use std::collections::VecDeque;
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
//! Auth gate for the management API `/api/v1` routes: paired client cert (mTLS, from anywhere)
//! or the bearer token (loopback peers only). Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use crate::gamestream::tls::PeerAddr;
use crate::gamestream::tls::PeerCertFingerprint;
use axum::extract::Request;
use axum::http::header;
use axum::http::Method;
use axum::middleware::Next;
use sha2::{Digest, Sha256};
/// 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; `/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.
pub(crate) 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
// routes (which would let one paired client unpair others, read/arm the pairing PIN, stop
// sessions, or edit the library). Everything outside the allowlist requires the operator's bearer
// token. The fingerprint is attached by `serve_https` from the verified peer cert.
if let Some(PeerCertFingerprint(Some(fp))) = req.extensions().get::<PeerCertFingerprint>() {
if cert_may_access(req.method(), req.uri().path())
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
{
return next.run(req).await;
}
}
// Otherwise require the bearer token (the web console / admin) — but only from a LOOPBACK peer.
// The token authorizes the full admin surface, so confining it to loopback keeps that surface off
// the LAN even though the listener now binds all interfaces by default (so paired clients can
// browse the library). The web console BFF — the sole token holder — always connects over
// loopback, so nothing first-party is affected; a LAN caller must use a paired client cert and is
// limited to the read-only allowlist above. (No PeerAddr ⇒ a non-`serve_https` caller, e.g. a unit
// test → treat as loopback so handler tests still authenticate by token.)
let from_loopback = req
.extensions()
.get::<PeerAddr>()
.is_none_or(|a| a.0.ip().is_loopback());
if !from_loopback {
return api_error(
StatusCode::UNAUTHORIZED,
"the admin API is loopback-only — a LAN client must present a paired client certificate",
);
}
// `run` always passes a token, so no-token means a misconfigured caller (e.g. a test constructing
// `app` directly) — deny.
let Some(expected) = st.token.as_deref() else {
return api_error(StatusCode::UNAUTHORIZED, "authentication required");
};
let presented = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
match presented {
Some(token) if token_eq(token, expected) => next.run(req).await,
_ => api_error(
StatusCode::UNAUTHORIZED,
"missing or invalid credentials (a paired client cert, or a bearer token)",
),
}
}
/// Which routes a paired *streaming* cert (mTLS, no bearer token) may reach: a small allowlist of
/// safe, read-only status routes only. Deny-by-default — every state-changing route and every route
/// that exposes a pairing PIN or the pending-approval queue requires the operator's bearer token, so
/// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions,
/// edit the library). `/health` is handled separately (always open).
pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool {
method == Method::GET
&& (matches!(
path,
"/api/v1/host"
| "/api/v1/compositors"
| "/api/v1/status"
| "/api/v1/clients"
| "/api/v1/native/clients"
// The native clients browse the game library with their cert (no bearer token); the
// library MUTATIONS (POST/PUT/DELETE /library/custom) stay token-only via the exact
// GET-path match above.
| "/api/v1/library"
) || path.starts_with("/api/v1/library/art/"))
}
/// Compare SHA-256 digests instead of the strings — constant-time with respect to the
/// secret without pulling in a ct-eq dependency.
pub(crate) fn token_eq(presented: &str, expected: &str) -> bool {
Sha256::digest(presented.as_bytes()) == Sha256::digest(expected.as_bytes())
}
+174
View File
@@ -0,0 +1,174 @@
//! Client/pairing-tagged management endpoints: paired Moonlight clients and the GameStream
//! pairing PIN flow. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use sha2::{Digest, Sha256};
/// A paired (certificate-pinned) Moonlight client.
#[derive(Serialize, ToSchema)]
pub(crate) struct PairedClient {
/// Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: String,
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
subject: Option<String>,
/// Certificate validity start (unix seconds).
not_before_unix: Option<i64>,
/// Certificate validity end (unix seconds).
not_after_unix: Option<i64>,
}
/// Pairing-flow status.
#[derive(Serialize, ToSchema)]
pub(crate) struct PairingStatus {
/// True while a pairing handshake is parked waiting for the user's PIN.
pin_pending: bool,
}
/// The PIN Moonlight displays during pairing.
#[derive(Deserialize, ToSchema)]
pub(crate) struct SubmitPin {
/// 116 ASCII digits (Moonlight shows 4).
#[schema(example = "1234")]
pin: String,
}
/// List paired clients
#[utoipa::path(
get,
path = "/clients",
tag = "clients",
operation_id = "listPairedClients",
responses(
(status = OK, description = "All certificate-pinned clients", body = [PairedClient]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_paired_clients(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<PairedClient>> {
let ders = st.app.paired.lock().unwrap().clone();
Json(ders.iter().map(|der| client_info(der)).collect())
}
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
let fingerprint = hex::encode(Sha256::digest(der));
match x509_parser::parse_x509_certificate(der) {
Ok((_, x509)) => PairedClient {
fingerprint,
subject: Some(x509.subject().to_string()),
not_before_unix: Some(x509.validity().not_before.timestamp()),
not_after_unix: Some(x509.validity().not_after.timestamp()),
},
Err(_) => PairedClient {
fingerprint,
subject: None,
not_before_unix: None,
not_after_unix: None,
},
}
}
/// Unpair a client
///
/// Removes the client's certificate from the pairing store. Caveat: the nvhttp TLS layer
/// does not yet reject unlisted certificates (`gamestream/tls.rs` accepts any well-formed
/// client cert — a planned hardening step), so until that lands this removes the client
/// from the listing without severing its ability to reconnect.
#[utoipa::path(
delete,
path = "/clients/{fingerprint}",
tag = "clients",
operation_id = "unpairClient",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
),
responses(
(status = NO_CONTENT, description = "Client unpaired"),
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
)
)]
pub(crate) async fn unpair_client(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
) -> Response {
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
return api_error(
StatusCode::BAD_REQUEST,
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
);
}
let mut paired = st.app.paired.lock().unwrap();
let before = paired.len();
paired.retain(|der| !hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint));
if paired.len() < before {
tracing::info!(fingerprint, "management API: client unpaired");
StatusCode::NO_CONTENT.into_response()
} else {
api_error(
StatusCode::NOT_FOUND,
"no paired client with that fingerprint",
)
}
}
/// Pairing-flow status
///
/// Poll this to know when to prompt the user for the PIN Moonlight displays.
#[utoipa::path(
get,
path = "/pair",
tag = "pairing",
operation_id = "getPairingStatus",
responses(
(status = OK, description = "Whether a pairing handshake is waiting for a PIN", body = PairingStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_pairing_status(State(st): State<Arc<MgmtState>>) -> Json<PairingStatus> {
Json(PairingStatus {
pin_pending: st.app.pairing.pin.awaiting_pin(),
})
}
/// Submit the pairing PIN
///
/// Delivers the PIN the Moonlight client is displaying, completing the out-of-band half
/// of the pairing handshake.
#[utoipa::path(
post,
path = "/pair/pin",
tag = "pairing",
operation_id = "submitPairingPin",
request_body = SubmitPin,
responses(
(status = NO_CONTENT, description = "PIN delivered to the waiting handshake"),
(status = BAD_REQUEST, description = "Malformed PIN or unparseable JSON body", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = CONFLICT, description = "No pairing handshake is waiting for a PIN", body = ApiError),
(status = UNSUPPORTED_MEDIA_TYPE, description = "Body is not application/json", body = ApiError),
(status = UNPROCESSABLE_ENTITY, description = "JSON body does not match the schema", body = ApiError),
)
)]
pub(crate) async fn submit_pairing_pin(
State(st): State<Arc<MgmtState>>,
ApiJson(req): ApiJson<SubmitPin>,
) -> Response {
let pin = req.pin.trim();
if pin.is_empty() || pin.len() > 16 || !pin.bytes().all(|b| b.is_ascii_digit()) {
return api_error(StatusCode::BAD_REQUEST, "pin must be 1-16 ASCII digits");
}
if !st.app.pairing.pin.awaiting_pin() {
// Refusing (rather than parking the PIN) prevents a stale PIN from silently
// satisfying a *future* pairing attempt.
return api_error(
StatusCode::CONFLICT,
"no pairing handshake is waiting for a PIN",
);
}
st.app.pairing.pin.submit(pin.to_string());
StatusCode::NO_CONTENT.into_response()
}
+416
View File
@@ -0,0 +1,416 @@
//! Display-tagged management endpoints: virtual-display policy, state, layout, and custom
//! presets. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
/// One preset's human-facing description + the fields it expands to, so the console can render a
/// preset picker with an accurate "what this does" preview without hardcoding the expansion.
#[derive(Serialize, ToSchema)]
pub(crate) struct PresetInfo {
/// The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).
id: String,
/// One-line story shown next to the option.
summary: String,
/// The effective policy this preset expands to (the same fields a `custom` policy carries).
fields: crate::vdisplay::policy::EffectivePolicy,
}
/// Full display-management state for the console: the stored policy, every preset's expansion, the
/// resolved effective policy, and which options this build actually enforces yet (Stage 0 wires
/// keep-alive linger + topology; the rest are stored but not yet acted on).
#[derive(Serialize, ToSchema)]
pub(crate) struct DisplaySettingsState {
/// The stored policy (preset + custom fields), or the built-in default when unconfigured.
settings: crate::vdisplay::policy::DisplayPolicy,
/// True once a `display-settings.json` exists (the console has configured this host).
configured: bool,
/// The effective (preset-expanded) policy currently in force.
effective: crate::vdisplay::policy::EffectivePolicy,
/// Every named preset and what it expands to (for the picker's preview).
presets: Vec<PresetInfo>,
/// The operator's saved custom presets (`display-presets.json`) — named field-bundles rendered
/// alongside the built-ins. Managed via `POST/PUT/DELETE /display/presets`; applied by writing a
/// `Custom` policy carrying the preset's fields.
custom_presets: Vec<crate::vdisplay::policy::CustomPreset>,
/// Option names this build enforces right now. All five axes are now acted on (keep_alive +
/// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
/// position apply being KWin-only, is reported per display in `/display/state`).
enforced: Vec<String>,
}
pub(crate) fn preset_summary(id: &str) -> &'static str {
match id {
"default" => "Good for most setups. Reconnects resume quickly, the stream is the whole desktop, and extra viewers each get their own screen.",
"gaming-rig" => "For a machine with no monitor that you only stream from. The game keeps running when you disconnect, and whoever connects next takes it over.",
"shared-desktop" => "For a PC you also use in person. Your real monitors are never blanked or left with a leftover display, and extra viewers each get their own screen.",
"hotdesk" => "One person at a time — roam between your own devices with an instant reconnect. Anyone else is told the box is busy.",
"workstation" => "Your multi-monitor daily driver. Displays come back exactly where you arranged them, each client keeps its own settings, and the desktop is yours alone.",
_ => "",
}
}
pub(crate) fn display_settings_state() -> DisplaySettingsState {
use crate::vdisplay::policy::{self, Preset};
let store = policy::prefs();
let settings = store.get();
let configured = store.configured().is_some();
let presets = [
("default", Preset::Default),
("gaming-rig", Preset::GamingRig),
("shared-desktop", Preset::SharedDesktop),
("hotdesk", Preset::Hotdesk),
("workstation", Preset::Workstation),
]
.into_iter()
.filter_map(|(id, p)| {
policy::preset_fields(p).map(|e| PresetInfo {
id: id.to_string(),
summary: preset_summary(id).to_string(),
fields: e,
})
})
.collect();
DisplaySettingsState {
effective: settings.effective(),
settings,
configured,
presets,
custom_presets: policy::load_custom_presets(),
enforced: vec![
"keep_alive".into(),
"topology".into(),
"mode_conflict".into(),
"identity".into(),
"layout".into(),
"game_session".into(),
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
],
}
}
/// Display-management policy
///
/// The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),
/// every preset's expansion, and which options this build enforces yet. See
/// `design/display-management.md`.
#[utoipa::path(
get,
path = "/display/settings",
tag = "display",
operation_id = "getDisplaySettings",
responses(
(status = OK, description = "Stored policy + preset expansions + enforced options", body = DisplaySettingsState),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_display_settings() -> Json<DisplaySettingsState> {
Json(display_settings_state())
}
/// Set the display-management policy
///
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
/// running session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is
/// honored (the display is Pinned; free it via `POST /display/release`).
#[utoipa::path(
put,
path = "/display/settings",
tag = "display",
operation_id = "setDisplaySettings",
request_body = crate::vdisplay::policy::DisplayPolicy,
responses(
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
(status = BAD_REQUEST, description = "Malformed policy body", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn set_display_settings(
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
) -> Response {
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist display policy: {e:#}"),
);
}
tracing::info!("management API: display policy updated");
Json(display_settings_state()).into_response()
}
/// One live or kept virtual display.
#[derive(Serialize, ToSchema)]
pub(crate) struct ApiDisplayInfo {
/// Stable-enough id for the `/display/release` `slot` argument.
slot: u64,
/// Backend name (`pf-vdisplay`, `kwin`, …).
backend: String,
/// `WIDTHxHEIGHT@HZ`.
mode: String,
/// `active` | `lingering` | `pinned`.
state: String,
/// Milliseconds until a lingering display is torn down (absent when active/pinned).
expires_in_ms: Option<u64>,
/// Live sessions holding the display.
sessions: u32,
/// Short client label, when the owner tracks it.
client: Option<String>,
/// Display group (shared desktop) id — several displays with the same group form one desktop (§6A).
group: u32,
/// This display's ordinal within its group, in acquire order (0-based).
display_index: u32,
/// Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2).
x: i32,
/// Desktop-space top-left `y`.
y: i32,
/// Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).
identity_slot: Option<u32>,
/// Effective topology for this display's group (`extend` | `primary` | `exclusive`).
topology: String,
}
/// The host's managed virtual displays right now.
#[derive(Serialize, ToSchema)]
pub(crate) struct DisplayStateResponse {
displays: Vec<ApiDisplayInfo>,
}
/// Request body for `releaseDisplay`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ReleaseDisplayRequest {
/// Slot to release (see `state`); omit to release **all** kept displays.
#[serde(default)]
slot: Option<u64>,
}
/// Result of a `/display/release`.
#[derive(Serialize, ToSchema)]
pub(crate) struct ReleaseDisplayResult {
/// Number of kept displays torn down.
released: usize,
}
/// Live virtual displays
///
/// The host's managed virtual displays right now — active (streaming), lingering (kept after
/// disconnect, counting down to teardown), or pinned (kept indefinitely). See
/// `design/display-management.md`.
#[utoipa::path(
get,
path = "/display/state",
tag = "display",
operation_id = "getDisplayState",
responses(
(status = OK, description = "The live/kept virtual displays", body = DisplayStateResponse),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_display_state() -> Json<DisplayStateResponse> {
let snap = crate::vdisplay::registry::snapshot();
Json(DisplayStateResponse {
displays: snap
.displays
.into_iter()
.map(|d| ApiDisplayInfo {
slot: d.slot,
backend: d.backend,
mode: format!("{}x{}@{}", d.mode.0, d.mode.1, d.mode.2),
state: d.state,
expires_in_ms: d.expires_in_ms,
sessions: d.sessions,
client: d.client,
group: d.group,
display_index: d.display_index,
x: d.position.0,
y: d.position.1,
identity_slot: d.identity_slot,
topology: d.topology,
})
.collect(),
})
}
/// Release kept virtual displays
///
/// Tear down lingering/pinned displays now — so a physical-screen user gets their screen back
/// without waiting out the linger. `slot` releases one; omit it to release all kept displays.
/// Active (streaming) displays are never torn down here (that is session control).
#[utoipa::path(
post,
path = "/display/release",
tag = "display",
operation_id = "releaseDisplay",
request_body = ReleaseDisplayRequest,
responses(
(status = OK, description = "The number of kept displays released", body = ReleaseDisplayResult),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn release_display(
ApiJson(req): ApiJson<ReleaseDisplayRequest>,
) -> Json<ReleaseDisplayResult> {
let released = crate::vdisplay::registry::release(req.slot);
tracing::info!(slot = ?req.slot, released, "management API: display release");
Json(ReleaseDisplayResult { released })
}
/// Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot
/// id as a string (the same id `/display/state` reports as `identity_slot`).
#[derive(Deserialize, ToSchema)]
pub(crate) struct DisplayLayoutRequest {
/// `{"<identity_slot>": {"x": …, "y": …}}` — where each arranged display's top-left sits.
#[serde(default)]
positions: std::collections::BTreeMap<String, crate::vdisplay::policy::Position>,
}
/// Arrange virtual displays
///
/// Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor
/// group (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block
/// and switched to manual mode; applied from the next connect (a live group re-applies on its next
/// acquire). Locks in the current effective behavior as explicit fields, so arranging displays never
/// silently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.
#[utoipa::path(
put,
path = "/display/layout",
tag = "display",
operation_id = "setDisplayLayout",
request_body = DisplayLayoutRequest,
responses(
(status = OK, description = "Layout stored; the new settings state", body = DisplaySettingsState),
(status = INTERNAL_SERVER_ERROR, description = "Layout could not be persisted", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Response {
let store = crate::vdisplay::policy::prefs();
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
let policy = store.get().effective().with_manual_layout(
req.positions,
store.game_session(),
store.ddc_power_off(),
store.pnp_disable_monitors(),
);
if let Err(e) = store.set(policy) {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist display layout: {e:#}"),
);
}
tracing::info!(
positions = display_settings_state().settings.layout.positions.len(),
"management API: display layout updated"
);
Json(display_settings_state()).into_response()
}
/// List the saved custom presets
///
/// The operator's named field-bundles (`display-presets.json`). These also ride the
/// `GET /display/settings` response (`custom_presets`), so the console rarely needs this directly.
#[utoipa::path(
get,
path = "/display/presets",
tag = "display",
operation_id = "listCustomPresets",
responses(
(status = OK, description = "The saved custom presets", body = Vec<crate::vdisplay::policy::CustomPreset>),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_custom_presets() -> Json<Vec<crate::vdisplay::policy::CustomPreset>> {
Json(crate::vdisplay::policy::load_custom_presets())
}
/// Save a custom preset
///
/// Stores a named bundle of the display-behavior axes (+ the game-session axis) the operator can
/// apply later. The host assigns a stable id, returned in the body. Applying a preset is a
/// `PUT /display/settings` with a `Custom` policy carrying its `fields` — no separate apply route.
#[utoipa::path(
post,
path = "/display/presets",
tag = "display",
operation_id = "createCustomPreset",
request_body = crate::vdisplay::policy::CustomPresetInput,
responses(
(status = CREATED, description = "Preset created", body = crate::vdisplay::policy::CustomPreset),
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn create_custom_preset(
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
) -> Response {
if input.name.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
}
match crate::vdisplay::policy::add_custom_preset(input) {
Ok(preset) => (StatusCode::CREATED, Json(preset)).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Update a custom preset
#[utoipa::path(
put,
path = "/display/presets/{id}",
tag = "display",
operation_id = "updateCustomPreset",
params(("id" = String, Path, description = "The custom preset id")),
request_body = crate::vdisplay::policy::CustomPresetInput,
responses(
(status = OK, description = "Preset updated", body = crate::vdisplay::policy::CustomPreset),
(status = BAD_REQUEST, description = "Empty name", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn update_custom_preset(
Path(id): Path<String>,
ApiJson(input): ApiJson<crate::vdisplay::policy::CustomPresetInput>,
) -> Response {
if input.name.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "preset name must not be empty");
}
match crate::vdisplay::policy::update_custom_preset(&id, input) {
Ok(Some(preset)) => Json(preset).into_response(),
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Delete a custom preset
///
/// Removes it from the catalog. The active policy is untouched — if this preset was the one applied,
/// the running behavior stays exactly as it was (the catalog and `display-settings.json` are decoupled).
#[utoipa::path(
delete,
path = "/display/presets/{id}",
tag = "display",
operation_id = "deleteCustomPreset",
params(("id" = String, Path, description = "The custom preset id")),
responses(
(status = NO_CONTENT, description = "Preset deleted"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No custom preset with that id", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn delete_custom_preset(Path(id): Path<String>) -> Response {
match crate::vdisplay::policy::delete_custom_preset(&id) {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom preset with that id"),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
+236
View File
@@ -0,0 +1,236 @@
//! GPU-tagged management endpoints: inventory + automatic/preferred selection. Split out of the
//! `mgmt` facade (plan §W5).
use super::shared::*;
/// One hardware GPU on the host (software/WARP adapters are never listed).
#[derive(Serialize, ToSchema)]
pub(crate) struct ApiGpu {
/// Stable identifier (`vendorid-deviceid-occurrence`, hex PCI ids) — pass to `setGpuPreference`.
/// Stable across reboots and driver updates, unlike an adapter index or LUID.
#[schema(example = "10de-2c05-0")]
id: String,
/// Adapter/marketing name.
#[schema(example = "NVIDIA GeForce RTX 5070 Ti")]
name: String,
/// `nvidia` | `amd` | `intel` | `other`.
vendor: String,
/// Dedicated VRAM in MiB (0 where the platform doesn't expose it).
vram_mb: u64,
}
/// The GPU the **next** session's pipeline will be created on, and why. (A preference change
/// applies to the next session; a running session keeps the GPU it opened on.)
#[derive(Serialize, ToSchema)]
pub(crate) struct ApiSelectedGpu {
id: String,
name: String,
/// `nvidia` | `amd` | `intel` | `other`.
vendor: String,
/// Why this GPU was selected: `preference` (the manual choice), `env`
/// (`PUNKTFUNK_RENDER_ADAPTER`), `auto` (max dedicated VRAM / platform default), or
/// `preference_missing` (a manual choice is set but that GPU is absent — auto-selected
/// instead so the host keeps streaming).
source: String,
}
/// The GPU live sessions are encoding on right now.
#[derive(Serialize, ToSchema)]
pub(crate) struct ApiActiveGpu {
/// Stable id matching an entry of `gpus` (empty for the CPU/software encoder).
id: String,
name: String,
/// `nvidia` | `amd` | `intel` | `other`.
vendor: String,
/// The encode backend in use (`nvenc` | `amf` | `qsv` | `vaapi` | `software`).
backend: String,
/// Number of live encode sessions on it.
sessions: u32,
}
/// Full GPU-selection state for the console: inventory, the persisted preference, what the next
/// session will use, and what is in use right now.
#[derive(Serialize, ToSchema)]
pub(crate) struct GpuState {
/// The host's hardware GPUs.
gpus: Vec<ApiGpu>,
/// `auto` or `manual`.
mode: String,
/// The manually preferred GPU's stable id, when one is stored (kept while `mode` is `auto` so
/// a console can offer returning to it). May reference a GPU that is currently absent.
preferred_id: Option<String>,
/// The stored name of the preferred GPU (a usable label even when it is absent).
preferred_name: Option<String>,
/// Whether the preferred GPU is currently present.
preferred_available: bool,
/// `PUNKTFUNK_RENDER_ADAPTER` (the host.env pin), when set — it applies while `mode` is
/// `auto`; a manual preference overrides it.
env_override: Option<String>,
/// The GPU the next session will use.
selected: Option<ApiSelectedGpu>,
/// The GPU live sessions use right now (absent while nothing is streaming).
active: Option<ApiActiveGpu>,
}
/// Request body for `setGpuPreference`.
#[derive(Deserialize, ToSchema)]
pub(crate) struct SetGpuPreference {
/// `auto` (env pin, else max dedicated VRAM — the default) or `manual`.
#[schema(example = "manual")]
mode: String,
/// Required when `mode` is `manual`: the stable `id` of a currently listed GPU
/// (see `listGpus`).
#[schema(example = "10de-2c05-0")]
gpu_id: Option<String>,
}
/// Build the [`GpuState`] snapshot (shared by the GET and the PUT's response).
pub(crate) fn gpu_state() -> GpuState {
let gpus = crate::gpu::enumerate();
let pref = crate::gpu::prefs().get();
let (preferred_id, preferred_name, preferred_available) = match &pref.gpu {
Some(want) => {
let found = crate::gpu::find_preferred(&gpus, want);
let id = match found {
// Canonical: the present GPU's id (identity may have matched loosely).
Some(i) => gpus[i].id.clone(),
None => format!(
"{:04x}-{:04x}-{}",
want.vendor_id, want.device_id, want.occurrence
),
};
let name = match found {
Some(i) => gpus[i].name.clone(),
None => want.name.clone(),
};
(Some(id), Some(name), found.is_some())
}
None => (None, None, false),
};
let selected = crate::gpu::selected_gpu().map(|sel| ApiSelectedGpu {
vendor: sel.info.vendor_tag().into(),
id: sel.info.id,
name: sel.info.name,
source: sel.source.tag().into(),
});
let active = crate::gpu::active().and_then(|(g, sessions)| {
(sessions > 0).then(|| ApiActiveGpu {
vendor: crate::gpu::vendor_tag(g.vendor_id).into(),
id: g.id,
name: g.name,
backend: g.backend.into(),
sessions,
})
});
GpuState {
gpus: gpus
.into_iter()
.map(|g| ApiGpu {
vendor: g.vendor_tag().into(),
vram_mb: g.vram_bytes / (1024 * 1024),
id: g.id,
name: g.name,
})
.collect(),
mode: match pref.mode {
crate::gpu::GpuMode::Auto => "auto".into(),
crate::gpu::GpuMode::Manual => "manual".into(),
},
preferred_id,
preferred_name,
preferred_available,
env_override: crate::config::config()
.render_adapter
.clone()
.filter(|s| !s.is_empty()),
selected,
active,
}
}
/// GPU inventory and selection
///
/// Lists the host's hardware GPUs, the persisted auto/manual preference, the GPU the next session
/// will use (and why), and the GPU live sessions encode on right now.
#[utoipa::path(
get,
path = "/gpus",
tag = "gpu",
operation_id = "listGpus",
responses(
(status = OK, description = "GPU inventory + selection state", body = GpuState),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_gpus() -> Json<GpuState> {
Json(gpu_state())
}
/// Set the GPU preference
///
/// `auto` restores automatic selection (`PUNKTFUNK_RENDER_ADAPTER` pin, else max dedicated VRAM);
/// `manual` pins capture + encode to the given GPU. Persisted across restarts; applies to the
/// **next** session (a running session keeps its GPU). If the preferred GPU is absent at session
/// start the host falls back to automatic selection rather than failing.
#[utoipa::path(
put,
path = "/gpus/preference",
tag = "gpu",
operation_id = "setGpuPreference",
request_body = SetGpuPreference,
responses(
(status = OK, description = "Preference stored; the new selection state", body = GpuState),
(status = BAD_REQUEST, description = "Unknown mode, or `gpu_id` missing / not a listed GPU", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Preference could not be persisted", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> Response {
let pref = match req.mode.to_ascii_lowercase().as_str() {
"auto" => {
// Keep the stored manual pick so the console can offer switching back to it.
let mut p = crate::gpu::prefs().get();
p.mode = crate::gpu::GpuMode::Auto;
p
}
"manual" => {
let Some(id) = req
.gpu_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return api_error(StatusCode::BAD_REQUEST, "mode `manual` requires `gpu_id`");
};
let Some(g) = crate::gpu::enumerate().into_iter().find(|g| g.id == id) else {
return api_error(
StatusCode::BAD_REQUEST,
"gpu_id does not match a present GPU (see GET /gpus)",
);
};
crate::gpu::GpuPreference {
mode: crate::gpu::GpuMode::Manual,
gpu: Some(crate::gpu::PreferredGpu {
vendor_id: g.vendor_id,
device_id: g.device_id,
occurrence: g.occurrence,
name: g.name,
}),
}
}
other => {
return api_error(
StatusCode::BAD_REQUEST,
&format!("unknown mode {other:?} — use `auto` or `manual`"),
)
}
};
if let Err(e) = crate::gpu::prefs().set(pref) {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist GPU preference: {e:#}"),
);
}
tracing::info!(mode = %req.mode, gpu_id = ?req.gpu_id, "management API: GPU preference updated");
Json(gpu_state()).into_response()
}
+421
View File
@@ -0,0 +1,421 @@
//! 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,
}
/// 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 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,
/// 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();
let gs_stream = *st.app.stream.lock().unwrap();
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(),
})
.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(),
})
});
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().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> {
// 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()
.map(|l| SessionInfo {
width: l.width,
height: l.height,
fps: l.fps,
})
.or_else(|| {
crate::session_status::snapshot()
.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(),
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,
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()),
})
}
+144
View File
@@ -0,0 +1,144 @@
//! Library-tagged management endpoints: installed-store + custom game entries and box art.
//! Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use axum::http::header;
/// List the game library
///
/// Every installed-store title (Steam, read from the host's local files — no Steam API key)
/// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client
/// fetches directly (the public Steam CDN for Steam titles).
#[utoipa::path(
get,
path = "/library",
tag = "library",
operation_id = "getLibrary",
responses(
(status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_library() -> Json<Vec<crate::library::GameEntry>> {
Json(crate::library::all_games())
}
/// Add a custom library entry
///
/// Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied
/// artwork URLs. The host assigns a stable id, returned in the body.
#[utoipa::path(
post,
path = "/library/custom",
tag = "library",
operation_id = "createCustomGame",
request_body = crate::library::CustomInput,
responses(
(status = CREATED, description = "Entry created", body = crate::library::CustomEntry),
(status = BAD_REQUEST, description = "Empty title", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn create_custom_game(
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
match crate::library::add_custom(input) {
Ok(entry) => (StatusCode::CREATED, Json(entry)).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Update a custom library entry
#[utoipa::path(
put,
path = "/library/custom/{id}",
tag = "library",
operation_id = "updateCustomGame",
params(("id" = String, Path, description = "The custom entry id (without the `custom:` prefix)")),
request_body = crate::library::CustomInput,
responses(
(status = OK, description = "Entry updated", body = crate::library::CustomEntry),
(status = BAD_REQUEST, description = "Empty title", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No custom entry with that id", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn update_custom_game(
Path(id): Path<String>,
ApiJson(input): ApiJson<crate::library::CustomInput>,
) -> Response {
if input.title.trim().is_empty() {
return api_error(StatusCode::BAD_REQUEST, "title must not be empty");
}
match crate::library::update_custom(&id, input) {
Ok(Some(entry)) => Json(entry).into_response(),
Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Delete a custom library entry
#[utoipa::path(
delete,
path = "/library/custom/{id}",
tag = "library",
operation_id = "deleteCustomGame",
params(("id" = String, Path, description = "The custom entry id (without the `custom:` prefix)")),
responses(
(status = NO_CONTENT, description = "Entry deleted"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No custom entry with that id", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError),
)
)]
pub(crate) async fn delete_custom_game(Path(id): Path<String>) -> Response {
match crate::library::delete_custom(&id) {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
/// Fetch one cover-art image for a library entry
///
/// Resolves `kind` (`portrait` | `hero` | `logo` | `header`) for the given library id and streams
/// the image bytes. For a Steam title, the host's own local Steam cache is tried first (exact —
/// it's what the user's Steam client already shows for it), the public Steam CDN's flat URL
/// convention as a fallback (newer titles' CDN assets can live at a per-asset-hash path the host
/// can't predict, in which case this 404s and the client falls through to its next art candidate).
/// Only Steam ids are backed today; any other store 404s.
#[utoipa::path(
get,
path = "/library/art/{id}/{kind}",
tag = "library",
operation_id = "getLibraryArt",
params(
("id" = String, Path, description = "The store-qualified library id, e.g. `steam:570`"),
("kind" = String, Path, description = "`portrait` | `hero` | `logo` | `header`"),
),
responses(
(status = OK, description = "Image bytes", content_type = "image/jpeg"),
(status = UNAUTHORIZED, description = "Missing or invalid credentials", body = ApiError),
(status = NOT_FOUND, description = "No art of that kind for that id", body = ApiError),
)
)]
pub(crate) async fn get_library_art(Path((id, kind)): Path<(String, String)>) -> Response {
let Some(kind) = crate::library::ArtKind::parse(&kind) else {
return api_error(StatusCode::NOT_FOUND, "unknown art kind");
};
let Some(appid) = id
.strip_prefix("steam:")
.and_then(|s| s.parse::<u32>().ok())
else {
return api_error(StatusCode::NOT_FOUND, "no art proxy for this store");
};
match tokio::task::spawn_blocking(move || crate::library::steam_art_bytes(appid, kind)).await {
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
_ => api_error(StatusCode::NOT_FOUND, "no art of that kind for this title"),
}
}
+361
View File
@@ -0,0 +1,361 @@
//! Native (punktfunk/1) pairing endpoints: arm/disarm a window, paired-device management, and
//! delegated approval of pending knocks. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
/// Native (punktfunk/1) pairing status. Unlike GameStream, the **host** mints the PIN (the SPAKE2
/// ceremony needs it client-side first), so the console **displays** `pin` for the user to enter on
/// their device — armed on demand for a short window.
#[derive(Serialize, ToSchema)]
pub(crate) struct NativePairStatus {
/// Whether the native host is running (the unified host started with `--native`).
enabled: bool,
/// True while a pairing window is open.
armed: bool,
/// The PIN to display while armed (null when disarmed).
#[schema(example = "1234")]
pin: Option<String>,
/// Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag).
expires_in_secs: Option<u64>,
/// Number of paired native clients.
paired_clients: u32,
}
/// Arm-native-pairing request body.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ArmNativePairing {
/// Window length in seconds (default 120; clamped to 15600).
#[schema(example = 120)]
ttl_secs: Option<u32>,
/// Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).
/// When set, only a pairing attempt from that fingerprint consumes the window — so an unpaired
/// LAN peer can neither pair nor burn a window armed for a specific device (security-review #9).
/// Omit for an unbound window (any device may use the PIN — trusted-LAN only).
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: Option<String>,
}
/// A paired native (punktfunk/1) client.
#[derive(Serialize, ToSchema)]
pub(crate) struct NativeClient {
/// The name the client supplied when pairing.
#[schema(example = "Living Room iPad")]
name: String,
/// Hex SHA-256 of the client certificate — its stable id here.
fingerprint: String,
}
/// An unpaired device that tried to connect while the host requires pairing — awaiting
/// **delegated approval** (approve it here instead of fetching the host PIN out of band).
#[derive(Serialize, ToSchema)]
pub(crate) struct PendingDevice {
/// Id to address approve/deny (per-process; entries expire after ~10 minutes).
id: u32,
/// Best-effort device label (the client's own name, else fingerprint-derived).
#[schema(example = "Enrico's MacBook")]
name: String,
/// Hex SHA-256 of the device's certificate — what approval pins.
fingerprint: String,
/// Seconds since the device last knocked.
age_secs: u64,
}
/// Approve-pending-device request body. Send `{}` to keep the device's own name.
#[derive(Deserialize, ToSchema)]
pub(crate) struct ApprovePending {
/// Operator-chosen label for the device (defaults to the name it knocked with).
#[schema(example = "Living Room TV")]
name: Option<String>,
}
pub(crate) fn native_status(st: &MgmtState) -> NativePairStatus {
match &st.native {
Some(np) => {
let s = np.status();
NativePairStatus {
enabled: true,
armed: s.armed,
pin: s.pin,
expires_in_secs: s.expires_in_secs,
paired_clients: s.paired_clients,
}
}
None => NativePairStatus {
enabled: false,
armed: false,
pin: None,
expires_in_secs: None,
paired_clients: 0,
},
}
}
/// Native pairing status
///
/// The native (punktfunk/1) pairing window. Poll while armed to show the PIN + countdown.
/// `enabled: false` means this host runs GameStream only (no `--native`).
#[utoipa::path(
get,
path = "/native/pair",
tag = "native",
operation_id = "getNativePairing",
responses(
(status = OK, description = "Native pairing status", body = NativePairStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_native_pairing(State(st): State<Arc<MgmtState>>) -> Json<NativePairStatus> {
Json(native_status(&st))
}
/// Arm native pairing
///
/// Opens a pairing window and mints a fresh PIN to display. The user enters it on their device
/// within `ttl_secs`; the device then appears in the native client list.
#[utoipa::path(
post,
path = "/native/pair/arm",
tag = "native",
operation_id = "armNativePairing",
request_body = ArmNativePairing,
responses(
(status = OK, description = "Pairing armed; the response carries the PIN to display", body = NativePairStatus),
(status = SERVICE_UNAVAILABLE, description = "Native host not available in this process", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn arm_native_pairing(
State(st): State<Arc<MgmtState>>,
ApiJson(req): ApiJson<ArmNativePairing>,
) -> Response {
let Some(np) = &st.native else {
return api_error(
StatusCode::SERVICE_UNAVAILABLE,
"native host not available in this process",
);
};
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
// A bound window (operator selected a specific device) is DoS-proof: only that fingerprint can
// consume it (#9). An unbound window (no fingerprint) keeps the legacy any-device behavior.
let bound = req
.fingerprint
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase());
let bound_to_device = bound.is_some();
let _pin = np.arm_for(std::time::Duration::from_secs(ttl as u64), bound);
tracing::info!(
ttl_secs = ttl,
bound_to_device,
"management API: native pairing armed"
);
Json(native_status(&st)).into_response()
}
/// Disarm native pairing
///
/// Closes the pairing window immediately (no new ceremonies accepted).
#[utoipa::path(
delete,
path = "/native/pair",
tag = "native",
operation_id = "disarmNativePairing",
responses(
(status = NO_CONTENT, description = "Pairing disarmed"),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn disarm_native_pairing(State(st): State<Arc<MgmtState>>) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
np.disarm();
StatusCode::NO_CONTENT.into_response()
}
/// List native paired clients
#[utoipa::path(
get,
path = "/native/clients",
tag = "native",
operation_id = "listNativeClients",
responses(
(status = OK, description = "Paired native clients", body = [NativeClient]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_native_clients(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<NativeClient>> {
let clients = match &st.native {
Some(np) => np
.list()
.into_iter()
.map(|c| NativeClient {
name: c.name,
fingerprint: c.fingerprint,
})
.collect(),
None => Vec::new(),
};
Json(clients)
}
/// Unpair a native client
///
/// Removes a punktfunk/1 client from the native trust store by fingerprint.
#[utoipa::path(
delete,
path = "/native/clients/{fingerprint}",
tag = "native",
operation_id = "unpairNativeClient",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 of the client certificate (case-insensitive)")
),
responses(
(status = NO_CONTENT, description = "Client unpaired"),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = NOT_FOUND, description = "No paired native client with that fingerprint", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn unpair_native_client(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.remove(&fingerprint) {
Ok(true) => {
tracing::info!(fingerprint, "management API: native client unpaired");
StatusCode::NO_CONTENT.into_response()
}
Ok(false) => api_error(
StatusCode::NOT_FOUND,
"no paired native client with that fingerprint",
),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// List devices awaiting pairing approval
///
/// Unpaired devices that tried to connect while the host requires pairing. Approve one to pair
/// it without a PIN (delegated approval); entries expire after ~10 minutes.
#[utoipa::path(
get,
path = "/native/pending",
tag = "native",
operation_id = "listPendingDevices",
responses(
(status = OK, description = "Devices awaiting approval (empty when none, or when the \
native host is not enabled)", body = Vec<PendingDevice>),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn list_pending_devices(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<PendingDevice>> {
let pending = st
.native
.as_ref()
.map(|np| np.pending())
.unwrap_or_default();
Json(
pending
.into_iter()
.map(|p| PendingDevice {
id: p.id,
name: p.name,
fingerprint: p.fingerprint,
age_secs: p.age_secs,
})
.collect(),
)
}
/// Approve a pending device
///
/// Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally
/// relabel it via the body; send `{}` to keep the name it knocked with.
#[utoipa::path(
post,
path = "/native/pending/{id}/approve",
tag = "native",
operation_id = "approvePendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
request_body = ApprovePending,
responses(
(status = OK, description = "Device paired", body = NativeClient),
(status = NOT_FOUND, description = "No pending request with that id (expired?)", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not persist the trust store", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn approve_pending_device(
State(st): State<Arc<MgmtState>>,
Path(id): Path<u32>,
ApiJson(req): ApiJson<ApprovePending>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
match np.approve_pending(id, req.name.as_deref()) {
Ok(Some(client)) => {
tracing::info!(name = %client.name, fingerprint = %client.fingerprint,
"management API: pending device approved (delegated pairing)");
Json(NativeClient {
name: client.name,
fingerprint: client.fingerprint,
})
.into_response()
}
Ok(None) => api_error(
StatusCode::NOT_FOUND,
"no pending request with that id (it may have expired — have the device retry)",
),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not persist trust store: {e}"),
),
}
}
/// Deny a pending device
///
/// Drops the request. Not a blocklist — the device's next attempt knocks again.
#[utoipa::path(
post,
path = "/native/pending/{id}/deny",
tag = "native",
operation_id = "denyPendingDevice",
params(("id" = u32, Path, description = "Pending-request id from the pending list")),
responses(
(status = NO_CONTENT, description = "Request dropped"),
(status = NOT_FOUND, description = "No pending request with that id", body = ApiError),
(status = SERVICE_UNAVAILABLE, description = "Native host not enabled", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn deny_pending_device(
State(st): State<Arc<MgmtState>>,
Path(id): Path<u32>,
) -> Response {
let Some(np) = &st.native else {
return api_error(StatusCode::SERVICE_UNAVAILABLE, "native host not enabled");
};
if np.deny_pending(id) {
tracing::info!(id, "management API: pending device denied");
StatusCode::NO_CONTENT.into_response()
} else {
api_error(StatusCode::NOT_FOUND, "no pending request with that id")
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Session-tagged management endpoints: stop the active session, force an IDR. Split out of the
//! `mgmt` facade (plan §W5).
use super::shared::*;
use std::sync::atomic::Ordering;
/// Stop the active session
///
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
/// state. Idempotent — succeeds even when nothing is streaming.
#[utoipa::path(
delete,
path = "/session",
tag = "session",
operation_id = "stopSession",
responses(
(status = NO_CONTENT, description = "Session stopped (or none was active)"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> StatusCode {
let was_streaming = st.app.streaming.swap(false, Ordering::SeqCst);
st.app.audio_streaming.store(false, Ordering::SeqCst);
*st.app.launch.lock().unwrap() = None;
*st.app.stream.lock().unwrap() = None;
// Native plane: the GameStream flags above don't reach it (it runs its own loops off the shared
// session registry), so signal every live native session to tear down too.
let native = crate::session_status::count();
crate::session_status::stop_all();
tracing::info!(
was_streaming,
native_sessions = native,
"management API: session stopped"
);
StatusCode::NO_CONTENT
}
/// Force a keyframe
///
/// Asks the encoder for an IDR frame on the active video stream (what a client requests
/// after unrecoverable loss — exposed for debugging).
#[utoipa::path(
post,
path = "/session/idr",
tag = "session",
operation_id = "requestIdr",
responses(
(status = ACCEPTED, description = "Keyframe requested"),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = CONFLICT, description = "No active video stream", body = ApiError),
)
)]
pub(crate) async fn request_idr(State(st): State<Arc<MgmtState>>) -> Response {
let gs = st.app.streaming.load(Ordering::SeqCst);
let native = crate::session_status::count();
if !gs && native == 0 {
return api_error(StatusCode::CONFLICT, "no active video stream");
}
if gs {
st.app.force_idr.store(true, Ordering::SeqCst);
}
// Native sessions get the keyframe request through their registry flag (see `session_status`).
crate::session_status::force_idr_all();
StatusCode::ACCEPTED.into_response()
}
+51
View File
@@ -0,0 +1,51 @@
//! Shared control-plane plumbing for the management submodules: the [`ApiError`] envelope
//! every non-2xx response wears, [`api_error`], the [`ApiJson`] extractor that keeps axum's own
//! rejections in that envelope, and a small re-export prelude of the axum/serde/utoipa vocabulary
//! the handler modules share. Split out of the `mgmt` facade (plan §W5).
use axum::extract::Request;
// Re-export prelude: the vocabulary every handler submodule pulls in via `use super::shared::*`.
pub(crate) use super::MgmtState;
pub(crate) use axum::extract::{Path, Query, State};
pub(crate) use axum::http::StatusCode;
pub(crate) use axum::response::{IntoResponse, Response};
pub(crate) use axum::Json;
pub(crate) use serde::{Deserialize, Serialize};
pub(crate) use std::sync::Arc;
pub(crate) use utoipa::ToSchema;
/// Error envelope for every non-2xx response.
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct ApiError {
error: String,
}
pub(crate) fn api_error(status: StatusCode, message: &str) -> Response {
(
status,
Json(ApiError {
error: message.to_string(),
}),
)
.into_response()
}
/// `axum::Json` whose rejections (bad JSON → 400/422, wrong content-type → 415) are
/// rewrapped in the [`ApiError`] envelope, keeping "every non-2xx body is `ApiError`" true.
pub(crate) struct ApiJson<T>(pub(crate) T);
impl<S, T> axum::extract::FromRequest<S> for ApiJson<T>
where
Json<T>: axum::extract::FromRequest<S, Rejection = axum::extract::rejection::JsonRejection>,
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match Json::<T>::from_request(req, state).await {
Ok(Json(value)) => Ok(ApiJson(value)),
Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())),
}
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Stats/logs-tagged management endpoints: performance-capture control + time-series and the
//! in-memory log stream. Split out of the `mgmt` facade (plan §W5).
use super::shared::*;
use crate::log_capture::LogPage;
use crate::stats_recorder::Capture;
use crate::stats_recorder::CaptureMeta;
use crate::stats_recorder::StatsStatus;
/// Start a stats capture
///
/// Arms a new performance-stats capture. Idempotent: if a capture is already running this returns
/// the current status unchanged. While armed, the streaming loops emit aggregated samples (~ every
/// 12 s) into the in-progress capture, readable live via `GET /stats/capture/live`.
#[utoipa::path(
post,
path = "/stats/capture/start",
tag = "stats",
operation_id = "statsCaptureStart",
responses(
(status = OK, description = "Capture armed (or already running)", body = StatsStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn stats_capture_start(State(st): State<Arc<MgmtState>>) -> Json<StatsStatus> {
let status = st.stats.start();
tracing::info!(
started_unix_ms = status.started_unix_ms,
"management API: stats capture armed"
);
Json(status)
}
/// Stop the stats capture
///
/// Disarms the in-progress capture and writes it to disk atomically, returning its summary. If
/// nothing was recording, returns `204 No Content`.
#[utoipa::path(
post,
path = "/stats/capture/stop",
tag = "stats",
operation_id = "statsCaptureStop",
responses(
(status = OK, description = "Capture stopped and saved", body = CaptureMeta),
(status = NO_CONTENT, description = "Nothing was recording"),
(status = INTERNAL_SERVER_ERROR, description = "Could not write the recording to disk", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn stats_capture_stop(State(st): State<Arc<MgmtState>>) -> Response {
match st.stats.stop() {
Ok(Some(meta)) => {
tracing::info!(id = %meta.id, samples = meta.sample_count, "management API: stats capture saved");
(StatusCode::OK, Json(meta)).into_response()
}
Ok(None) => StatusCode::NO_CONTENT.into_response(),
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not save capture: {e}"),
),
}
}
/// Stats capture status
///
/// Whether a capture is armed, its sample count, and start time. Poll this (e.g. every 2 s) to
/// drive the capture-control UI.
#[utoipa::path(
get,
path = "/stats/capture/status",
tag = "stats",
operation_id = "statsCaptureStatus",
responses(
(status = OK, description = "In-progress capture status (idle when not armed)", body = StatsStatus),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn stats_capture_status(State(st): State<Arc<MgmtState>>) -> Json<StatsStatus> {
Json(st.stats.status())
}
/// Live in-progress capture
///
/// The full sample time-series of the capture currently recording, for live graphing. `404` when
/// nothing is armed.
#[utoipa::path(
get,
path = "/stats/capture/live",
tag = "stats",
operation_id = "statsCaptureLive",
responses(
(status = OK, description = "The in-progress capture (meta + samples so far)", body = Capture),
(status = NOT_FOUND, description = "No capture is currently recording", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn stats_capture_live(State(st): State<Arc<MgmtState>>) -> Response {
match st.stats.live_snapshot() {
Some(capture) => Json(capture).into_response(),
None => api_error(StatusCode::NOT_FOUND, "no capture is currently recording"),
}
}
/// List saved recordings
///
/// Every saved capture's summary (the `meta` head only — not the sample body), newest first.
#[utoipa::path(
get,
path = "/stats/recordings",
tag = "stats",
operation_id = "statsRecordingsList",
responses(
(status = OK, description = "Saved capture summaries, newest first", body = [CaptureMeta]),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn stats_recordings_list(
State(st): State<Arc<MgmtState>>,
) -> Json<Vec<CaptureMeta>> {
Json(st.stats.list())
}
/// Get a saved recording
///
/// The full capture (meta + samples) for `id`, for graphing or download.
#[utoipa::path(
get,
path = "/stats/recordings/{id}",
tag = "stats",
operation_id = "statsRecordingGet",
params(("id" = String, Path, description = "The recording id (its filename stem)")),
responses(
(status = OK, description = "The full capture", body = Capture),
(status = NOT_FOUND, description = "No recording with that id", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "The recording file is unreadable", body = ApiError),
)
)]
pub(crate) async fn stats_recording_get(
State(st): State<Arc<MgmtState>>,
Path(id): Path<String>,
) -> Response {
match st.stats.load(&id) {
Ok(capture) => Json(capture).into_response(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
api_error(StatusCode::NOT_FOUND, "no recording with that id")
}
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not read recording: {e}"),
),
}
}
/// Delete a saved recording
///
/// Removes the recording `id` from disk. `404` if there is no such recording.
#[utoipa::path(
delete,
path = "/stats/recordings/{id}",
tag = "stats",
operation_id = "statsRecordingDelete",
params(("id" = String, Path, description = "The recording id (its filename stem)")),
responses(
(status = NO_CONTENT, description = "Recording deleted"),
(status = NOT_FOUND, description = "No recording with that id", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Could not delete the recording", body = ApiError),
)
)]
pub(crate) async fn stats_recording_delete(
State(st): State<Arc<MgmtState>>,
Path(id): Path<String>,
) -> Response {
match st.stats.delete(&id) {
Ok(()) => {
tracing::info!(id, "management API: recording deleted");
StatusCode::NO_CONTENT.into_response()
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
api_error(StatusCode::NOT_FOUND, "no recording with that id")
}
Err(e) => api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("could not delete recording: {e}"),
),
}
}
/// Query for `GET /logs` — a cursor poll.
#[derive(Deserialize)]
pub(crate) struct LogsQuery {
after: Option<u64>,
limit: Option<u32>,
}
/// Host logs
///
/// The host's recent log entries — an in-memory ring of the newest few thousand, captured at
/// DEBUG and above regardless of `RUST_LOG`. Follow live by polling with `after` set to the last
/// response's `next` cursor; a `dropped: true` means entries were evicted between polls (the ring
/// wrapped). Bearer-only: logs can reference client identities and host paths, so this is part of
/// the loopback-only admin surface, never the LAN-readable mTLS one.
#[utoipa::path(
get,
path = "/logs",
tag = "logs",
operation_id = "logsGet",
params(
("after" = Option<u64>, Query, description = "Return entries with seq greater than this (omitted/0 = oldest retained)"),
("limit" = Option<u32>, Query, description = "Max entries per response (default and cap 1000)"),
),
responses(
(status = OK, description = "Entries after the cursor, oldest first", body = LogPage),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn logs_get(Query(q): Query<LogsQuery>) -> Json<LogPage> {
let limit = q.limit.map_or(crate::log_capture::MAX_PAGE, |l| l as usize);
Json(crate::log_capture::ring().since(q.after.unwrap_or(0), limit))
}
+948
View File
@@ -0,0 +1,948 @@
//! Handler + auth tests for the management API, exercised through `app()`. Split out of the
//! `mgmt` facade (plan §W5).
use super::*;
use crate::encode::Codec;
use crate::gamestream::tls::{PeerAddr, PeerCertFingerprint};
use crate::gamestream::{cert::ServerIdentity, Host, LaunchSession, HTTPS_PORT, HTTP_PORT};
use axum::body::Body;
use axum::http::StatusCode;
use http_body_util::BodyExt;
use sha2::{Digest, Sha256};
use std::net::{IpAddr, Ipv4Addr};
use std::sync::atomic::Ordering;
use tower::ServiceExt;
/// A throwaway stats recorder rooted in a unique temp dir (never touches the real config dir).
fn test_stats() -> Arc<crate::stats_recorder::StatsRecorder> {
crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
"pf-mgmt-stats-{}-{:p}",
std::process::id(),
&0u8 as *const u8
)))
}
fn test_state() -> Arc<AppState> {
let host = Host {
hostname: "test-host".into(),
uniqueid: "deadbeef".into(),
local_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
};
let identity = ServerIdentity::ephemeral().expect("ephemeral identity");
Arc::new(AppState::new(host, identity, test_stats()))
}
// The mgmt API now always requires auth, so the router always has a token. A test that passes
// `None` gets the default "test-secret" (and `send` auto-attaches the matching bearer); a test
// that passes an explicit token exercises a mismatch (e.g. `bearer_token_is_enforced`).
fn test_app(state: Arc<AppState>, token: Option<&str>) -> Router {
let stats = state.stats.clone();
app(
state,
Some(token.unwrap_or("test-secret").to_string()),
DEFAULT_PORT,
None,
stats,
// GameStream-compat planes off (the secure default the native-only tests model).
false,
)
}
fn test_app_native(state: Arc<AppState>, np: Arc<crate::native_pairing::NativePairing>) -> Router {
// Auth required always; the paired-cert tests inject a fingerprint (cert branch wins), the
// rest authenticate via the `send`-attached default bearer.
let stats = state.stats.clone();
app(
state,
Some("test-secret".to_string()),
DEFAULT_PORT,
Some(np),
stats,
false,
)
}
async fn send(app: &Router, mut req: axum::http::Request<Body>) -> (StatusCode, serde_json::Value) {
// Auto-attach the default bearer unless the test set its own Authorization (e.g. the
// mismatch cases in `bearer_token_is_enforced`). Open routes ignore it; authed routes
// accept it against the `test-secret` default token.
if !req
.headers()
.contains_key(axum::http::header::AUTHORIZATION)
{
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_static("Bearer test-secret"),
);
}
let resp = app.clone().oneshot(req).await.expect("infallible");
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json = if bytes.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)
};
(status, json)
}
fn get_req(path: &str) -> axum::http::Request<Body> {
axum::http::Request::get(path).body(Body::empty()).unwrap()
}
/// Send a request authenticated ONLY by a paired streaming cert (the `PeerCertFingerprint`
/// `serve_https` would attach) — no bearer header — so `require_auth`'s cert branch decides.
async fn send_cert(app: &Router, mut req: axum::http::Request<Body>, fp: &str) -> StatusCode {
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
app.clone().oneshot(req).await.expect("infallible").status()
}
/// A paired *streaming* cert (mTLS, no bearer) authorizes only the read-only allowlist; every
/// state-changing or PIN-exposing route still requires the operator's bearer token (audit #4).
#[tokio::test]
async fn cert_auth_is_a_read_only_allowlist() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-cert-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let fp = "deadbeefcafe";
np.add("streaming-client", fp).unwrap();
let app = test_app_native(test_state(), np);
// Allowlisted read-only GETs → the cert authorizes them (not 401).
for p in [
"/api/v1/host",
"/api/v1/status",
"/api/v1/compositors",
"/api/v1/clients",
"/api/v1/native/clients",
"/api/v1/library",
] {
assert_ne!(
send_cert(&app, get_req(p), fp).await,
StatusCode::UNAUTHORIZED,
"a paired streaming cert should authorize GET {p}"
);
}
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
assert_eq!(
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
StatusCode::UNAUTHORIZED,
"GET /native/pair exposes the PIN → must require the bearer token"
);
assert_eq!(
send_cert(
&app,
post_json(
"/api/v1/native/pair/arm",
serde_json::json!({"ttl_secs": 60})
),
fp,
)
.await,
StatusCode::UNAUTHORIZED,
"arming pairing must require the bearer token"
);
assert_eq!(
send_cert(
&app,
axum::http::Request::delete("/api/v1/native/clients/deadbeefcafe")
.body(Body::empty())
.unwrap(),
fp,
)
.await,
StatusCode::UNAUTHORIZED,
"unpair (DELETE) must require the bearer token"
);
// An UNPAIRED cert is rejected even on an allowlisted path.
assert_eq!(
send_cert(&app, get_req("/api/v1/status"), "not-paired").await,
StatusCode::UNAUTHORIZED,
"an unpaired cert must be rejected"
);
}
/// The bearer-token (admin) path is honored only from a LOOPBACK peer: the same token from a LAN
/// peer is rejected, so binding the listener to all interfaces (so paired clients can browse the
/// library by default) never LAN-exposes the admin surface. A paired *cert*, by contrast, reaches
/// the read-only allowlist from anywhere.
#[tokio::test]
async fn bearer_admin_is_loopback_only() {
let lan: SocketAddr = "192.168.1.50:54321".parse().unwrap();
let loopback: SocketAddr = "127.0.0.1:33333".parse().unwrap();
let bearer = |peer: SocketAddr| {
let mut req = get_req("/api/v1/stats/recordings"); // a bearer-only (admin) route
req.extensions_mut().insert(PeerAddr(peer));
req.headers_mut().insert(
axum::http::header::AUTHORIZATION,
axum::http::HeaderValue::from_static("Bearer test-secret"),
);
req
};
let app = test_app(test_state(), None);
// A valid bearer from a LAN peer → rejected on the admin API.
assert_eq!(
app.clone()
.oneshot(bearer(lan))
.await
.expect("infallible")
.status(),
StatusCode::UNAUTHORIZED,
"a bearer token from a LAN peer must be rejected on the admin API"
);
// The SAME token from a loopback peer (the web console BFF) → accepted.
assert_ne!(
app.clone()
.oneshot(bearer(loopback))
.await
.expect("infallible")
.status(),
StatusCode::UNAUTHORIZED,
"the bearer token must be accepted from a loopback peer"
);
// A paired cert from a LAN peer still reaches the read-only library (the feature this enables).
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-lanlib-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let fp = "deadbeefcafe";
np.add("lan-client", fp).unwrap();
let app = test_app_native(test_state(), np);
let mut req = get_req("/api/v1/library");
req.extensions_mut().insert(PeerAddr(lan));
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
assert_ne!(
app.clone().oneshot(req).await.expect("infallible").status(),
StatusCode::UNAUTHORIZED,
"a paired cert must reach the library from a LAN peer"
);
// The per-image art proxy (`/api/v1/library/art/{id}/{kind}`) is a prefix match in
// `cert_may_access`, not an exact one (dynamic id/kind segments) — exercise it directly. An
// unknown `kind` 404s before any disk/network I/O, so this stays a fast, deterministic check
// of the auth gate (not of art resolution, which `library::tests` covers).
let mut req = get_req("/api/v1/library/art/steam:570/not-a-real-kind");
req.extensions_mut().insert(PeerAddr(lan));
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
assert_eq!(
app.clone().oneshot(req).await.expect("infallible").status(),
StatusCode::NOT_FOUND,
"a paired cert must reach the per-image library art proxy from a LAN peer \
(and an unknown kind 404s, rather than ever being rejected as unauthorized)"
);
}
#[tokio::test]
async fn health_is_open_and_versioned() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/health")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "ok");
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"));
// No/wrong token → 401 with the error envelope.
let (status, body) = send(&app, get_req("/api/v1/status")).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(body["error"].as_str().unwrap().contains("bearer"));
let wrong = axum::http::Request::get("/api/v1/status")
.header("authorization", "Bearer nope")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, wrong).await.0, StatusCode::UNAUTHORIZED);
// Right token → 200.
let right = axum::http::Request::get("/api/v1/status")
.header("authorization", "Bearer sekrit")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, right).await.0, StatusCode::OK);
// Health + the spec/docs stay open.
assert_eq!(
send(&app, get_req("/api/v1/health")).await.0,
StatusCode::OK
);
assert_eq!(
send(&app, get_req("/api/v1/openapi.json")).await.0,
StatusCode::OK
);
let docs = app.clone().oneshot(get_req("/api/docs")).await.unwrap();
assert_eq!(docs.status(), StatusCode::OK);
let html = docs.into_body().collect().await.unwrap().to_bytes();
assert!(
html.starts_with(b"<!doctype html>"),
"Scalar UI should serve HTML"
);
}
#[tokio::test]
async fn host_info_reports_identity_and_ports() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/host")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["hostname"], "test-host");
assert_eq!(body["uniqueid"], "deadbeef");
assert_eq!(body["ports"]["http"], HTTP_PORT);
assert_eq!(body["ports"]["mgmt"], DEFAULT_PORT);
// Codecs are GPU-aware (derived from `Codec::host_wire_caps`), so assert against that mask
// rather than a fixed set — and confirm HEVC serializes as "hevc" (the unified codec label),
// never "h265".
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
let caps = Codec::host_wire_caps();
let expected: Vec<&str> = [
(CODEC_H264, "h264"),
(CODEC_HEVC, "hevc"),
(CODEC_AV1, "av1"),
(CODEC_PYROWAVE, "pyrowave"),
]
.into_iter()
.filter(|(bit, _)| caps & bit != 0)
.map(|(_, name)| name)
.collect();
assert_eq!(body["codecs"], serde_json::json!(expected));
assert!(caps & CODEC_H264 != 0, "H.264 is always encodable");
// test_app models the secure default (GameStream-compat off).
assert_eq!(body["gamestream"], false);
}
#[tokio::test]
async fn compositors_lists_all_backends_with_flags() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/compositors")).await;
assert_eq!(status, StatusCode::OK);
let arr = body.as_array().expect("array");
// Every backend the host knows, in stable order.
let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots", "hyprland"]);
for c in arr {
assert!(c["available"].is_boolean());
assert!(c["default"].is_boolean());
assert!(c["label"].as_str().is_some_and(|s| !s.is_empty()));
}
// At most one backend is the auto-detect default (none, if the test env has no desktop).
assert!(arr.iter().filter(|c| c["default"] == true).count() <= 1);
}
#[tokio::test]
async fn status_reflects_runtime_state() {
let state = test_state();
let app = test_app(state.clone(), None);
let (_, body) = send(&app, get_req("/api/v1/status")).await;
assert_eq!(body["video_streaming"], false);
assert_eq!(body["session"], serde_json::Value::Null);
*state.launch.lock().unwrap() = Some(LaunchSession {
gcm_key: [0; 16],
rikeyid: 1,
width: 2560,
height: 1440,
fps: 120,
appid: 1,
peer_ip: None,
owner_fp: None,
});
state.streaming.store(true, Ordering::SeqCst);
let (_, body) = send(&app, get_req("/api/v1/status")).await;
assert_eq!(body["video_streaming"], true);
assert_eq!(body["session"]["width"], 2560);
assert_eq!(body["session"]["fps"], 120);
// Key material must never appear anywhere in the response.
assert!(!body.to_string().contains("gcm"));
}
#[tokio::test]
async fn paired_clients_list_and_unpair() {
let state = test_state();
let app = test_app(state.clone(), None);
// Pin the host's own cert DER as a stand-in client.
let (_, pem) = x509_parser::pem::parse_x509_pem(state.identity.cert_pem.as_bytes()).unwrap();
let der = pem.contents.clone();
let fingerprint = hex::encode(Sha256::digest(&der));
// Isolate from any real paired store on the dev box: AppState::new loads
// ~/.config/punktfunk/paired.json, so clear it before seeding our stand-in — otherwise
// a real GameStream-paired client lands at body[0] and this assertion sees its hash.
{
let mut p = state.paired.lock().unwrap();
p.clear();
p.push(der);
}
let (status, body) = send(&app, get_req("/api/v1/clients")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body[0]["fingerprint"], fingerprint);
assert_eq!(body[0]["subject"], "CN=punktfunk");
// Malformed fingerprint → 400.
let bad = axum::http::Request::delete("/api/v1/clients/zz")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, bad).await.0, StatusCode::BAD_REQUEST);
// Unpair (uppercase hex must match too) → 204, list empties, second delete → 404.
let del = |fp: String| {
axum::http::Request::delete(format!("/api/v1/clients/{fp}"))
.body(Body::empty())
.unwrap()
};
assert_eq!(
send(&app, del(fingerprint.to_uppercase())).await.0,
StatusCode::NO_CONTENT
);
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert_eq!(body, serde_json::json!([]));
assert_eq!(send(&app, del(fingerprint)).await.0, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn submit_pin_validates_and_requires_pending_pairing() {
let app = test_app(test_state(), None);
let post = |body: &str| {
axum::http::Request::post("/api/v1/pair/pin")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Malformed PINs → 400.
assert_eq!(
send(&app, post(r#"{"pin":""}"#)).await.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
send(&app, post(r#"{"pin":"12ab"}"#)).await.0,
StatusCode::BAD_REQUEST
);
// Well-formed but nothing waiting → 409 (a parked stale PIN would poison the
// next pairing attempt).
assert_eq!(
send(&app, post(r#"{"pin":"1234"}"#)).await.0,
StatusCode::CONFLICT
);
// axum's own body rejections must still wear the ApiError envelope (ApiJson).
let (status, body) = send(&app, post("{not json")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].is_string(), "syntax error: {body}");
let (status, body) = send(&app, post(r#"{"wrong":"shape"}"#)).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert!(body["error"].is_string(), "schema mismatch: {body}");
let no_ct = axum::http::Request::post("/api/v1/pair/pin")
.body(Body::from(r#"{"pin":"1234"}"#))
.unwrap();
let (status, body) = send(&app, no_ct).await;
assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
assert!(body["error"].is_string(), "media type: {body}");
}
/// A blank token is treated as no token: the mgmt API requires auth always (even on loopback),
/// so `run` refuses to start unauthenticated rather than serve open.
#[tokio::test]
async fn blank_token_rejected() {
let opts = Options {
bind: "127.0.0.1:0".parse().unwrap(),
token: Some(" ".into()),
};
let err = run(test_state(), opts, None, test_stats(), false)
.await
.unwrap_err();
assert!(err.to_string().contains("no token"), "{err}");
}
#[tokio::test]
async fn stop_session_clears_runtime_state() {
let state = test_state();
let app = test_app(state.clone(), None);
state.streaming.store(true, Ordering::SeqCst);
state.audio_streaming.store(true, Ordering::SeqCst);
*state.launch.lock().unwrap() = Some(LaunchSession {
gcm_key: [0; 16],
rikeyid: 0,
width: 1920,
height: 1080,
fps: 60,
appid: 1,
peer_ip: None,
owner_fp: None,
});
let del = axum::http::Request::delete("/api/v1/session")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
assert!(!state.streaming.load(Ordering::SeqCst));
assert!(!state.audio_streaming.load(Ordering::SeqCst));
assert!(state.launch.lock().unwrap().is_none());
}
#[tokio::test]
async fn idr_requires_an_active_stream() {
let state = test_state();
let app = test_app(state.clone(), None);
let post = || {
axum::http::Request::post("/api/v1/session/idr")
.body(Body::empty())
.unwrap()
};
assert_eq!(send(&app, post()).await.0, StatusCode::CONFLICT);
state.streaming.store(true, Ordering::SeqCst);
assert_eq!(send(&app, post()).await.0, StatusCode::ACCEPTED);
assert!(state.force_idr.load(Ordering::SeqCst));
}
/// The OpenAPI document lists every route with a unique operationId (codegen relies
/// on both), and the checked-in copy is current.
#[test]
fn openapi_document_is_complete_and_checked_in() {
let json = openapi_json();
let doc: serde_json::Value = serde_json::from_str(&json).unwrap();
let paths = doc["paths"].as_object().unwrap();
for p in [
"/api/v1/health",
"/api/v1/host",
"/api/v1/status",
"/api/v1/clients",
"/api/v1/clients/{fingerprint}",
"/api/v1/pair",
"/api/v1/pair/pin",
"/api/v1/session",
"/api/v1/session/idr",
] {
assert!(paths.contains_key(p), "spec is missing {p}");
}
let mut op_ids: Vec<&str> = paths
.values()
.flat_map(|ops| ops.as_object().unwrap().values())
.filter_map(|op| op["operationId"].as_str())
.collect();
let total = op_ids.len();
op_ids.sort_unstable();
op_ids.dedup();
assert_eq!(total, op_ids.len(), "duplicate operationIds");
assert!(doc["components"]["securitySchemes"]["bearerAuth"].is_object());
// The health probe overrides the document-global bearer requirement (the server
// exempts it in `require_auth`; the spec must agree).
assert_eq!(
doc["paths"]["/api/v1/health"]["get"]["security"],
serde_json::json!([{}])
);
let checked_in = include_str!("../../../../api/openapi.json");
// Compare STRUCTURALLY with `info.version` normalized on both sides: the served document
// stamps the live crate version, but a version bump alone must never invalidate the
// snapshot — the API *surface* is what drift-control protects (the 0.5.0 release tripped
// on exactly this). Structural comparison also makes line endings a non-issue (git may
// check the file out CRLF on Windows).
let mut generated = doc;
let mut snapshot: serde_json::Value = serde_json::from_str(checked_in).unwrap();
generated["info"]["version"] = serde_json::json!("<any>");
snapshot["info"]["version"] = serde_json::json!("<any>");
assert_eq!(
generated, snapshot,
"api/openapi.json is stale — regenerate with: \
cargo run -p punktfunk-host -- openapi > api/openapi.json"
);
}
fn post_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
axum::http::Request::post(path)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
/// The display-management GET surface (presets + effective + the enforced-axes list). READ-ONLY
/// on purpose: `prefs()` is a process-global `OnceLock`, so a PUT here would clobber it and race
/// other tests running in the same process. `keep_alive: forever` (gaming-rig) is now accepted
/// (not rejected) — that acceptance is covered on-glass (`.116`) + by the pure `policy` tests, and
/// the `forever` value is read off the surfaced preset below without writing.
#[tokio::test]
async fn display_settings_surface() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/settings")).await;
assert_eq!(status, StatusCode::OK);
let presets = body["presets"].as_array().expect("presets array");
assert_eq!(
presets.len(),
5,
"all five named presets are surfaced for the console picker"
);
assert!(
body["effective"]["keep_alive"].is_object(),
"the effective policy is echoed"
);
// gaming-rig surfaces keep_alive: forever (no longer rejected) — read it off the preset list.
let gaming = presets
.iter()
.find(|p| p["id"] == "gaming-rig")
.expect("gaming-rig preset surfaced");
assert_eq!(
gaming["fields"]["keep_alive"]["mode"], "forever",
"gaming-rig is keep_alive: forever"
);
let enforced: Vec<&str> = body["enforced"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
// All five axes are enforced now (Stages 0-5).
assert!(enforced.contains(&"keep_alive"));
assert!(enforced.contains(&"topology"));
assert!(enforced.contains(&"mode_conflict"));
assert!(enforced.contains(&"identity"));
assert!(enforced.contains(&"layout"));
// The experimental DDC/CI + PnP-disable axes are acted on (Windows exclusive-isolate path).
assert!(enforced.contains(&"ddc_power_off"));
assert!(enforced.contains(&"pnp_disable_monitors"));
}
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
/// created a display (and non-Windows reports none), so `/state` is empty and `/release` is a
/// no-op — the shapes + the "nothing to release" path, without touching any global owner.
#[tokio::test]
async fn display_state_and_release_empty() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/state")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["displays"].as_array().map(|a| a.len()),
Some(0),
"no managed displays on an idle test host"
);
let (status, body) = send(
&app,
post_json("/api/v1/display/release", serde_json::json!({})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["released"], 0);
}
#[tokio::test]
async fn native_pairing_arm_show_and_unpair() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-np-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// Disarmed: enabled, not armed, no PIN.
let (s, b) = send(&app, get_req("/api/v1/native/pair")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["enabled"], true);
assert_eq!(b["armed"], false);
assert!(b["pin"].is_null());
// Arm → a PIN appears and is readable via status.
let (s, b) = send(
&app,
post_json(
"/api/v1/native/pair/arm",
serde_json::json!({"ttl_secs": 60}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["armed"], true);
let pin = b["pin"].as_str().unwrap().to_string();
assert_eq!(pin.len(), 4);
let (_, b) = send(&app, get_req("/api/v1/native/pair")).await;
assert_eq!(b["pin"], pin);
assert!(b["expires_in_secs"].as_u64().unwrap() <= 60);
// The QUIC side would read the same live PIN.
assert_eq!(np.current_pin().as_deref(), Some(pin.as_str()));
// Pair a client out-of-band, then it shows in the list + can be unpaired.
np.add("Test Device", "abc123").unwrap();
let (s, b) = send(&app, get_req("/api/v1/native/clients")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b[0]["name"], "Test Device");
assert_eq!(b[0]["fingerprint"], "abc123");
let del = axum::http::Request::delete("/api/v1/native/clients/ABC123")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
let missing = axum::http::Request::delete("/api/v1/native/clients/abc123")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, missing).await.0, StatusCode::NOT_FOUND);
// Disarm clears the window.
let del = axum::http::Request::delete("/api/v1/native/pair")
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
let (_, b) = send(&app, get_req("/api/v1/native/pair")).await;
assert_eq!(b["armed"], false);
}
#[tokio::test]
async fn pending_devices_approve_and_deny() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(std::env::temp_dir().join(format!("pf-mgmt-pending-{}.json", std::process::id()))),
None,
false,
)
.unwrap(),
);
let app = test_app_native(test_state(), np.clone());
// Empty queue.
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b.as_array().unwrap().len(), 0);
// Two devices knock (what the QUIC gate records); they appear in the list.
np.note_pending("Enrico's MacBook", "aa11", None);
np.note_pending("device bb22cc33", "bb22", None);
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 2);
assert_eq!(b[0]["name"], "Enrico's MacBook");
let approve_id = b[0]["id"].as_u64().unwrap();
let deny_id = b[1]["id"].as_u64().unwrap();
// Approve the first with an operator label → paired under that name, gone from pending.
let (s, b) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{approve_id}/approve"),
serde_json::json!({"name": "Office MacBook"}),
),
)
.await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["name"], "Office MacBook");
assert_eq!(b["fingerprint"], "aa11");
assert!(np.is_paired("AA11"), "approval pins the fingerprint");
// Deny the second → dropped, not paired; a re-deny is 404.
let deny = post_json(
&format!("/api/v1/native/pending/{deny_id}/deny"),
serde_json::json!({}),
);
assert_eq!(send(&app, deny).await.0, StatusCode::NO_CONTENT);
assert!(!np.is_paired("bb22"));
let (s, _) = send(
&app,
post_json(
&format!("/api/v1/native/pending/{deny_id}/deny"),
serde_json::json!({}),
),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
// Queue is empty again; approving a stale id is 404 (keep `{}` = device's own name).
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(b.as_array().unwrap().len(), 0);
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/123/approve", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn native_endpoints_report_disabled_without_native_host() {
let app = test_app(test_state(), None);
let (s, b) = send(&app, get_req("/api/v1/native/pair")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b["enabled"], false);
// Arming a host that isn't running the native server is a 503.
let (s, _) = send(
&app,
post_json("/api/v1/native/pair/arm", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
// Pending list reads as an empty array (like /native/clients), not a 503.
let (s, b) = send(&app, get_req("/api/v1/native/pending")).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(b.as_array().unwrap().len(), 0);
// Approve/deny without a native host are 503.
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/0/approve", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
let (s, _) = send(
&app,
post_json("/api/v1/native/pending/0/deny", serde_json::json!({})),
)
.await;
assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);
}
fn put_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
axum::http::Request::put(path)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
/// The GPU endpoints: the inventory GET always answers (an empty list on a GPU-less box —
/// the schema is platform-independent), and the preference PUT validates mode + gpu_id
/// BEFORE touching the persisted store, so a bad request can never write.
#[tokio::test]
async fn gpu_endpoints_list_and_validate() {
let app = test_app(test_state(), None);
let (s, b) = send(&app, get_req("/api/v1/gpus")).await;
assert_eq!(s, StatusCode::OK);
assert!(b["gpus"].is_array());
assert!(b["mode"].is_string());
// Unknown mode → 400.
let (s, _) = send(
&app,
put_json(
"/api/v1/gpus/preference",
serde_json::json!({"mode": "fastest"}),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// `manual` without a gpu_id → 400.
let (s, _) = send(
&app,
put_json(
"/api/v1/gpus/preference",
serde_json::json!({"mode": "manual"}),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
// `manual` with an id that is not a present GPU → 400 (the console only offers listed ids).
let (s, _) = send(
&app,
put_json(
"/api/v1/gpus/preference",
serde_json::json!({"mode": "manual", "gpu_id": "ffff-ffff-9"}),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn logs_endpoint_pages_by_cursor() {
let app = test_app(test_state(), None);
// The ring is a process-wide singleton — start from wherever its cursor currently is.
let (s, json) = send(&app, get_req("/api/v1/logs")).await;
assert_eq!(s, StatusCode::OK);
let start = json["next"].as_u64().unwrap();
let ring = crate::log_capture::ring();
ring.push(&tracing::Level::WARN, "mgmt::tests", "first".into());
ring.push(&tracing::Level::INFO, "mgmt::tests", "second".into());
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={start}"))).await;
assert_eq!(s, StatusCode::OK);
let entries = json["entries"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["msg"], "first");
assert_eq!(entries[0]["level"], "WARN");
assert_eq!(json["next"].as_u64().unwrap(), start + 2);
assert_eq!(json["dropped"], false);
// Nothing newer → empty page, cursor unchanged.
let after = start + 2;
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={after}"))).await;
assert_eq!(s, StatusCode::OK);
assert!(json["entries"].as_array().unwrap().is_empty());
assert_eq!(json["next"].as_u64().unwrap(), after);
}
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
//! The native audio plane (plan §W1 — carved out of the [`super`] module): desktop capture → Opus
//! (48 kHz, 5 ms, CBR — the same tuning as the GameStream path) → `AUDIO_MAGIC` QUIC datagrams, at
//! the negotiated channel count. The encoder ([`NativeAudioEnc`]) and the capture/encode/send loop
//! ([`audio_thread`]) are gated to linux/windows (libopus + a real capturer); other targets get the
//! stub, so a dev build streams video-only rather than failing to compile.
use super::*;
/// Opus encoder for the native audio plane: a plain stereo encoder (the live-validated,
/// byte-identical path) or a libopus *multistream* encoder for 5.1/7.1, both behind one
/// `encode_float`. Surround uses the safe `opus::MSEncoder` (no `audiopus_sys`).
#[cfg(any(target_os = "linux", target_os = "windows"))]
enum NativeAudioEnc {
Stereo(opus::Encoder),
Surround(opus::MSEncoder),
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
impl NativeAudioEnc {
/// Build the encoder for `channels` (2/6/8), hard-CBR + RESTRICTED_LOWDELAY like the
/// GameStream path; bitrate from the shared layout table (stereo keeps the validated 128 kbps).
fn new(channels: u8) -> Result<NativeAudioEnc, opus::Error> {
if channels == 2 {
let mut e = opus::Encoder::new(
crate::audio::SAMPLE_RATE,
opus::Channels::Stereo,
opus::Application::LowDelay,
)?;
e.set_bitrate(opus::Bitrate::Bits(128_000)).ok();
e.set_vbr(false).ok();
Ok(NativeAudioEnc::Stereo(e))
} else {
let l = punktfunk_core::audio::layout_for(channels, false);
let mut e = opus::MSEncoder::new(
crate::audio::SAMPLE_RATE,
l.streams,
l.coupled,
l.mapping,
opus::Application::LowDelay,
)?;
e.set_bitrate(opus::Bitrate::Bits(l.bitrate)).ok();
e.set_vbr(false).ok();
Ok(NativeAudioEnc::Surround(e))
}
}
fn encode_float(&mut self, frame: &[f32], out: &mut [u8]) -> Result<usize, opus::Error> {
match self {
NativeAudioEnc::Stereo(e) => e.encode_float(frame, out),
NativeAudioEnc::Surround(e) => e.encode_float(frame, out),
}
}
}
/// The audio thread: desktop capture → Opus (48 kHz, 5 ms, CBR — same tuning as the GameStream
/// path) → `AUDIO_MAGIC` datagrams, at the negotiated `channels` (2 stereo / 6 = 5.1 / 8 = 7.1,
/// canonical wire order FL FR FC LFE RL RR SL SR). QUIC already encrypts; no extra layer. The
/// capturer comes from (and returns to) the persistent slot — see [`AudioCapSlot`].
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub(super) fn audio_thread(
conn: quinn::Connection,
stop: Arc<AtomicBool>,
audio_cap: AudioCapSlot,
channels: u8,
) {
use crate::audio::SAMPLE_RATE;
const FRAME_MS: usize = 5;
const SAMPLES_PER_FRAME: usize = SAMPLE_RATE as usize * FRAME_MS / 1000; // 240
let want = punktfunk_core::audio::normalize_channels(channels);
// Reuse the cached capturer ONLY when its channel count matches this session's; a stereo
// capturer left by a prior session must not feed a 5.1/7.1 session (the encoder + the client's
// decoder are sized for `want`, so a mismatched capturer would garble/desync the audio).
let capturer = match audio_cap.lock().unwrap().take() {
Some(mut c) if c.channels() == want as u32 => {
c.drain(); // discard audio captured between sessions
c
}
prev => {
drop(prev); // wrong channel count (or none): clean teardown, open fresh at `want`
match crate::audio::open_audio_capture(want as u32) {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "punktfunk/1 audio unavailable — session continues without it");
return;
}
}
}
};
let mut enc = match NativeAudioEnc::new(want) {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "opus encoder init failed — session continues without audio");
*audio_cap.lock().unwrap() = Some(capturer);
return;
}
};
let frame_len = SAMPLES_PER_FRAME * want as usize;
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
// Sized for the largest surround frame (7.1 HQ ≈ 1.3 KB at 5 ms); ample for normal quality.
let mut opus_buf = vec![0u8; 4096];
let mut seq: u32 = 0;
// Reopen-with-backoff: hold the capturer in an Option so a mid-session capture-thread death
// (device unplug, daemon restart) reopens instead of muting the rest of a multi-hour session.
// A quiet sink is NOT a death — `next_chunk` returns an empty chunk on its idle timeout — so only
// a genuine thread-ended Err drops the capturer. Reopens are throttled by INJECTOR_REOPEN_BACKOFF.
// The Opus encoder and the monotonic `seq` are kept across reopens (the client sees a gap, not a
// restart). The first open already happened above; failing THAT still ends the session quietly.
let mut capturer = Some(capturer);
let mut last_failed: Option<std::time::Instant> = None;
// A stuck Opus encoder would fail on every 5 ms frame (~200/s); power-of-two throttle the
// warn so it can't flood stderr + the log ring while still surfacing that it's failing.
let mut opus_encode_errs: u64 = 0;
tracing::info!(
channels = want,
"punktfunk/1 audio streaming (Opus 48 kHz, 5 ms datagrams)"
);
'session: while !stop.load(Ordering::SeqCst) {
if capturer.is_none() {
if last_failed.is_some_and(|t| t.elapsed() < INJECTOR_REOPEN_BACKOFF) {
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
match crate::audio::open_audio_capture(want as u32) {
Ok(c) => {
tracing::info!("punktfunk/1 audio capture reopened");
capturer = Some(c);
last_failed = None;
acc.clear(); // drop the partial frame straddling the gap
}
Err(e) => {
tracing::debug!(error = %format!("{e:#}"), "audio reopen failed — will retry");
last_failed = Some(std::time::Instant::now());
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
}
}
let chunk = match capturer.as_mut().unwrap().next_chunk() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "audio capture lost — reopening");
capturer = None;
last_failed = Some(std::time::Instant::now());
continue;
}
};
acc.extend_from_slice(&chunk);
while acc.len() >= frame_len {
let frame: Vec<f32> = acc.drain(..frame_len).collect();
let pts_ns = now_ns();
match enc.encode_float(&frame, &mut opus_buf) {
Ok(n) => {
let d =
punktfunk_core::quic::encode_audio_datagram(seq, pts_ns, &opus_buf[..n]);
if conn.send_datagram(d.into()).is_err() {
break 'session; // connection gone
}
seq = seq.wrapping_add(1);
}
Err(e) => {
opus_encode_errs += 1;
if opus_encode_errs.is_power_of_two() {
tracing::warn!(
error = %e,
count = opus_encode_errs,
"opus encode failed — dropping audio frame"
);
}
}
}
}
}
// Return the live capturer for the next session (None if it died and never reopened).
if let Some(c) = capturer {
*audio_cap.lock().unwrap() = Some(c);
}
}
/// Stub — punktfunk/1 audio needs Linux (PipeWire capture + libopus); non-Linux dev builds
/// run sessions without it, same as when the capturer fails to open.
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub(super) fn audio_thread(
_conn: quinn::Connection,
_stop: Arc<AtomicBool>,
_audio_cap: AudioCapSlot,
_channels: u8,
) {
tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it");
}
@@ -0,0 +1,190 @@
//! Compositor-preference resolution for the native handshake (plan §W1 — carved out of the
//! [`super`] module): map the client's [`CompositorPref`] to a concrete `crate::vdisplay::Compositor`
//! backend, honoring an explicit request when the named backend is live and otherwise auto-detecting
//! the active graphical session. The pure decision ([`pick_compositor`]) is separated from the I/O
//! shell ([`resolve_compositor`]) that runs the blocking session probes.
use super::*;
/// Pure selection: choose the backend to drive from the client's `pref`, the set `available`
/// right now, and the auto-`detected` default. A concrete preference wins only if it's available;
/// otherwise (and for `Auto`) fall back to the detected default. `None` only when nothing is
/// available *and* nothing was detected — the caller turns that into a handshake error.
fn pick_compositor(
pref: CompositorPref,
available: &[crate::vdisplay::Compositor],
detected: Option<crate::vdisplay::Compositor>,
) -> Option<crate::vdisplay::Compositor> {
use crate::vdisplay::Compositor;
match Compositor::from_pref(pref) {
Some(want) if available.contains(&want) => Some(want),
// `CompositorPref::Wlroots` names the wlroots *family* (D2): sway/river ([`Wlroots`]) and
// Hyprland are distinct backends but mutually-exclusive live sessions, so honor the request
// with whichever family member is actually available — the detected one if it's a family
// member, else the first available of the two.
Some(Compositor::Wlroots) => match detected {
Some(d @ (Compositor::Wlroots | Compositor::Hyprland)) => Some(d),
_ => [Compositor::Wlroots, Compositor::Hyprland]
.into_iter()
.find(|c| available.contains(c))
.or(detected),
},
_ => detected,
}
}
/// Resolve the client's compositor preference to a concrete backend (the I/O shell around
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the
/// async reactor (`spawn_blocking`).
pub(super) fn resolve_compositor(
pref: CompositorPref,
dedicated_launch: bool,
) -> Result<crate::vdisplay::Compositor> {
use crate::vdisplay::Compositor;
// Windows has a single virtual-display backend (pf-vdisplay); vdisplay::open ignores the compositor
// arg there, so short-circuit the Linux session-detection state machine with a placeholder.
#[cfg(target_os = "windows")]
{
let _ = (pref, dedicated_launch);
Ok(Compositor::Kwin)
}
#[cfg(not(target_os = "windows"))]
{
// A client is (re)connecting → cancel any pending TV-session restore so the box stays in the
// streamed session (covers the keep-alive REUSE reconnect, which skips create_managed_session's
// own cancel — review #3). No-op when nothing is pending.
crate::vdisplay::cancel_pending_tv_restore();
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
// to come with a hand-set env — don't retarget the process env in that case.
let overridden = crate::config::config().compositor.is_some();
let detected = if overridden {
crate::vdisplay::detect().ok()
} else {
// Auto: detect the LIVE session (Gaming vs Desktop) and retarget the process env at it so
// every backend (video capture + input) this connect opens against the active session —
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
let active = crate::vdisplay::detect_active_session();
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
// reuses a node id from the dead instance.
crate::vdisplay::observe_session_instance(&active);
crate::vdisplay::apply_session_env(&active);
tracing::info!(
active = ?active.kind,
wayland = active.env.wayland_display.as_deref().unwrap_or("-"),
"detected active graphical session"
);
crate::vdisplay::compositor_for_kind(active.kind)
};
// Dedicated game session (design/gamemode-and-dedicated-sessions.md B0): a launching session
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
if dedicated_launch && !overridden {
crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
tracing::info!(
"dedicated game session — routing to a headless gamescope spawn at the client mode"
);
return Ok(Compositor::Gamescope);
}
let available = crate::vdisplay::available();
let chosen = match pick_compositor(pref, &available, detected) {
Some(c) => c,
None => {
// No live session — the state a compositor crash leaves behind (gnome-shell
// SIGSEGV → GDM greeter, whose auto-login is once-per-boot). If the operator
// configured a recovery hook, fire it (debounced) and tell the client to retry:
// its next knock lands in the recovered desktop.
if crate::vdisplay::try_recover_session() {
anyhow::bail!(
"no live graphical session for this uid — host session recovery launched \
(PUNKTFUNK_RECOVER_SESSION_CMD); retry in a few seconds"
);
}
anyhow::bail!(
"no usable compositor (no live graphical session for this uid; set \
PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)"
);
}
};
if !overridden {
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
crate::vdisplay::apply_input_env(chosen, false);
}
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
match Compositor::from_pref(pref) {
Some(want) if want == chosen => {
tracing::info!(
compositor = chosen.id(),
"honoring client compositor request"
)
}
Some(want) => tracing::warn!(
requested = want.id(),
chosen = chosen.id(),
available = ?avail_ids,
"client-requested compositor unavailable — falling back to auto-detect"
),
None => tracing::info!(
compositor = chosen.id(),
"auto-detected compositor (client: auto)"
),
}
Ok(chosen)
}
}
#[cfg(test)]
mod tests {
use super::pick_compositor;
use punktfunk_core::config::CompositorPref;
#[test]
fn compositor_resolution_precedence() {
use crate::vdisplay::Compositor::*;
// A concrete, available preference is honored.
assert_eq!(
pick_compositor(CompositorPref::Gamescope, &[Kwin, Gamescope], Some(Kwin)),
Some(Gamescope)
);
// A concrete but UNavailable preference falls back to the detected default.
assert_eq!(
pick_compositor(CompositorPref::Mutter, &[Kwin, Gamescope], Some(Kwin)),
Some(Kwin)
);
// Auto always uses the detected default.
assert_eq!(
pick_compositor(CompositorPref::Auto, &[Kwin, Gamescope], Some(Kwin)),
Some(Kwin)
);
// Unavailable preference + nothing detected → None (caller errors the handshake).
assert_eq!(
pick_compositor(CompositorPref::Mutter, &[Gamescope], None),
None
);
// Available preference still wins even when nothing was auto-detected.
assert_eq!(
pick_compositor(CompositorPref::Gamescope, &[Gamescope], None),
Some(Gamescope)
);
// Wlroots family (D2): the shared `Wlroots` pref resolves to whichever of sway/river
// (Wlroots) and Hyprland is the live session.
assert_eq!(
pick_compositor(CompositorPref::Wlroots, &[Hyprland], Some(Hyprland)),
Some(Hyprland)
);
// …and to Wlroots-proper on a sway/river host.
assert_eq!(
pick_compositor(CompositorPref::Wlroots, &[Wlroots], Some(Wlroots)),
Some(Wlroots)
);
// Family fallback even if detection came back empty but a member is available.
assert_eq!(
pick_compositor(CompositorPref::Wlroots, &[Hyprland], None),
Some(Hyprland)
);
}
}
+386
View File
@@ -0,0 +1,386 @@
//! Virtual-gamepad backend resolution for the native session (plan §W1 — carved out of the
//! [`super`] module). Maps the client's [`GamepadPref`] (plus the host `PUNKTFUNK_GAMEPAD` env and
//! the live platform) to a backend this host can actually build, applying the runtime UHID /
//! Steam-conflict degrades. Pure selection ([`pick_gamepad`]) is separated from the env/logging
//! shell ([`resolve_gamepad`]) and the per-pad variant ([`resolve_pad_kind`]); the platform degrades
//! (`degrade_if_no_uhid`, `physical_steam_controller_present`, `degrade_steam_on_conflict`) are
//! cfg-split linux/other and MUST be re-verified on Windows when touched (Linux clippy can't see the
//! non-linux copies).
use super::*;
/// The per-pad routing decision for one frame ([`Pads::handle`]): given `owner` (the manager
/// holding a live device at this index, if any), the client-`declared` kind, and whether this is a
/// create/update frame (`present`) vs a removal, return `(kind to route to, new owner)`.
///
/// A live device stays in its owning manager even if the declared kind later changes (so a pad is
/// never duplicated across managers); the declared kind takes effect only when no device exists
/// yet; a removal routes to the owner's manager (so it tears the right device down) and clears the
/// owner.
pub(super) fn route_decision(
owner: Option<GamepadPref>,
declared: GamepadPref,
present: bool,
) -> (GamepadPref, Option<GamepadPref>) {
match (owner, present) {
(Some(k), true) => (k, Some(k)), // keep the existing device in its manager
(Some(k), false) => (k, None), // removal → owner's manager, then clear
(None, true) => (declared, Some(declared)), // create in the declared kind's manager
(None, false) => (declared, None), // removal with no device — a harmless no-op
}
}
/// Resolve one client-declared per-pad kind to a backend this host can actually build (mixed
/// types): the platform map + the runtime UHID / Steam-conflict degrades that [`resolve_gamepad`]
/// applies to the session default, minus the Auto/env session logic (a per-pad declaration is
/// always a concrete kind).
pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref {
let chosen = pick_gamepad(
kind,
None,
cfg!(target_os = "linux"),
cfg!(target_os = "windows"),
);
degrade_steam_on_conflict(degrade_if_no_uhid(chosen))
}
/// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins,
/// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360.
///
/// `linux`/`windows` flag the host platform. DualSense and DualShock 4 each have both a Linux (UHID
/// hid-playstation) and a Windows (UMDF minidriver) backend; on any other platform such a wish degrades
/// to X-Box 360 (never an error: a session without rich pads still streams). X-Box One/Series is a
/// distinct uinput *identity* on Linux, but XInput-identical to the 360 pad on Windows (the XUSB
/// companion presents a 360 identity), so it degrades to `Xbox360` there.
fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref {
let want = match pref {
GamepadPref::Auto => env
.and_then(GamepadPref::from_name)
.unwrap_or(GamepadPref::Auto),
explicit => explicit,
};
match want {
// DualSense / DualShock 4: Linux UHID hid-playstation, or the Windows UMDF minidriver backend.
GamepadPref::DualSense if linux || windows => GamepadPref::DualSense,
GamepadPref::DualShock4 if linux || windows => GamepadPref::DualShock4,
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
// Windows (XInput can't tell them apart anyway).
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
// Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices
// are the N4 spike).
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
GamepadPref::SteamController if linux => GamepadPref::SteamController,
// Windows virtual Deck: the UMDF device-type-3 identity, Steam-Input-promoted via the
// MI_02 hardware-id synthesis (gamepad-new-types N4) — native Deck glyphs + trackpads +
// gyro + back grips, replacing the old fold to DualSense.
GamepadPref::SteamDeck if windows => GamepadPref::SteamDeck,
// DualSense Edge: Linux UHID hid-playstation / Windows UMDF (device-type 2) — the plain
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
// policy. Degrades to Xbox360 elsewhere like its siblings.
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
// New Steam Controller (2026, `28DE:1302`): passed through as-is on Linux — the Triton
// UHID backend mirrors the client's raw reports under the real identity and Steam on
// the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the
// consumer). No Windows backend; folds to Xbox360 there.
GamepadPref::SteamController2 if linux => GamepadPref::SteamController2,
GamepadPref::SteamController2Puck if linux => GamepadPref::SteamController2Puck,
_ => GamepadPref::Xbox360,
}
}
/// Runtime degrade for the Linux UHID backends (DualSense / DualShock 4 / Steam Deck): if
/// `/dev/uhid` can't be opened for write *now*, fall back to the uinput X-Box 360 pad rather than a
/// dead controller (the UHID device-create would just fail). Cheap — opens + drops the char device,
/// no `UHID_CREATE2`, so no device is created. A no-op on non-Linux (those backends are UMDF/uinput).
#[cfg(target_os = "linux")]
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
let needs_uhid = matches!(
chosen,
GamepadPref::DualSense
| GamepadPref::DualSenseEdge
| GamepadPref::DualShock4
| GamepadPref::SteamDeck
| GamepadPref::SteamController
| GamepadPref::SteamController2
| GamepadPref::SteamController2Puck
| GamepadPref::SwitchPro
);
if needs_uhid
&& std::fs::OpenOptions::new()
.write(true)
.open("/dev/uhid")
.is_err()
{
tracing::warn!(
wanted = chosen.as_str(),
"/dev/uhid not writable — falling back to the X-Box 360 pad"
);
return GamepadPref::Xbox360;
}
chosen
}
#[cfg(not(target_os = "linux"))]
fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
chosen
}
/// True if a **physical** Valve Steam controller (`28DE`) is already attached. The host's own Steam
/// Input is then managing a `28DE` device, and presenting a second (virtual) one makes Steam juggle
/// two Decks — confirmed conflict-prone on a Deck-as-host (the physical `28DE:1205` + Steam's
/// `28DE:11FF` XInput output pad are both live). HID device dirs are named `BUS:VID:PID.INST`
/// (uppercase); a UHID virtual device resolves through `/devices/virtual/…`, a real one does not.
///
/// Punktfunk's OWN virtual Decks must never count: the usbip/gadget transports present a real USB
/// device (vhci resolves through `vhci_hcd`, NOT `/devices/virtual/`), so a just-ended session's
/// pad still detaching — or a concurrent session's live one — read as "physical" and degraded
/// every back-to-back Deck session to DualSense (observed live on Bazzite 2026-07-04). Ours are
/// recognizable by the `FVPF…` serial ([`steam_proto::deck_serial`]) in `HID_UNIQ`, with the
/// vhci path as belt and braces.
#[cfg(target_os = "linux")]
fn physical_steam_controller_present() -> bool {
let Ok(entries) = std::fs::read_dir("/sys/bus/hid/devices") else {
return false;
};
entries.flatten().any(|e| {
if !e.file_name().to_string_lossy().contains(":28DE:") {
return false;
}
if std::fs::read_to_string(e.path().join("uevent"))
.is_ok_and(|u| u.lines().any(|l| l.starts_with("HID_UNIQ=FVPF")))
{
return false; // one of our own virtual Decks
}
match std::fs::read_link(e.path()) {
Ok(target) => {
let t = target.to_string_lossy();
!t.contains("/virtual/") && !t.contains("vhci_hcd")
}
Err(_) => true,
}
})
}
/// Gate a virtual Steam pad off when a physical Steam controller is attached (§ conflict). Degrade to
/// DualSense (then the uhid ladder), which Steam treats as an ordinary, distinct pad. Override with
/// `PUNKTFUNK_STEAM_FORCE=1` when the host has no competing Steam Input (e.g. a remote-only box).
#[cfg(target_os = "linux")]
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
if !matches!(
chosen,
GamepadPref::SteamDeck
| GamepadPref::SteamController
| GamepadPref::SteamController2
| GamepadPref::SteamController2Puck
) {
return chosen;
}
let forced = std::env::var("PUNKTFUNK_STEAM_FORCE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if !forced && physical_steam_controller_present() {
tracing::warn!(
wanted = chosen.as_str(),
"a physical Steam controller is attached — the host's Steam Input would manage two 28DE \
devices; falling back to DualSense (set PUNKTFUNK_STEAM_FORCE=1 to override)"
);
return degrade_if_no_uhid(GamepadPref::DualSense);
}
chosen
}
#[cfg(not(target_os = "linux"))]
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
chosen
}
/// Resolve the client's gamepad-backend preference (the env/logging shell around
/// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive.
pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref {
let env = crate::config::config().gamepad.clone();
let chosen = pick_gamepad(
pref,
env.as_deref(),
cfg!(target_os = "linux"),
cfg!(target_os = "windows"),
);
// Runtime degrade (separate from the compile-time platform check above): the Linux UHID
// backends need `/dev/uhid` usable *now*, else creating the device just fails and the controller
// goes dead — fall back to the always-available uinput X-Box 360 pad instead.
let chosen = degrade_if_no_uhid(chosen);
// Conflict gate: don't present a virtual Steam (28DE) pad when the host already has a physical
// Steam controller — its own Steam Input would then manage two Decks (confirmed conflict-prone on
// a Deck-as-host). `PUNKTFUNK_STEAM_FORCE=1` overrides.
let chosen = degrade_steam_on_conflict(chosen);
match pref {
GamepadPref::Auto => {
// The operator's env knob deserves a diagnostic when it didn't drive the
// choice — a typo, or a DualSense wish on a non-UHID host, would otherwise
// degrade silently.
if let Some(env) = env.as_deref() {
if GamepadPref::from_name(env) != Some(chosen) {
tracing::warn!(
env,
chosen = chosen.as_str(),
"PUNKTFUNK_GAMEPAD unrecognized or unavailable — falling back"
);
}
}
tracing::info!(gamepad = chosen.as_str(), "gamepad backend (client: auto)")
}
want if want == chosen => {
tracing::info!(gamepad = chosen.as_str(), "honoring client gamepad request")
}
want => tracing::warn!(
requested = want.as_str(),
chosen = chosen.as_str(),
"client-requested gamepad backend unavailable — falling back"
),
}
chosen
}
#[cfg(test)]
mod tests {
use super::{pick_gamepad, route_decision};
use punktfunk_core::config::GamepadPref;
#[test]
fn per_pad_route_decision() {
use GamepadPref::{DualSense, Xbox360};
// First frame with no device: create in the declared kind's manager, record ownership.
assert_eq!(
route_decision(None, DualSense, true),
(DualSense, Some(DualSense))
);
// Subsequent frame: stays in the owning manager even if the declared kind now differs
// (the arrival-after-first-frame reorder) — never a second device in another manager.
assert_eq!(
route_decision(Some(DualSense), Xbox360, true),
(DualSense, Some(DualSense))
);
// Removal (cleared bit): routes to the owner so the RIGHT device is torn down, then clears.
assert_eq!(
route_decision(Some(DualSense), Xbox360, false),
(DualSense, None)
);
// Removal with no device is a harmless no-op route (owner stays cleared).
assert_eq!(route_decision(None, Xbox360, false), (Xbox360, None));
// A fresh device after a re-plug picks up the newly-declared kind (owner was cleared).
assert_eq!(
route_decision(None, Xbox360, true),
(Xbox360, Some(Xbox360))
);
}
#[test]
fn gamepad_resolution_precedence() {
use GamepadPref::*;
// Trailing args are (linux, windows).
// An explicit client choice wins over the env var.
assert_eq!(
pick_gamepad(DualSense, Some("xbox360"), true, false),
DualSense
);
assert_eq!(
pick_gamepad(Xbox360, Some("dualsense"), true, false),
Xbox360
);
// Client Auto defers to the env var.
assert_eq!(
pick_gamepad(Auto, Some("dualsense"), true, false),
DualSense
);
assert_eq!(pick_gamepad(Auto, Some("xbox360"), true, false), Xbox360);
// Auto + no env (or an unparseable one) → X-Box 360.
assert_eq!(pick_gamepad(Auto, None, true, false), Xbox360);
assert_eq!(pick_gamepad(Auto, Some("bogus"), true, false), Xbox360);
// DualSense: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere.
assert_eq!(pick_gamepad(DualSense, None, false, true), DualSense);
assert_eq!(
pick_gamepad(Auto, Some("dualsense"), false, true),
DualSense
);
assert_eq!(pick_gamepad(DualSense, None, false, false), Xbox360);
assert_eq!(pick_gamepad(Auto, Some("dualsense"), false, false), Xbox360);
// DualShock 4: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere.
assert_eq!(pick_gamepad(DualShock4, None, true, false), DualShock4);
assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4);
assert_eq!(pick_gamepad(DualShock4, None, false, true), DualShock4);
assert_eq!(pick_gamepad(DualShock4, None, false, false), Xbox360);
// X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows.
assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne);
assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne);
assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360);
// Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3,
// Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere.
assert_eq!(pick_gamepad(SteamDeck, None, true, false), SteamDeck);
assert_eq!(pick_gamepad(SteamDeck, None, false, true), SteamDeck);
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), SteamDeck);
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
assert_eq!(
pick_gamepad(SteamController, None, true, false),
SteamController
);
assert_eq!(
pick_gamepad(Auto, Some("steamcontroller"), true, false),
SteamController
);
assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360);
// DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
// elsewhere.
assert_eq!(
pick_gamepad(DualSenseEdge, None, true, false),
DualSenseEdge
);
assert_eq!(
pick_gamepad(DualSenseEdge, None, false, true),
DualSenseEdge
);
assert_eq!(pick_gamepad(Auto, Some("edge"), true, false), DualSenseEdge);
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
assert_eq!(
pick_gamepad(Auto, Some("switchpro"), true, false),
SwitchPro
);
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
// New Steam Controller (as-is Triton passthrough): native on Linux (UHID, Steam-driven);
// Xbox360 on Windows and elsewhere.
assert_eq!(
pick_gamepad(SteamController2, None, true, false),
SteamController2
);
assert_eq!(
pick_gamepad(Auto, Some("sc2"), true, false),
SteamController2
);
assert_eq!(
pick_gamepad(Auto, Some("ibex"), true, false),
SteamController2
);
assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360);
assert_eq!(
pick_gamepad(SteamController2Puck, None, true, false),
SteamController2Puck
);
assert_eq!(
pick_gamepad(Auto, Some("sc2puck"), true, false),
SteamController2Puck
);
assert_eq!(
pick_gamepad(SteamController2Puck, None, false, true),
Xbox360
);
}
}
@@ -0,0 +1,377 @@
//! The native `punktfunk/1` handshake negotiation (plan §W1 — carved out of the [`super`] module).
//! After the pairing gate (which stays in `serve_session`, since its delegated-approval wait must
//! outlive the short handshake timeout and release the session permit), this decodes the client's
//! [`Hello`], runs mode-conflict admission, negotiates codec / compositor / gamepad / bitrate /
//! audio channels / bit-depth / chroma, reserves the data-plane UDP socket, sends the [`Welcome`],
//! and reads the client's [`Start`] — returning everything `serve_session` needs to stand the
//! session up.
use super::*;
/// Run the Hello→Welcome→Start negotiation. Borrows the control streams (the caller keeps them for
/// mid-stream renegotiation afterwards). `first` is the already-read first control message.
#[allow(clippy::type_complexity)]
pub(super) async fn negotiate(
conn: &quinn::Connection,
send: &mut quinn::SendStream,
recv: &mut quinn::RecvStream,
first: &[u8],
source: Punktfunk1Source,
frames: u32,
data_port: Option<u16>,
) -> Result<(
Hello,
Welcome,
u16,
std::net::UdpSocket,
bool,
Start,
Option<crate::vdisplay::Compositor>,
)> {
let peer = conn.remote_address();
let mut hello = Hello::decode(first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
if hello.abi_version != punktfunk_core::WIRE_VERSION {
close_rejected(
conn,
punktfunk_core::reject::RejectReason::WireVersionMismatch,
);
anyhow::bail!(
"wire version mismatch: client {} host {}",
hello.abi_version,
punktfunk_core::WIRE_VERSION
);
}
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
// before this future, so a client reaching here is paired (or the host is `--open`).
// Codec negotiation: pick the one codec this host will emit (its GPU-probed backend
// capability ∩ the client's advertised codecs, honoring the client's soft preference).
// A GPU-less software host emits H.264 only, so an HEVC-only client shares nothing with
// it → refuse honestly rather than send a stream it can't decode.
let host_codecs = crate::encode::Codec::host_wire_caps();
let codec_bit =
punktfunk_core::quic::resolve_codec(hello.video_codecs, host_codecs, hello.preferred_codec)
.ok_or_else(|| {
anyhow!(
"no shared video codec: client advertised 0x{:02x}, host can emit 0x{:02x} \
(a software-encode host produces H.264 the client must advertise CODEC_H264)",
hello.video_codecs,
host_codecs
)
})?;
let codec = crate::encode::Codec::from_wire(codec_bit);
tracing::info!(
?codec,
client_codecs = format_args!("0x{:02x}", hello.video_codecs),
host_codecs = format_args!("0x{host_codecs:02x}"),
"video codec negotiated"
);
// Mode-conflict ADMISSION (Stage 4): a DIFFERENT client connecting while another client's
// session is live is resolved by the `mode_conflict` policy BEFORE the Welcome — `separate`
// (default, no change), `join` (serve at the live mode — an honest downgrade the client
// renders from the Welcome), `steal` (preempt the victim), or `reject` (refuse the handshake).
// A same-client reconnect never conflicts. THIS session registers in the live set once its
// data plane is up (below the handshake), so a later client can see + steal it.
{
use crate::vdisplay::admission::{admit, preempt_same_identity, Admission};
let peer_fp = endpoint::peer_fingerprint(conn);
// Same-client RECONNECT preempt (design §5.3 "preempts downstream"): if THIS client
// already has a live session, it's the zombie of an unwanted disconnect whose QUIC idle
// timer hasn't fired yet (detection lags a drop by up to `max_idle_timeout`). Signal it to
// stop and give it the release grace so it tears its display down — which, keep-alive on,
// lingers — and THIS reconnect REUSES that kept display below instead of landing on a
// fresh SECOND one. Independent of the mode_conflict arm (it's our OWN prior session, not
// a conflict with a different client), and it runs before we register ourselves so we
// never signal our own stop flag.
let own_zombies = preempt_same_identity(peer_fp);
if !own_zombies.is_empty() {
tracing::info!(
count = own_zombies.len(),
"reconnect: preempting this client's own zombie session(s) so the kept display is reused"
);
for z in &own_zombies {
z.store(true, Ordering::SeqCst);
}
// Same blind release grace the steal path uses — lets the zombie's loops notice the
// stop flag and drop its display (→ Lingering) before we acquire below.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
match admit(peer_fp) {
Admission::Separate => {}
Admission::Join(m) => {
tracing::info!(
requested =
%format_args!("{}x{}@{}", hello.mode.width, hello.mode.height, hello.mode.refresh_hz),
live = %format_args!("{}x{}@{}", m.0, m.1, m.2),
"mode-conflict: JOIN — admitting at the live display's mode"
);
hello.mode.width = m.0;
hello.mode.height = m.1;
hello.mode.refresh_hz = m.2;
}
Admission::Steal(victims) => {
tracing::info!(
victims = victims.len(),
"mode-conflict: STEAL — preempting the live session(s)"
);
for v in &victims {
v.store(true, Ordering::SeqCst);
}
// Give the victims the release grace to tear their display down before we acquire.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
Admission::Reject(reason) => {
tracing::warn!("mode-conflict: REJECT — {reason}");
// Deliver the reason to the client as a TYPED refusal: close the QUIC connection
// with the BUSY application code + the reason bytes, which the client reads from
// the `ApplicationClosed` error (so its UI can say "host is streaming X to <name>")
// instead of seeing a bare connection drop. Then end the handshake.
conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes());
anyhow::bail!("{reason}");
}
}
}
crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
.context("client-requested mode")?;
// Resolve the client's compositor preference to a concrete backend *now*, so the Welcome
// can report what we'll actually drive. Only the Virtual source has a compositor; the
// synthetic source has no virtual output. Blocking probes → spawn_blocking.
let compositor = match source {
Punktfunk1Source::Virtual => {
let pref = hello.compositor;
// Dedicated game session (B0): a launching client under `game_session=dedicated`
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
#[cfg(not(target_os = "windows"))]
let has_resolvable_launch = hello
.launch
.as_deref()
.and_then(crate::library::launch_command)
.is_some();
#[cfg(target_os = "windows")]
let has_resolvable_launch = false;
let dedicated = crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
Some(
tokio::task::spawn_blocking(move || resolve_compositor(pref, dedicated))
.await
.context("resolve compositor task")??,
)
}
Punktfunk1Source::Synthetic => None,
};
// A requested library launch (the client sends only the store-qualified id; we look it up
// in OUR library so a client can't inject a command) is resolved below — after the Welcome,
// where it's threaded per-session into the data plane as `SessionContext.launch` (no
// process-global env: the old `PUNKTFUNK_GAMESCOPE_APP` write leaked across sessions, and
// only gamescope's bare-spawn path ever read it, so launches on every other backend were
// silently dropped).
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
// needed; the actual pads are created lazily by the input thread).
let gamepad = resolve_gamepad(hello.gamepad);
// Resolve the encoder bitrate (client request clamped to a sane range, or a
// codec-aware host default — PyroWave pins ~1.6 bpp for the mode).
let bitrate_kbps = resolve_bitrate_kbps_for(codec, hello.bitrate_kbps, &hello.mode);
tracing::info!(
requested_kbps = hello.bitrate_kbps,
resolved_kbps = bitrate_kbps,
"encoder bitrate"
);
// Resolve the audio channel count (client request → stereo / 5.1 / 7.1). The capturer opens
// at this count: PipeWire synthesizes the requested positions (padding with silence when the
// sink has fewer), WASAPI loopback up/downmixes via AUTOCONVERTPCM — so a client always gets
// the channels it asked for, and the Welcome echoes the value the audio thread will encode.
let audio_channels = resolve_audio_channels(hello.audio_channels);
tracing::info!(
requested = hello.audio_channels,
resolved = audio_channels,
"audio channels"
);
// Resolve the encode bit depth: 10-bit (HEVC Main10 / AV1 10-bit) only when ALL of — the
// host allows it (PUNKTFUNK_10BIT, default ON with explicit-off grammar; the CLIENT's HDR
// setting behind VIDEO_CAP_10BIT is the per-session policy switch), the client advertised
// VIDEO_CAP_10BIT (a client that can't decode 10-bit, or an older client, always gets the
// 8-bit stream), the codec has a 10-bit path (HEVC/AV1 — H.264 never), and the active
// GPU/backend actually encodes 10-bit for that codec (probed, cached). Resolved BEFORE the
// Welcome, exactly like the 4:4:4 gate below, so `color` reflects what we'll really emit —
// the honest-downgrade channel: a GPU/backend that can't 10-bit yields 8-bit AND an SDR
// label that matches the stream.
let host_wants_10bit = crate::config::config().ten_bit;
let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0;
// The GPU probe may open a tiny encoder on first use, so run it off the reactor like the
// 4:4:4 probe below (blocking probes → spawn_blocking), short-circuited behind the cheap
// gates. The result is cached process-wide per (GPU, codec).
let gpu_can_10bit = if host_wants_10bit && client_supports_10bit && codec.supports_10bit() {
tokio::task::spawn_blocking(move || crate::encode::can_encode_10bit(codec))
.await
.context("10-bit capability probe task")?
} else {
false
};
let bit_depth: u8 = if gpu_can_10bit { 10 } else { 8 };
tracing::info!(
bit_depth,
host_wants_10bit,
client_supports_10bit,
codec = ?codec,
gpu_can_10bit,
client_video_caps = hello.video_caps,
"encode bit depth"
);
// Resolve the chroma subsampling: full-chroma HEVC 4:4:4 only when ALL of — the host
// allows it (PUNKTFUNK_444, default ON; the CLIENT's 4:4:4 setting — default OFF — is the
// per-session policy switch behind VIDEO_CAP_444), the client advertised VIDEO_CAP_444,
// the session is single-process (the two-process WGC relay encodes 4:2:0 in v1), and the
// active GPU/driver actually supports a 4:4:4 encode (probed, cached). The native path
// always encodes HEVC. We resolve this BEFORE the Welcome so `chroma_format` reflects
// what we'll really emit — the honest-downgrade channel: if any gate fails the client is
// told 4:2:0 before it builds its decoder. The probe opens a tiny encoder; it runs only
// when the earlier gates pass and is cached after the first.
let host_wants_444 = crate::config::config().four_four_four;
let client_supports_444 = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_444 != 0;
// The active capturer must be able to deliver a full-chroma (RGB) source — the honest-downgrade
// gate. Linux's portal capturer can; the Windows IDD-push path delivers subsampled NV12/P010
// today (full-chroma IDD-push capture is a follow-up), so it returns false there and the host
// negotiates 4:2:0. (Replaces the old `single_process` gate — single-process is now the only
// topology, and 4:4:4 routed to DDA, which was removed.)
let capture_supports_444 = crate::capture::capturer_supports_444();
// The GPU probe opens a real (tiny) encoder on first use, so run it off the reactor like the
// compositor probe above (blocking probes → spawn_blocking). Short-circuit so it only runs when
// the cheap gates already pass. The result is cached process-wide (a negative latches until
// restart — acceptable: a GPU either supports HEVC 4:4:4 or it doesn't, and a transient open
// failure here is rare since the session's own encoder isn't open yet).
let gpu_supports_444 = if codec == crate::encode::Codec::H265
&& host_wants_444
&& client_supports_444
&& capture_supports_444
{
tokio::task::spawn_blocking(|| crate::encode::can_encode_444(crate::encode::Codec::H265))
.await
.context("4:4:4 capability probe task")?
} else {
false
};
let chroma = if gpu_supports_444 {
crate::encode::ChromaFormat::Yuv444
} else {
crate::encode::ChromaFormat::Yuv420
};
tracing::info!(
chroma = ?chroma,
host_wants_444,
client_supports_444,
capture_supports_444,
"encode chroma"
);
// Linux 4:4:4 rides the CPU swscale → 8-bit `YUV444P` path (see `encode/linux`) — there
// is no 10-bit 4:4:4 input there, so a 10-bit-negotiated session would silently encode
// 8-bit. Resolve the depth DOWN before the Welcome so the wire never overstates what the
// stream carries. (Windows NVENC composes Main 4:4:4 10 from an RGB input, so it keeps
// the resolved depth — this clamp is Linux-only.)
#[cfg(target_os = "linux")]
let bit_depth: u8 = if chroma.is_444() && bit_depth == 10 {
tracing::info!("4:4:4 on the Linux path encodes 8-bit YUV444P — resolving bit depth 8");
8
} else {
bit_depth
};
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
// no punch-wait); otherwise a random ephemeral port + hole-punch.
let (data_sock, direct) = bind_data_socket(data_port)?;
let udp_port = data_sock.local_addr()?.port();
let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key);
// Fresh per-session salt alongside the fresh key. GCM nonce uniqueness only *requires* one
// of the two to be unique per session (the nonce is salt || sequence under the session
// key), but a constant salt would make a key-reuse bug catastrophic instead of merely
// wrong — this keeps the second line of defense real. Negotiated via Welcome, so clients
// just follow.
let mut salt = [0u8; 4];
rand::thread_rng().fill_bytes(&mut salt);
let welcome = Welcome {
abi_version: punktfunk_core::WIRE_VERSION,
udp_port,
mode: hello.mode,
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
fec: FecConfig {
scheme: FecScheme::Gf16,
// Static override pins it; otherwise sessions start at the adaptive midpoint and the
// host re-sizes FEC live from the client's LossReports (adaptive FEC).
fec_percent: fec_static_override().unwrap_or(FEC_ADAPTIVE_START),
max_data_per_block: 4096,
},
// The largest even payload whose sealed datagram (header + shard + crypto) fits an
// unfragmented UDP packet on a 1500 MTU for THIS client's address family — 1408 over
// IPv4 (1472 = the exact ceiling), 1388 over IPv6 (40-byte header, and v6 routers
// don't fragment: overshooting there blackholes instead of degrading). The data plane
// dials the same family as this QUIC connection, so the remote decides. The previous
// hardcoded 1452 overshot the v4 ceiling (its math forgot the header/crypto ride
// inside the UDP payload) and silently IP-fragmented EVERY video datagram, doubling
// per-datagram loss on Wi-Fi — the "100 Mbps badly fails on the phone" root cause.
// Negotiated, so the client follows. Jumbo (≈8900) is a future negotiated bump (needs
// MAX_DATAGRAM_BYTES raised + end-to-end 9000 MTU).
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
encrypt: true,
key,
salt,
frames: match source {
Punktfunk1Source::Synthetic => frames,
Punktfunk1Source::Virtual => 0, // unbounded — client streams until we close
},
// Report the resolved backends back to the client (compositor: Auto for the
// synthetic source).
compositor: compositor
.map(|c| c.as_pref())
.unwrap_or(CompositorPref::Auto),
gamepad,
bitrate_kbps,
bit_depth,
// Colour signalling the client configures its decoder/presenter from. A negotiated
// 10-bit session is our HDR path (BT.2020 PQ — what the NVENC HEVC VUI emits from a
// 10-bit capture format); 8-bit stays BT.709 SDR. The mastering metadata (ST.2086 +
// CLL) rides the 0xCE datagram below. (A future step can refine this to the capturer's
// actual monitor HDR state and announce a mid-stream flip.)
color: if bit_depth >= 10 {
ColorInfo::HDR10_BT2020_PQ
} else {
ColorInfo::SDR_BT709
},
// The chroma the encoder will actually emit (resolved + GPU-probed above) — 4:4:4 only
// when every gate passed, else 4:2:0. The client sizes its decoder from this.
chroma_format: chroma.idc(),
// The resolved audio channel count the audio thread will capture + Opus-(multi)stream
// encode (2/6/8). The client builds its decoder from this echoed value.
audio_channels,
// The negotiated codec the encoder will emit (client preference ∩ GPU capability;
// HEVC-precedence tie-break). The client builds its decoder from this instead of
// assuming HEVC.
codec: codec_bit,
// This host applies sequence-gated gamepad-state snapshots (InputKind::GamepadState),
// so capable clients send those instead of the loss-fragile per-transition events.
host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE,
};
io::write_msg(send, &welcome.encode()).await?;
let start =
Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?;
Ok::<_, anyhow::Error>((
hello, welcome, udp_port, data_sock, direct, start, compositor,
))
}
+998
View File
@@ -0,0 +1,998 @@
//! The native input plane (plan §W1 — carved out of the [`super`] module): the client→host input
//! thread and the per-pad virtual-gamepad router ([`Pads`]) that fans mixed controller kinds out to
//! the right injector backend (uinput / UHID on Linux, XUSB / UMDF on Windows), plus rumble
//! feedback. `serve_session` spawns [`input_thread`] and feeds it a channel of [`ClientInput`].
use super::*;
/// Per-pad accumulated state: punktfunk/1 gamepad events are incremental (one button or axis
/// per datagram, see `punktfunk_core::input::gamepad`), the virtual xpad applies full frames.
/// A snapshot-capable client replaces the whole state at once ([`PadState::set_snapshot`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct PadState {
buttons: u32,
left_trigger: u8,
right_trigger: u8,
ls_x: i16,
ls_y: i16,
rs_x: i16,
rs_y: i16,
}
impl PadState {
/// Fold one wire event into the state. `false` = unknown axis id (event dropped).
fn apply(&mut self, ev: &InputEvent) -> bool {
if ev.kind == InputKind::GamepadButton {
if ev.x != 0 {
self.buttons |= ev.code;
} else {
self.buttons &= !ev.code;
}
return true;
}
use punktfunk_core::input::gamepad::*;
let stick = ev.x.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
let trigger = ev.x.clamp(0, 255) as u8;
match ev.code {
AXIS_LS_X => self.ls_x = stick,
AXIS_LS_Y => self.ls_y = stick,
AXIS_RS_X => self.rs_x = stick,
AXIS_RS_Y => self.rs_y = stick,
AXIS_LT => self.left_trigger = trigger,
AXIS_RT => self.right_trigger = trigger,
_ => return false,
}
true
}
/// Replace the whole state from one client snapshot (the [`InputKind::GamepadState`] form).
fn set_snapshot(&mut self, s: &punktfunk_core::input::GamepadSnapshot) {
self.buttons = s.buttons;
self.left_trigger = s.left_trigger;
self.right_trigger = s.right_trigger;
self.ls_x = s.ls_x;
self.ls_y = s.ls_y;
self.rs_x = s.rs_x;
self.rs_y = s.rs_y;
}
fn frame(&self, index: usize, active_mask: u16) -> crate::gamestream::gamepad::GamepadFrame {
crate::gamestream::gamepad::GamepadFrame {
index: index as i16,
active_mask,
buttons: self.buttons,
left_trigger: self.left_trigger,
right_trigger: self.right_trigger,
ls_x: self.ls_x,
ls_y: self.ls_y,
rs_x: self.rs_x,
rs_y: self.rs_y,
}
}
}
/// Highest pad index addressable on the wire (`flags` field / snapshot `pad`); the uinput
/// manager caps actual pad creation at its own MAX_PADS.
const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
/// Per-pad virtual-gamepad router: each pad index is served by a backend of that pad's declared
/// kind ([`InputKind::GamepadArrival`](punktfunk_core::input::InputKind::GamepadArrival)), so ONE
/// session can MIX controller types — pad 0 a DualSense, pad 1 an Xbox pad. A pad the client never
/// declares uses `default` (the session kind resolved from the Hello — the pre-existing single-kind
/// behaviour).
///
/// Backends are created lazily per kind (an empty manager holds no device), and each owns only the
/// indices routed to it. A manager's `active_mask` unplug sweep stays correct across managers
/// because an index another manager owns is `None` in this one, so the sweep never touches it.
///
/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
/// two identities), the XUSB companion driver (classic XInput) on Windows.
/// - DualSense / DualSense Edge / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF
/// minidriver (device-type 0/2/1).
/// - Steam Deck — Linux UHID `hid-steam` (or usbip/gadget), or the Windows UMDF minidriver
/// (device-type 3, Steam-Input-promoted).
///
/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never
/// constructs a manager the build lacks.
struct Pads {
/// Declared (and host-resolved) kind per pad index; `default` until a `GamepadArrival` lands.
kinds: [GamepadPref; MAX_WIRE_PADS],
/// The kind of the manager that currently OWNS a built device at each index (`None` = no
/// device). A live device stays in its manager even if `kinds[idx]` later changes (the rare
/// arrival-after-first-frame reorder), so a pad is never duplicated across managers and its
/// removal always reaches the manager that actually holds it.
owner: [Option<GamepadPref>; MAX_WIRE_PADS],
xbox360: Option<crate::inject::gamepad::GamepadManager>,
#[cfg(target_os = "linux")]
xboxone: Option<crate::inject::gamepad::GamepadManager>,
#[cfg(target_os = "linux")]
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
#[cfg(target_os = "linux")]
dualsense_edge: Option<crate::inject::dualsense::DualSenseEdgeManager>,
#[cfg(target_os = "linux")]
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
#[cfg(target_os = "linux")]
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "linux")]
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
#[cfg(target_os = "linux")]
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
#[cfg(target_os = "linux")]
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "linux")]
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")]
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[cfg(target_os = "windows")]
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
#[cfg(target_os = "windows")]
steamdeck_win: Option<crate::inject::steam_deck_windows::SteamDeckWindowsManager>,
}
impl Pads {
/// `default` is the session kind (see [`resolve_gamepad`]); every pad starts on it until the
/// client declares its own kind.
fn new(default: GamepadPref) -> Pads {
let default = resolve_pad_kind(default);
tracing::info!(
default = default.as_str(),
"gamepad backends: per-pad router (session default)"
);
Pads {
kinds: [default; MAX_WIRE_PADS],
owner: [None; MAX_WIRE_PADS],
xbox360: None,
#[cfg(target_os = "linux")]
xboxone: None,
#[cfg(target_os = "linux")]
dualsense: None,
#[cfg(target_os = "linux")]
dualsense_edge: None,
#[cfg(target_os = "linux")]
dualshock4: None,
#[cfg(target_os = "linux")]
steamdeck: None,
#[cfg(target_os = "linux")]
switchpro: None,
#[cfg(target_os = "linux")]
steamctrl: None,
#[cfg(target_os = "linux")]
steamctrl2: None,
#[cfg(target_os = "linux")]
steamctrl2_puck: None,
#[cfg(target_os = "windows")]
dualsense_win: None,
#[cfg(target_os = "windows")]
dualsense_edge_win: None,
#[cfg(target_os = "windows")]
dualshock4_win: None,
#[cfg(target_os = "windows")]
steamdeck_win: None,
}
}
/// Record a pad's client-declared kind (resolved to a buildable backend). Takes effect on the
/// pad's next frame; the arrival is sent before the pad's first input, so a device already
/// built under the wrong kind is only the rare arrival-after-first-frame reorder — it then
/// keeps the earlier kind until re-plug (no live device swap).
fn set_kind(&mut self, idx: usize, kind: GamepadPref) {
if idx >= MAX_WIRE_PADS {
return;
}
let resolved = resolve_pad_kind(kind);
if self.kinds[idx] != resolved {
tracing::info!(
pad = idx,
kind = resolved.as_str(),
"gamepad kind declared (per-pad)"
);
}
self.kinds[idx] = resolved;
}
fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
use crate::gamestream::gamepad::GamepadEvent;
// Present = a create/update frame (the pad's mask bit is set); a cleared bit is the
// removal frame emitted by the native detach path (`GamepadRemove`).
let (idx, present) = match ev {
GamepadEvent::State(f) => {
let idx = f.index as usize;
(idx, f.active_mask & (1 << idx) != 0)
}
GamepadEvent::Arrival { index, .. } => (*index as usize, true),
};
if idx >= MAX_WIRE_PADS {
return;
}
let (kind, new_owner) = route_decision(self.owner[idx], self.kinds[idx], present);
self.owner[idx] = new_owner;
self.route_handle(kind, ev);
}
/// Dispatch a decoded event to the manager for `kind`, creating it lazily.
fn route_handle(&mut self, kind: GamepadPref, ev: &crate::gamestream::gamepad::GamepadEvent) {
match kind {
#[cfg(target_os = "linux")]
GamepadPref::DualSense => self
.dualsense
.get_or_insert_with(crate::inject::dualsense::DualSenseManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => self
.dualsense_edge
.get_or_insert_with(crate::inject::dualsense::DualSenseEdgeManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => self
.dualshock4
.get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamDeck => self
.steamdeck
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SwitchPro => self
.switchpro
.get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamController => self
.steamctrl
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamController2 => self
.steamctrl2
.get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamController2Puck => self
.steamctrl2_puck
.get_or_insert_with(|| {
crate::inject::steam_controller2::Triton2Manager::with_backend(
crate::inject::steam_controller2::TritonProto::puck(),
)
})
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::XboxOne => self
.xboxone
.get_or_insert_with(|| {
crate::inject::gamepad::GamepadManager::with_identity(
crate::inject::gamepad::PadIdentity::xbox_one(),
)
})
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualSense => self
.dualsense_win
.get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualSenseEdge => self
.dualsense_edge_win
.get_or_insert_with(
crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager::new,
)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::DualShock4 => self
.dualshock4_win
.get_or_insert_with(
crate::inject::dualshock4_windows::DualShock4WindowsManager::new,
)
.handle(ev),
#[cfg(target_os = "windows")]
GamepadPref::SteamDeck => self
.steamdeck_win
.get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new)
.handle(ev),
_ => self
.xbox360
.get_or_insert_with(crate::inject::gamepad::GamepadManager::new)
.handle(ev),
}
}
/// Apply a rich client→host event (touchpad / motion) to the pad's kind manager, if it exists
/// (rich before the first frame = no device yet = a no-op anyway). The X-Box pads have no rich
/// plane, so those indices ignore it.
fn apply_rich(&mut self, rich: punktfunk_core::quic::RichInput) {
use punktfunk_core::quic::RichInput;
let idx = match rich {
RichInput::Touchpad { pad, .. }
| RichInput::Motion { pad, .. }
| RichInput::TouchpadEx { pad, .. }
| RichInput::HidReport { pad, .. } => pad as usize,
};
// Route to the manager that actually owns the device (falling back to the declared kind
// before the first frame builds it), so a pad's touchpad/motion never lands on the wrong
// backend after a kind change.
let kind = self
.owner
.get(idx)
.copied()
.flatten()
.or_else(|| self.kinds.get(idx).copied())
.unwrap_or(GamepadPref::Xbox360);
match kind {
#[cfg(target_os = "linux")]
GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4 {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamDeck => {
if let Some(m) = &mut self.steamdeck {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SwitchPro => {
if let Some(m) = &mut self.switchpro {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamController => {
if let Some(m) = &mut self.steamctrl {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamController2 => {
if let Some(m) = &mut self.steamctrl2 {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamController2Puck => {
if let Some(m) = &mut self.steamctrl2_puck {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualSenseEdge => {
if let Some(m) = &mut self.dualsense_edge_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualShock4 => {
if let Some(m) = &mut self.dualshock4_win {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::SteamDeck => {
if let Some(m) = &mut self.steamdeck_win {
m.apply_rich(rich)
}
}
_ => {}
}
}
/// Triton's USB output endpoint is polled at 1 kHz. Service its raw haptic writes on the same
/// cadence so PC-generated trackpad pulses do not sit for up to 4 ms and then arrive at the
/// client in bursts. Other backends keep the lower-frequency poll to avoid idle churn.
fn feedback_poll_interval(&self) -> std::time::Duration {
#[cfg(target_os = "linux")]
if self.steamctrl2.is_some() || self.steamctrl2_puck.is_some() {
return std::time::Duration::from_millis(1);
}
std::time::Duration::from_millis(4)
}
/// Service feedback for every instantiated backend each cycle. `rumble` carries motor
/// force-feedback on the universal plane (every backend, tagged with its own pad index);
/// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
/// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend.
fn pump(
&mut self,
mut rumble: impl FnMut(u16, u16, u16),
mut hidout: impl FnMut(punktfunk_core::quic::HidOutput),
) {
if let Some(m) = &mut self.xbox360 {
m.pump_rumble(&mut rumble); // the X-Box pad has no rich-feedback plane
}
#[cfg(target_os = "linux")]
{
if let Some(m) = &mut self.xboxone {
m.pump_rumble(&mut rumble);
}
if let Some(m) = &mut self.dualsense {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualsense_edge {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4 {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamdeck {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.switchpro {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamctrl {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamctrl2 {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamctrl2_puck {
m.pump(&mut rumble, &mut hidout);
}
}
#[cfg(target_os = "windows")]
{
if let Some(m) = &mut self.dualsense_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualsense_edge_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.dualshock4_win {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamdeck_win {
m.pump(&mut rumble, &mut hidout);
}
}
}
/// Keep every instantiated virtual UHID/UMDF pad alive during input silence (re-emit its HID
/// report so the kernel driver / SDL don't drop a held-steady pad). The X-Box pads need no
/// heartbeat (evdev holds last-known state). Per-pad gap timers inside each manager govern the
/// actual emit cadence, not this per-tick call.
fn heartbeat(&mut self) {
#[cfg(target_os = "linux")]
{
let gap = std::time::Duration::from_millis(8);
if let Some(m) = &mut self.dualsense {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualsense_edge {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4 {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamdeck {
m.heartbeat(gap);
}
if let Some(m) = &mut self.switchpro {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamctrl {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamctrl2 {
m.heartbeat(gap);
}
}
#[cfg(target_os = "windows")]
{
let gap = std::time::Duration::from_millis(8);
if let Some(m) = &mut self.dualsense_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualsense_edge_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.dualshock4_win {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamdeck_win {
m.heartbeat(gap);
}
}
}
}
/// One client→host input item, both planes on ONE channel so the input thread wakes the
/// moment either arrives (a second rich channel drained after the 4 ms recv timeout cost
/// every pure-gyro motion sample up to 4 ms of quantization).
pub(super) enum ClientInput {
/// The 0xC8 plane: pointer / keyboard / gamepad button+axis.
Event(InputEvent),
/// The 0xCC plane: touchpad contacts + motion samples.
Rich(punktfunk_core::quic::RichInput),
}
/// Default TTL stamped on a non-zero rumble envelope (0xCA v2): how long the client renders the
/// level before silencing unless the host renews it. Tolerates 23 lost renewals (same loss
/// margin the old flat 500 ms refresh gave) while capping a host-abandoned rumble at this on every
/// client — versus the per-platform client heuristics it replaces (SDL 1.5 s, Apple 1.6 s, Android
/// up to the QUIC idle-timeout). Overridable via `PUNKTFUNK_RUMBLE_TTL_MS` (floored at
/// [`RUMBLE_TTL_FLOOR_MS`] so expiry jitter stays below the clients' tick granularity).
const RUMBLE_TTL_MS: u16 = 400;
/// Floor for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch — below this the ~50 ms client ticks make expiry
/// audible (see `rumble-envelope-plan.md` §5).
const RUMBLE_TTL_FLOOR_MS: u16 = 150;
/// Ceiling for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch. A lease longer than a few seconds defeats the
/// design's "an abandoned rumble stops promptly" goal, and keeping it well under `u16::MAX` means
/// the wire never emits a TTL a narrower client-side slot could mistake for a sentinel.
const RUMBLE_TTL_CEIL_MS: u16 = 5_000;
/// Floor for the derived renewal interval (renew = ttl × 3/10) so an aggressive TTL hatch can't
/// spin the renewal loop faster than this.
const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
/// How many times a transition-to-zero (a stop) is re-sent on the renewal ticks after the
/// immediate stop datagram, before the pad goes quiet. Covers stop-datagram loss for legacy
/// clients (a v2 client also self-silences at TTL); even a fully lost burst heals via the client's
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
const RUMBLE_STOP_BURST: u8 = 2;
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
fn send_rumble(
conn: &quinn::Connection,
envelope_on: bool,
pad: u16,
low: u16,
high: u16,
seq: u8,
ttl_ms: u16,
) {
let d: Vec<u8> = if envelope_on {
punktfunk_core::quic::encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms).to_vec()
} else {
punktfunk_core::quic::encode_rumble_datagram(pad, low, high).to_vec()
};
let _ = conn.send_datagram(d.into());
}
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
/// service (`inj_tx`) and gamepad events to this session's [`Pads`] router (`gamepad` — the
/// resolved Hello preference is the per-pad default; clients declare each pad's kind so a session
/// can mix uinput X-Box pads and virtual DualSense pads), with rich
/// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
/// feedback pumped between events — rumble on the universal datagram plane, DualSense
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
/// across sessions.
///
/// Rumble is emitted as self-terminating 0xCA v2 envelopes (`[level][seq][ttl_ms]`): the host owns
/// the timeline, renewing an active level every ~`RUMBLE_TTL_MS × 3/10` ms and letting an
/// abandoned one expire client-side, so "stuck rumble" is inexpressible on the wire (see
/// `punktfunk-planning/design/rumble-envelope-plan.md`). `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to
/// legacy v1 level datagrams + the flat 500 ms refresh (bisect hatch).
pub(super) fn input_thread(
rx: std::sync::mpsc::Receiver<ClientInput>,
conn: quinn::Connection,
inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref,
) {
let mut pads = Pads::new(gamepad);
// Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window,
// the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
// is 5000 u32s.
let mut motion_gaps_us: Vec<u32> = Vec::new();
let mut last_motion: Option<std::time::Instant> = None;
let mut motion_window = std::time::Instant::now();
let mut pad_state = [PadState::default(); MAX_WIRE_PADS];
let mut pad_mask = 0u16;
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
// Rumble self-terminating envelopes (0xCA v2). Each non-zero level is authorized for
// `rumble_ttl_ms`; the host renews an active pad every `rumble_renew` and lets an abandoned
// one expire on the client, so a dropped transition heals on the next renewal and a stop that
// is lost heals via the stop burst (or the client's own TTL expiry). `rumble_seq` is the
// per-pad wrapping reorder counter (bumped on changes AND renewals) the client gates on;
// `rumble_stop_burst` counts the post-stop zero re-sends still owed. `PUNKTFUNK_RUMBLE_ENVELOPE=0`
// reverts to legacy v1 datagrams re-sent flat every 500 ms.
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
let mut rumble_seen = [false; MAX_WIRE_PADS];
let mut rumble_seq = [0u8; MAX_WIRE_PADS];
let mut rumble_stop_burst = [0u8; MAX_WIRE_PADS];
let mut last_refresh = std::time::Instant::now();
let rumble_envelope_on = std::env::var("PUNKTFUNK_RUMBLE_ENVELOPE").as_deref() != Ok("0");
let rumble_ttl_ms: u16 = std::env::var("PUNKTFUNK_RUMBLE_TTL_MS")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.map(|v| v.clamp(RUMBLE_TTL_FLOOR_MS, RUMBLE_TTL_CEIL_MS))
.unwrap_or(RUMBLE_TTL_MS);
// Renew at 30 % of the TTL (≈120 ms for the 400 ms default) so 23 renewals cover the lease;
// in legacy mode the periodic block instead runs the old flat 500 ms full-state refresh.
let rumble_refresh_interval = if rumble_envelope_on {
std::time::Duration::from_millis((rumble_ttl_ms as u64 * 3 / 10).max(RUMBLE_RENEW_FLOOR_MS))
} else {
std::time::Duration::from_millis(500)
};
// Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a
// press left dangling by an abrupt client disconnect stays latched in the compositor across the
// reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck
// left-button-down then turns every later click into a drag: windows move, but clicking buttons
// and text inputs does nothing). We synthesize the matching up-events when this session ends —
// see the release loop after the `break`.
// Sets (not Vecs) so the presence test is O(1), not O(n) per event, and bounded by `MAX_HELD`
// so a client flooding distinct never-released codes can't grow the tracking state or spike the
// input thread (security-review 2026-06-28 S3). A real keyboard+mouse holds far fewer at once;
// codes past the cap simply aren't tracked for end-of-session release (worst case: one unreleased
// key on a pathological disconnect, which the injector's own state still bounds).
const MAX_HELD: usize = 256;
let mut held_buttons: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut held_keys: std::collections::HashSet<u32> = std::collections::HashSet::new();
loop {
match rx.recv_timeout(pads.feedback_poll_interval()) {
// Rich input (touchpad / motion) is applied the moment it arrives; the single channel
// wakes for gyro samples instead of making them wait out the feedback poll interval.
Ok(ClientInput::Rich(rich)) => {
if matches!(rich, punktfunk_core::quic::RichInput::Motion { .. }) {
let now = std::time::Instant::now();
if let Some(prev) = last_motion.replace(now) {
let gap = now.duration_since(prev);
if gap < std::time::Duration::from_secs(1) {
motion_gaps_us.push(gap.as_micros() as u32);
}
}
if motion_window.elapsed() >= std::time::Duration::from_secs(5)
&& !motion_gaps_us.is_empty()
{
motion_gaps_us.sort_unstable();
let p = |q: f64| {
motion_gaps_us[(q * (motion_gaps_us.len() - 1) as f64) as usize]
};
tracing::debug!(
samples = motion_gaps_us.len() + 1,
gap_p50_us = p(0.5),
gap_p95_us = p(0.95),
gap_max_us = motion_gaps_us.last().copied().unwrap_or(0),
"motion cadence (client gyro inter-arrival, 5 s window)"
);
motion_gaps_us.clear();
motion_window = std::time::Instant::now();
}
}
pads.apply_rich(rich);
}
Ok(ClientInput::Event(ev)) => match ev.kind {
InputKind::GamepadButton | InputKind::GamepadAxis => {
// A bad index / unknown axis just doesn't update a pad — fall through (no
// `continue`) so the rich-input drain + feedback pump below still run every
// iteration (the DualSense GET_REPORT handshake must be serviced promptly).
let idx = ev.flags as usize;
if idx < MAX_WIRE_PADS && pad_state[idx].apply(&ev) {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
}
}
InputKind::GamepadState => {
// Idempotent full-state snapshot from a capable client (see
// `GamepadSnapshot`): applied only when its seq supersedes the last one, so
// a datagram the network reordered can't roll held state backwards. The
// client refreshes touched pads every ~100 ms, so an unchanged refresh is
// the common case — skip the frame emit then (an XInput packet-number bump
// for identical state is pure churn), but always advance the gate.
use punktfunk_core::input::GamepadSnapshot;
if let Some(snap) = GamepadSnapshot::from_event(&ev) {
let idx = snap.pad as usize;
if idx < MAX_WIRE_PADS && GamepadSnapshot::seq_newer(snap.seq, pad_seq[idx])
{
pad_seq[idx] = Some(snap.seq);
let before = pad_state[idx];
pad_state[idx].set_snapshot(&snap);
let first = pad_mask & (1 << idx) == 0;
if first || pad_state[idx] != before {
pad_mask |= 1 << idx;
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(
frame,
));
}
}
}
}
InputKind::GamepadRemove => {
// Mid-session hot-unplug from a snapshot-capable client (the native plane's
// `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence
// space as snapshots, so a snapshot the network reordered past this removal
// is dropped (older seq) and can't resurrect the pad — while a later re-plug
// on the same index arrives with a still-newer seq and is accepted. Clearing
// the `active_mask` bit and re-emitting the frame fires every backend's
// unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device.
let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags);
let idx = pad as usize;
if idx < MAX_WIRE_PADS
&& punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx])
{
pad_seq[idx] = Some(seq);
if pad_mask & (1 << idx) != 0 {
pad_mask &= !(1 << idx);
pad_state[idx] = PadState::default();
let frame = pad_state[idx].frame(idx, pad_mask);
pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame));
tracing::info!(pad = idx, "gamepad unplugged (native detach)");
}
// Fresh feedback bookkeeping so a later re-plug on this index inherits no
// stale rumble lease/seq (a lease still ticking would buzz the new pad).
rumble_state[idx] = (0, 0);
rumble_seen[idx] = false;
rumble_seq[idx] = 0;
rumble_stop_burst[idx] = 0;
}
}
InputKind::GamepadArrival => {
// Per-pad controller kind declaration (mixed types): route this pad's future
// frames to a backend of the declared kind. `code` = the GamepadPref wire byte,
// `flags` = pad index. Applied before the pad's first frame (the client sends it
// on slot open), so the device is built as the right type from the start.
let idx = ev.flags as usize;
let kind = GamepadPref::from_u8(ev.code as u8);
pads.set_kind(idx, kind);
}
_ => {
// Track press/release so a mid-press disconnect can be undone below.
match ev.kind {
InputKind::MouseButtonDown if held_buttons.len() < MAX_HELD => {
held_buttons.insert(ev.code);
}
InputKind::MouseButtonUp => {
held_buttons.remove(&ev.code);
}
InputKind::KeyDown if held_keys.len() < MAX_HELD => {
held_keys.insert(ev.code);
}
InputKind::KeyUp => {
held_keys.remove(&ev.code);
}
_ => {}
}
// Pointer/keyboard → the host-lifetime injector service (one persistent
// portal session for every punktfunk/1 session). A send error only means the
// service thread is gone (host shutting down) — dropping the event is fine,
// input is lossy by design.
let _ = inj_tx.send(ev);
}
},
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
// Service feedback every iteration (≤1 ms for Triton, ≤4 ms otherwise; games block on
// EVIOCSFF, and HID handshakes must be answered promptly). Rumble → the universal 0xCA
// plane; rich/raw HID feedback → 0xCD.
pads.pump(
|pad, low, high| {
let idx = pad as usize;
if idx < MAX_WIRE_PADS {
let prev = rumble_state[idx];
// Log the silent→active transition (once per buzz) so a live test can tell
// "host never gets rumble from the game" apart from "client doesn't render it".
if prev == (0, 0) && (low != 0 || high != 0) {
tracing::debug!(pad, low, high, "rumble: forwarding to client (0xCA)");
}
rumble_state[idx] = (low, high);
rumble_seen[idx] = true;
// Bump the reorder counter on every change, then arm the stop burst on a
// transition to zero (so a lost stop still reaches a legacy client) and clear
// it when the game re-asserts a non-zero level.
rumble_seq[idx] = rumble_seq[idx].wrapping_add(1);
if (low, high) == (0, 0) {
rumble_stop_burst[idx] = if prev != (0, 0) { RUMBLE_STOP_BURST } else { 0 };
} else {
rumble_stop_burst[idx] = 0;
}
let ttl = if (low, high) == (0, 0) {
0
} else {
rumble_ttl_ms
};
send_rumble(
&conn,
rumble_envelope_on,
pad,
low,
high,
rumble_seq[idx],
ttl,
);
} else {
// Out-of-range pad (a backend never produces these) — forward without gating.
send_rumble(&conn, rumble_envelope_on, pad, low, high, 0, rumble_ttl_ms);
}
},
|h| {
let _ = conn.send_datagram(h.encode().into());
},
);
// Keep the virtual DualSense from going silent during steady input (no-op for X-Box): a
// held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop
// it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick.
pads.heartbeat();
if last_refresh.elapsed() >= rumble_refresh_interval {
last_refresh = std::time::Instant::now();
if rumble_envelope_on {
// Renewal: refresh an active pad's lease (bump seq, fresh TTL), and drain each
// pad's post-stop zero burst, then let it go quiet — no perpetual zero refreshes.
for i in 0..MAX_WIRE_PADS {
if !rumble_seen[i] {
continue;
}
let (low, high) = rumble_state[i];
if (low, high) != (0, 0) {
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
send_rumble(
&conn,
true,
i as u16,
low,
high,
rumble_seq[i],
rumble_ttl_ms,
);
} else if rumble_stop_burst[i] > 0 {
rumble_stop_burst[i] -= 1;
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
send_rumble(&conn, true, i as u16, 0, 0, rumble_seq[i], 0);
}
}
} else {
// Legacy: re-send the current level of every seen pad every 500 ms (v1).
for (i, &(low, high)) in rumble_state.iter().enumerate() {
if rumble_seen[i] {
let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high);
let _ = conn.send_datagram(d.to_vec().into());
}
}
}
}
}
// Session ended (client gone). Release anything still held through the host-lifetime injector —
// its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this
// session, so without this a button pressed at disconnect stays latched and breaks clicks for
// the next session. Mirror of the injector's own release_all, but keyed off the session, which
// is where a client actually vanishes mid-press.
if !held_buttons.is_empty() || !held_keys.is_empty() {
tracing::debug!(
buttons = held_buttons.len(),
keys = held_keys.len(),
"input: releasing held buttons/keys at session end"
);
}
for code in held_buttons {
let _ = inj_tx.send(InputEvent {
kind: InputKind::MouseButtonUp,
_pad: [0; 3],
code,
x: 0,
y: 0,
flags: 0,
});
}
for code in held_keys {
let _ = inj_tx.send(InputEvent {
kind: InputKind::KeyUp,
_pad: [0; 3],
code,
x: 0,
y: 0,
flags: 0,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::input::{InputEvent, InputKind};
#[test]
fn pad_snapshot_replaces_state_and_seq_gates() {
use punktfunk_core::input::{gamepad, GamepadSnapshot};
let mut state = PadState::default();
let mut last_seq: Option<u8> = None;
// Legacy accumulation first (an older client), then a snapshot replaces it wholesale.
let axis = InputEvent {
kind: InputKind::GamepadAxis,
_pad: [0; 3],
code: gamepad::AXIS_LT,
x: 200,
y: 0,
flags: 0,
};
assert!(state.apply(&axis));
assert_eq!(state.left_trigger, 200);
let snap = GamepadSnapshot {
pad: 0,
seq: 1,
buttons: gamepad::BTN_A,
left_trigger: 255,
right_trigger: 0,
ls_x: 100,
ls_y: -100,
rs_x: 0,
rs_y: 0,
};
assert!(GamepadSnapshot::seq_newer(snap.seq, last_seq));
last_seq = Some(snap.seq);
state.set_snapshot(&snap);
assert_eq!(state.left_trigger, 255);
assert_eq!(state.buttons, gamepad::BTN_A);
assert_eq!((state.ls_x, state.ls_y), (100, -100));
// A reordered (stale) snapshot must not roll the trigger back.
let stale = GamepadSnapshot {
seq: 0,
left_trigger: 10,
..snap
};
assert!(!GamepadSnapshot::seq_newer(stale.seq, last_seq));
// The unchanged-refresh case the input thread skips the frame emit for: identical
// payload with a newer seq compares equal after apply.
let refresh = GamepadSnapshot { seq: 2, ..snap };
assert!(GamepadSnapshot::seq_newer(refresh.seq, last_seq));
let before = state;
state.set_snapshot(&refresh);
assert_eq!(state, before);
// The snapshot survives the wire roundtrip into the same PadState shape.
let dec =
GamepadSnapshot::from_event(&InputEvent::decode(&snap.to_event().encode()).unwrap())
.unwrap();
assert_eq!(dec, snap);
}
fn gp(kind: InputKind, code: u32, x: i32, pad: u32) -> InputEvent {
InputEvent {
kind,
_pad: [0; 3],
code,
x,
y: 0,
flags: pad,
}
}
/// Incremental wire events accumulate into the full pad frame the virtual xpad applies.
#[test]
fn gamepad_accumulator() {
use punktfunk_core::input::gamepad::*;
let mut s = PadState::default();
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_A, 1, 0)));
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_LB, 1, 0)));
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LS_X, -32768, 0)));
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_RT, 255, 0)));
let f = s.frame(2, 0b0100);
assert_eq!(f.buttons, BTN_A | BTN_LB);
assert_eq!((f.ls_x, f.right_trigger), (-32768, 255));
assert_eq!((f.index, f.active_mask), (2, 0b0100));
// Release folds out; axis values clamp; unknown axis ids are rejected.
assert!(s.apply(&gp(InputKind::GamepadButton, BTN_A, 0, 0)));
assert_eq!(s.frame(0, 1).buttons, BTN_LB);
assert!(s.apply(&gp(InputKind::GamepadAxis, AXIS_LT, 9_999, 0)));
assert_eq!(s.left_trigger, 255);
assert!(!s.apply(&gp(InputKind::GamepadAxis, 42, 1, 0)));
}
}
@@ -0,0 +1,89 @@
//! The host side of the native SPAKE2 pairing ceremony (plan §W1 — carved out of the [`super`]
//! module). `serve_session` dispatches a connection whose first message is a `PairRequest` here,
//! after it has resolved the live arming PIN (honoring fingerprint binding, #9); this runs the
//! ceremony, enforces the single online guess, and persists the client's fingerprint on success.
use super::*;
// The ceremony-only wire messages: imported directly (native.rs no longer references them, so they
// were dropped from its `use` and won't come through `use super::*`). `PairRequest` still arrives
// via the glob (serve_session decodes it).
use punktfunk_core::quic::{PairChallenge, PairProof, PairResult};
/// Pairing needs a human in the loop (reading the PIN off the host, typing it into the
/// client), so its budget is far larger than the machine-speed session handshake.
const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
/// client's key-confirmation MAC (its single online guess), and persist the client's
/// fingerprint on success.
pub(super) async fn pair_ceremony(
conn: &quinn::Connection,
mut send: quinn::SendStream,
mut recv: quinn::RecvStream,
req: PairRequest,
host_fp: &[u8; 32],
np: &NativePairing,
pin: &str,
) -> Result<()> {
use punktfunk_core::quic::pake;
let client_fp = endpoint::peer_fingerprint(conn)
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
tracing::info!(
name = %req.name,
client = %fingerprint_hex(&client_fp),
"PAIRING REQUEST — verifying against the armed PIN"
);
// SPAKE2 as B; bind our own host_fp + the client cert we actually received.
let (pake, spake_b) = pake::start(false, pin, &client_fp, host_fp);
let confirms = pake.finish(&req.spake_a)?; // Err only on a malformed peer message
io::write_msg(
&mut send,
&PairChallenge {
spake_b,
confirm: confirms.host,
}
.encode(),
)
.await?;
// SINGLE-USE PIN: we've now sent the host key-confirmation, which lets the client TEST this one
// guess (a right PIN → its proof will match; a wrong PIN → the client detects the mismatch and
// aborts *without* sending its proof). So consume the PIN HERE — before reading the proof —
// regardless of the outcome: an attacker gets EXACTLY ONE online guess (the documented guarantee),
// not an unbounded brute-force of the 4-digit space against a static, never-rotating PIN. A
// malformed request that errored at `pake.finish` above never reached here, so it doesn't burn the
// window (no DoS from garbage). The operator re-arms (web console / restart) for the next device —
// including after a successful pair; the protocol gives no reliable host-observable "wrong PIN"
// signal to scope this to failures only (the client just disconnects).
np.disarm();
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
.await
.map_err(|_| anyhow!("pairing timed out waiting for the client's confirmation"))??;
let proof = PairProof::decode(&proof).map_err(|e| anyhow!("PairProof decode: {e:?}"))?;
// A wrong PIN (or a MITM with mismatched cert views) yields a different SPAKE2 key, so
// the client's confirmation MAC won't match ours — one online attempt, no offline search.
let ok = pake::verify(&confirms.client, &proof.confirm);
if ok {
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
}
tracing::info!(name = %req.name, "pairing complete — client trusted");
} else {
tracing::warn!(name = %req.name, "pairing rejected (wrong PIN) — fingerprint not stored");
}
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
let _ = send.finish();
// Wait for the client to acknowledge by closing, so the PairResult isn't dropped by our
// close on a slow link (bounded so a vanished client can't wedge the sequential host).
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn.closed()).await;
conn.close(0u32.into(), b"pairing done");
anyhow::ensure!(ok, "pairing rejected (wrong PIN)");
Ok(())
}
@@ -0,0 +1,73 @@
//! Per-thread OS scheduling QoS for the native data plane (plan §W1 — carved out of the [`super`]
//! module). The capture/encode and send threads raise their own priority so a CPU-saturating game
//! can't deschedule them; the GameStream path and the direct-NVENC send thread reach this the same
//! way (`crate::native::boost_thread_priority`).
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
/// Raise the current thread's OS scheduling priority so a CPU-heavy game can't deschedule our
/// capture/encode/send threads. This matters even though our GPU work is already HIGH priority: the
/// GPU scheduler can only favour commands we've actually SUBMITTED, so if a normal-priority thread is
/// descheduled by the game it submits the convert/encode late and the GPU priority never bites. Apollo
/// does the same (capture thread CRITICAL, encoder ABOVE_NORMAL). The Linux host needs this too: an
/// uncapped GPU-saturating title (e.g. CS2 direct on a virtual output, not capped by gamescope) is
/// also a CPU hog and can deschedule our submit threads. `critical` → highest non-realtime class
/// (the capture+encode loop); otherwise above-normal (the send/relay thread).
pub(crate) fn boost_thread_priority(critical: bool) {
// Windows host-process/thread session tuning (timer 1ms, DWM MMCSS, HIGH class once; MMCSS +
// keep-display-awake per thread). No-op off Windows. Both stream threads call us, so this covers
// capture/encode (critical) and send (non-critical).
crate::session_tuning::on_hot_thread();
#[cfg(target_os = "windows")]
// SAFETY: `GetCurrentThread()` returns the constant pseudo-handle for the calling thread — always
// valid, thread-local in meaning, and never closed (no leak/double-close). `SetThreadPriority`
// takes that handle plus a `THREAD_PRIORITY_*` value the windows crate defines (HIGHEST or
// ABOVE_NORMAL here); it only reprioritizes this OS thread, borrows no Rust memory, and its
// `Result` is matched (a failure is logged, never UB). No pointers, lifetimes, or aliasing.
unsafe {
use windows::Win32::System::Threading::{
GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_ABOVE_NORMAL,
THREAD_PRIORITY_HIGHEST,
};
let prio = if critical {
THREAD_PRIORITY_HIGHEST
} else {
THREAD_PRIORITY_ABOVE_NORMAL
};
match SetThreadPriority(GetCurrentThread(), prio) {
Ok(()) => tracing::debug!(critical, "thread priority raised"),
Err(e) => {
tracing::debug!(critical, error = ?e, "SetThreadPriority failed")
}
}
}
#[cfg(target_os = "linux")]
{
// Best-effort nice of the CALLING thread. On Linux `setpriority(PRIO_PROCESS, 0, …)` acts on
// the calling thread (the kernel resolves who==0 to the current task/tid), and both call
// sites run inside their worker thread — so this nices exactly the capture/encode (critical)
// and send (non-critical) threads, nothing else. Silently no-ops without CAP_SYS_NICE / a
// raised RLIMIT_NICE, which is fine. We deliberately do NOT use SCHED_RR/FIFO by default: a
// realtime CPU class can preempt the compositor AND the game's own render thread, adding the
// very frame-time we refuse to add (opt-in only — see PUNKTFUNK_SCHED_RR).
let nice = if critical { -10 } else { -5 };
// SAFETY: `setpriority` takes three by-value integers and no pointers, so there is nothing to
// alias or outlive. `PRIO_PROCESS` with `who == 0` targets the calling task on Linux and
// `nice` is in range; the call only adjusts this thread's scheduling nice value and returns an
// `int` we inspect. No memory is touched.
let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, nice) };
if rc == 0 {
tracing::debug!(critical, nice, "thread nice raised");
} else {
tracing::debug!(
critical,
"setpriority(nice) no-op (needs CAP_SYS_NICE / RLIMIT_NICE)"
);
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
let _ = critical;
}
}
+119 -547
View File
@@ -1,158 +1,49 @@
//! Shared native (`punktfunk/1`) pairing state — the on-demand arming PIN (with expiry) plus the
//! persistent paired-clients store. One [`NativePairing`] handle is shared by the punktfunk/1 QUIC
//! accept loop ([`crate::punktfunk1`]) and the management API ([`crate::mgmt`]), so an operator can **arm
//! pairing and read the PIN from the web console** instead of the service log.
//! persistent paired-clients store and the delegated-approval queue. One [`NativePairing`] handle is
//! shared by the punktfunk/1 QUIC accept loop ([`crate::native`]) and the management API
//! ([`crate::mgmt`]), so an operator can **arm pairing and read the PIN from the web console**
//! instead of the service log.
//!
//! The PIN direction is inherent to the SPAKE2 ceremony: the *host* mints the PIN and the *client*
//! enters it (the client needs it to build its first message). So the UI **displays** the PIN —
//! armed on demand for a short window — rather than accepting one.
//!
//! This is a thin facade (plan §W5); the three concerns each own their state in a submodule:
//! - `arming` — the on-demand PIN window (`ArmState`),
//! - `store` — the persistent trust store (`TrustStore`),
//! - `approval` — the pending-knock queue + delegated approval (`ApprovalQueue`),
//! - `sanitize` — the untrusted-device-name scrubber.
//!
//! Admitting a device is the one cross-cutting flow: pinning the fingerprint lives in `store` and
//! clearing the pending knock lives in `approval`, so [`NativePairing::add`] drives both in order
//! (pin, THEN clear + notify) and [`NativePairing::wait_for_decision`] injects an `is_paired` closure
//! into the store-blind approval queue.
use anyhow::Result;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tokio::sync::Notify;
use std::time::Duration;
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
/// (Separate from GameStream pairing, which has its own store and ceremony.)
#[derive(Default, serde::Serialize, serde::Deserialize)]
pub struct PairedClients {
pub clients: Vec<PairedClient>,
}
mod approval;
mod arming;
mod sanitize;
mod store;
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct PairedClient {
pub name: String,
/// Hex SHA-256 of the client's certificate.
pub fingerprint: String,
}
pub use approval::{PairingDecision, PendingRequest};
pub use arming::PinAttempt;
pub use store::PairedClient;
impl PairedClients {
fn contains(&self, fp_hex: &str) -> bool {
self.clients
.iter()
.any(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
}
}
struct PairedState {
path: PathBuf,
clients: PairedClients,
}
/// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no
/// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms.
///
/// `bound_fp == Some(fp)` ⇒ the window is **bound to one operator-selected device fingerprint**:
/// only a pairing attempt from that fingerprint may consume it (security-review 2026-06-28 #9). This
/// closes the window-burn DoS — an unpaired LAN peer cannot consume a window armed for a specific
/// device, because the QUIC client-auth proves cert possession (it can't forge the bound fingerprint).
/// `None` ⇒ unbound (the CLI flag / a console "arm open"): any well-formed attempt consumes it (the
/// legacy behavior, retaining the window-burn DoS — acceptable only on a trusted LAN).
#[derive(Default)]
struct Armed {
pin: Option<String>,
expires_at: Option<Instant>,
bound_fp: Option<String>,
}
/// The result of resolving the armed PIN for a specific client fingerprint ([`NativePairing::pin_for_attempt`]).
pub enum PinAttempt {
/// No window is armed (disarmed/expired) — reject; do not run the ceremony.
Disarmed,
/// A window IS armed but **bound to a different fingerprint** — reject WITHOUT consuming it, so
/// an unrelated (attacker) fingerprint can't burn the operator's armed window (#9).
BoundToOther,
/// Proceed: the PIN to run the ceremony with (the window is unbound, or bound to this fingerprint).
Pin(String),
}
/// An unpaired (but identified) device that knocked on a pairing-required host — held for
/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
/// again), and they expire after [`PENDING_TTL`].
struct Pending {
id: u32,
name: String,
fp_hex: String,
requested_at: Instant,
/// QUIC-validated source address of the knock — used for the per-source cap (#13), so one host
/// can't fill the queue. `None` if unknown (e.g. tests / a caller that doesn't supply it).
src_ip: Option<IpAddr>,
/// True while a connection is held open in [`NativePairing::wait_for_decision`] for this knock.
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
parked: bool,
/// Generation of the MOST RECENT knock for this fingerprint. A re-knock bumps it (and wakes
/// waiters), so a stale parked connection resolves [`PairingDecision::Superseded`] instead of
/// being admitted alongside the newest one — one Approve must admit exactly ONE session.
/// (Observed live: a client retried 3× while parked, one console Approve admitted all three,
/// and the three concurrent Mutter virtual monitors segfaulted gnome-shell.)
knock_seq: u32,
}
#[derive(Default)]
struct PendingState {
next_id: u32,
items: Vec<Pending>,
/// Fingerprint → the knock generation an approval admitted, kept briefly after [`NativePairing::add`]
/// clears the pending entry. Closes the last double-admit window: a superseded waiter that only
/// polls AFTER the approval (entry gone, fingerprint paired) can't tell it lost from the entry
/// alone — this marker lets it resolve `Superseded` instead of a second `Approved`. Pruned on
/// the pending TTL and overwritten per fingerprint, so it stays a handful of tuples.
admitted: Vec<(String, u32, Instant)>,
}
/// A pending-approval snapshot for the management API / web console.
pub struct PendingRequest {
/// Per-process id used to address approve/deny (stable for the entry's lifetime).
pub id: u32,
/// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
pub name: String,
/// Hex SHA-256 of the knocking client's certificate — what approval pins.
pub fingerprint: String,
/// Seconds since the (most recent) knock.
pub age_secs: u64,
}
/// The outcome of [`NativePairing::wait_for_decision`] — what an operator did with a parked,
/// unpaired knock (delegated approval, roadmap §8b-1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PairingDecision {
/// The operator clicked Approve (the fingerprint is now paired) — admit the session.
Approved,
/// The operator denied, or the pending entry was otherwise dropped without pairing — reject.
Denied,
/// No decision within the wait window — reject; the device can knock again.
TimedOut,
/// A NEWER knock from the same fingerprint replaced this one — close this connection; the
/// newest parked connection is the one an approval admits (a retrying client abandons its
/// older attempts, and admitting them all crashes compositors — see [`Pending::knock_seq`]).
Superseded,
}
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
/// approvable days later when the operator no longer remembers the context).
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
const PENDING_CAP: usize = 32;
/// Max pending knocks one source IP may occupy, so a single host can't fill the whole queue and hide
/// / evict a genuine device's knock (security-review 2026-06-28 #13). The QUIC path is address-
/// validated, so the source IP isn't off-path spoofable; an attacker would need that many real hosts.
const MAX_PENDING_PER_IP: usize = 4;
/// The untrusted-device-name sanitizer lives in its own module (plan §W5); re-exported so
/// `crate::native_pairing::sanitize_device_name` stays stable (the `native` accept loop
/// reaches it there).
pub(crate) use sanitize::sanitize_device_name;
/// Shared native-pairing state: the arming PIN window + the persistent trust store + the
/// pending-approval queue.
pub struct NativePairing {
arm: Mutex<Armed>,
paired: Mutex<PairedState>,
pending: Mutex<PendingState>,
/// Notified whenever the trust/pending state changes (a fingerprint paired, or a pending knock
/// denied/dropped), so a QUIC connection parked in [`NativePairing::wait_for_decision`] wakes
/// the instant an operator acts in the console — the substrate for delegated approval admitting
/// a session with no client reconnect.
changed: Notify,
arm: arming::ArmState,
store: store::TrustStore,
approval: approval::ApprovalQueue,
}
/// A snapshot for the management API / web console.
@@ -165,43 +56,6 @@ pub struct NativePairingStatus {
pub paired_clients: u32,
}
fn default_path() -> Result<PathBuf> {
// `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the
// native paired-store works without a HOME env var (which a Windows service/task doesn't set).
Ok(crate::gamestream::config_dir().join("punktfunk1-paired.json"))
}
fn load(path: &std::path::Path) -> PairedClients {
std::fs::read(path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save(state: &PairedState) -> Result<()> {
if let Some(dir) = state.path.parent() {
crate::gamestream::create_private_dir(dir)?;
}
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would
// silently lock out every paired client on a --require-pairing host). Temp + rename. The temp is
// written owner-only so a local user can't inject a fingerprint to pair themselves.
let tmp = state.path.with_extension("json.tmp");
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?;
std::fs::rename(&tmp, &state.path)?;
Ok(())
}
fn random_pin() -> String {
use rand::Rng;
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
}
/// The untrusted-device-name sanitizer lives in its own module (plan §W5); re-exported so
/// `crate::native_pairing::sanitize_device_name` stays stable (the `punktfunk1` accept loop
/// reaches it there).
mod sanitize;
pub(crate) use sanitize::sanitize_device_name;
impl NativePairing {
/// Load the trust store. `store_path = None` uses the default config path. If `arm_at_start`
/// (the CLI `--allow-pairing`/`--require-pairing` flags), arm immediately with `fixed_pin`
@@ -211,368 +65,142 @@ impl NativePairing {
fixed_pin: Option<String>,
arm_at_start: bool,
) -> Result<NativePairing> {
let path = match store_path {
Some(p) => p,
None => default_path()?,
};
let clients = load(&path);
let arm = if arm_at_start {
Armed {
pin: Some(fixed_pin.unwrap_or_else(random_pin)),
expires_at: None,
bound_fp: None,
}
} else {
Armed::default()
};
Ok(NativePairing {
arm: Mutex::new(arm),
paired: Mutex::new(PairedState { path, clients }),
pending: Mutex::new(PendingState::default()),
changed: Notify::new(),
arm: arming::ArmState::new(arm_at_start, fixed_pin),
store: store::TrustStore::open(store_path)?,
approval: approval::ApprovalQueue::new(),
})
}
// -- Arming window ------------------------------------------------------
/// Arm pairing with a fresh random PIN, valid for `ttl`, **unbound** (any well-formed attempt
/// consumes it). Returns the PIN to display. Prefer [`Self::arm_for`] with a specific device
/// fingerprint on untrusted LANs — an unbound window is burnable by any peer (#9).
pub fn arm(&self, ttl: Duration) -> String {
self.arm_for(ttl, None)
self.arm.arm_for(ttl, None)
}
/// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is
/// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN.
pub fn arm_for(&self, ttl: Duration, bound_fp: Option<String>) -> String {
let pin = random_pin();
*self.arm.lock().unwrap() = Armed {
pin: Some(pin.clone()),
expires_at: Some(Instant::now() + ttl),
bound_fp,
};
pin
self.arm.arm_for(ttl, bound_fp)
}
/// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9):
/// `Disarmed` if no window is armed; `BoundToOther` if a window is armed but bound to a different
/// fingerprint (the caller MUST reject without consuming it); else `Pin` to run the ceremony.
pub fn pin_for_attempt(&self, client_fp_hex: &str) -> PinAttempt {
let mut arm = self.arm.lock().unwrap();
Self::expire(&mut arm);
match &arm.pin {
None => PinAttempt::Disarmed,
Some(pin) => match &arm.bound_fp {
Some(bound) if !bound.eq_ignore_ascii_case(client_fp_hex) => {
PinAttempt::BoundToOther
}
_ => PinAttempt::Pin(pin.clone()),
},
}
self.arm.pin_for_attempt(client_fp_hex)
}
/// Disarm pairing (no new ceremonies accepted).
pub fn disarm(&self) {
*self.arm.lock().unwrap() = Armed::default();
}
/// Expire a timed window if its deadline passed (called under the lock before any read).
fn expire(arm: &mut Armed) {
if let Some(t) = arm.expires_at {
if Instant::now() >= t {
*arm = Armed::default();
}
}
self.arm.disarm()
}
/// The current valid PIN, or `None` if disarmed/expired. The QUIC ceremony reads this
/// per-attempt, so a window that lapsed mid-connection no longer pairs.
pub fn current_pin(&self) -> Option<String> {
let mut arm = self.arm.lock().unwrap();
Self::expire(&mut arm);
arm.pin.clone()
self.arm.current_pin()
}
/// A snapshot for the management API.
pub fn status(&self) -> NativePairingStatus {
let mut arm = self.arm.lock().unwrap();
Self::expire(&mut arm);
let expires_in_secs = arm
.expires_at
.map(|t| t.saturating_duration_since(Instant::now()).as_secs());
let (armed, pin, expires_in_secs) = self.arm.snapshot();
NativePairingStatus {
armed: arm.pin.is_some(),
pin: arm.pin.clone(),
armed,
pin,
expires_in_secs,
paired_clients: self.paired.lock().unwrap().clients.clients.len() as u32,
paired_clients: self.store.count(),
}
}
// -- Trust store --------------------------------------------------------
/// Is this client (hex SHA-256 fingerprint) in the paired set?
pub fn is_paired(&self, fp_hex: &str) -> bool {
self.paired.lock().unwrap().clients.contains(fp_hex)
self.store.is_paired(fp_hex)
}
/// Record a successful pairing (re-pairing the same fingerprint just updates the name
/// matched case-insensitively, like every other fingerprint comparison here). The name is
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
/// diverges from disk. Also clears any pending knock for this fingerprint (it's now paired).
/// Record a successful pairing (re-pairing the same fingerprint just updates the name). The name
/// is sanitized (untrusted); a persist failure rolls the in-memory store back. Pins the
/// fingerprint in the store FIRST, then clears any pending knock for it and wakes parked waiters
/// — an order [`Self::wait_for_decision`] relies on (a woken waiter must observe the fully
/// settled state: paired = true, no longer pending).
pub fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
let name = sanitize_device_name(name, fp_hex);
{
let mut p = self.paired.lock().unwrap();
let snapshot = p.clients.clients.clone(); // restore on a failed save
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
p.clients.clients.push(PairedClient {
name,
self.store.add(name, fp_hex)?;
self.approval.admit_and_clear(fp_hex);
// The one choke point every successful pairing passes through (PIN ceremony AND
// delegated approval), so the lifecycle event fires exactly once per pairing.
crate::events::emit(crate::events::EventKind::PairingCompleted {
device: crate::events::DeviceRef {
name: sanitize_device_name(name, fp_hex),
fingerprint: fp_hex.to_string(),
});
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
}
// A device that knocked and is now paired shouldn't linger in the approval list. Record
// WHICH knock generation this pairing admits before clearing the entry: only the waiter
// holding that generation may return `Approved`; a superseded sibling that polls after the
// clear resolves `Superseded` off this marker (exactly-one-admission — see `admitted`).
{
let mut pending = self.pending.lock().unwrap();
let admitted_seq = pending
.items
.iter()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
.map(|p| p.knock_seq);
if let Some(seq) = admitted_seq {
pending
.admitted
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
pending
.admitted
.push((fp_hex.to_string(), seq, Instant::now()));
}
pending
.items
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
}
// Wake any connection parked in `wait_for_decision` for this fingerprint: pairing just
// completed (console approve or the PIN ceremony), so it can admit the session with no
// reconnect. Notified AFTER the pin AND the pending-clear so a woken waiter observes the
// fully settled state (paired = true, no longer pending) — see `wait_for_decision`.
self.changed.notify_waiters();
plane: crate::events::Plane::Native,
},
});
Ok(())
}
/// The paired clients (for the management API's device list).
pub fn list(&self) -> Vec<PairedClient> {
self.paired.lock().unwrap().clients.clients.clone()
self.store.list()
}
/// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
/// failure the in-memory store is rolled back (it never diverges from disk).
pub fn remove(&self, fp_hex: &str) -> Result<bool> {
let mut p = self.paired.lock().unwrap();
let before = p.clients.clients.len();
let snapshot = p.clients.clients.clone();
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
let removed = p.clients.clients.len() != before;
if removed {
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
}
Ok(removed)
self.store.remove(fp_hex)
}
// -- Delegated approval (roadmap §8b-1) --------------------------------
/// Drop expired pending knocks (called under the lock, mirroring [`Self::expire`]). The
/// admitted-generation markers share the TTL — they only matter while a superseded waiter
/// could still be parked, which is bounded by the approval wait (well under the TTL).
fn expire_pending(pending: &mut PendingState) {
pending
.items
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
pending
.admitted
.retain(|(_, _, at)| at.elapsed() < PENDING_TTL);
}
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
/// **non-parked** entry (a live parked knock is a genuine device awaiting the operator — never
/// evict it under load); only if every candidate is parked does it fall back to the oldest of
/// those (#13). Returns the index, or `None` if there's nothing to evict.
fn evict_index(items: &[Pending], only_ip: Option<IpAddr>) -> Option<usize> {
let pick = |allow_parked: bool| {
items
.iter()
.enumerate()
.filter(|(_, p)| only_ip.is_none_or(|ip| p.src_ip == Some(ip)))
.filter(|(_, p)| allow_parked || !p.parked)
.min_by_key(|(_, p)| p.requested_at)
.map(|(i, _)| i)
};
pick(false).or_else(|| pick(true))
}
// -- Delegated approval (roadmap §8b-1) ---------------------------------
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list) and
/// bump its knock generation — the returned generation is what [`Self::wait_for_decision`] admits,
/// so the NEWEST connection wins and any older parked sibling resolves `Superseded`. A
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
/// refresh the existing entry in place (same id) and bump its knock generation — the returned
/// generation is what [`Self::wait_for_decision`] admits. See [`approval::ApprovalQueue::note_pending`].
pub fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) -> u32 {
let name = sanitize_device_name(name, fp_hex);
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
if let Some(p) = pending
.items
.iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
{
p.requested_at = Instant::now();
p.name = name;
if p.src_ip.is_none() {
p.src_ip = src_ip;
}
p.knock_seq = p.knock_seq.wrapping_add(1);
let seq = p.knock_seq;
drop(pending);
// Wake the previous knock's parked waiter so it sees it was superseded NOW instead of
// holding its dead connection open until the approval window lapses.
self.changed.notify_waiters();
return seq;
// Only a NEW fingerprint emits `pairing.pending` — a re-knock refreshes the existing
// entry in place, and a client auto-retrying while parked must not spam the operator's
// notification hook once per retry.
let was_pending = self.approval.pending_contains(fp_hex);
let seq = self.approval.note_pending(name, fp_hex, src_ip);
if !was_pending {
crate::events::emit(crate::events::EventKind::PairingPending {
device: crate::events::DeviceRef {
name: sanitize_device_name(name, fp_hex),
fingerprint: fp_hex.to_string(),
plane: crate::events::Plane::Native,
},
});
}
// A fresh knock lifecycle: drop any admitted-generation marker left from a previous
// pair→unpair round of this fingerprint, or it would wrongly supersede the new waiter.
pending
.admitted
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
// own oldest entry first so it can't crowd out other devices' knocks (#13).
if let Some(ip) = src_ip {
if pending
.items
.iter()
.filter(|p| p.src_ip == Some(ip))
.count()
>= MAX_PENDING_PER_IP
{
if let Some(i) = Self::evict_index(&pending.items, Some(ip)) {
pending.items.remove(i);
}
}
}
// Global cap: evict the least-recently-active non-parked entry (Vec order no longer tracks
// recency after in-place refreshes, so pick explicitly).
if pending.items.len() >= PENDING_CAP {
if let Some(i) = Self::evict_index(&pending.items, None) {
pending.items.remove(i);
}
}
let id = pending.next_id;
pending.next_id = pending.next_id.wrapping_add(1);
pending.items.push(Pending {
id,
name,
fp_hex: fp_hex.to_string(),
requested_at: Instant::now(),
src_ip,
parked: false,
knock_seq: 0,
});
0
}
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
/// A parked entry is protected from eviction under load (#13). Gated on `knock_seq` so a
/// superseded waiter's exit can't unmark the flag the NEWER waiter (a bumped generation) owns.
fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
let mut pending = self.pending.lock().unwrap();
if let Some(p) = pending
.items
.iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex) && p.knock_seq == knock_seq)
{
p.parked = parked;
}
}
/// The current knock generation for `fp_hex`, `None` when no entry is pending. A parked waiter
/// compares this against its own generation to detect it was superseded by a re-knock.
fn knock_seq_of(&self, fp_hex: &str) -> Option<u32> {
let pending = self.pending.lock().unwrap();
pending
.items
.iter()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
.map(|p| p.knock_seq)
}
/// The knock generation the approval of `fp_hex` admitted, if one was recorded (see
/// [`PendingState::admitted`]).
fn admitted_seq(&self, fp_hex: &str) -> Option<u32> {
let pending = self.pending.lock().unwrap();
pending
.admitted
.iter()
.find(|(fp, _, _)| fp.eq_ignore_ascii_case(fp_hex))
.map(|(_, seq, _)| *seq)
seq
}
/// The devices currently awaiting approval (for the management API).
pub fn pending(&self) -> Vec<PendingRequest> {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
pending
.items
.iter()
.map(|p| PendingRequest {
id: p.id,
name: p.name.clone(),
fingerprint: p.fp_hex.clone(),
age_secs: p.requested_at.elapsed().as_secs(),
})
.collect()
self.approval.pending()
}
/// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped
/// first, so this also reports whether a parked knock is still live.)
/// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped first.)
pub fn pending_contains(&self, fp_hex: &str) -> bool {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
pending
.items
.iter()
.any(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
self.approval.pending_contains(fp_hex)
}
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator
/// labeled it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such
/// (or expired) id.
/// Approve a pending knock: pair its fingerprint (under `name_override` if the operator labeled
/// it, else the knock's own name) and drop it from the queue. `Ok(None)` = no such (or expired)
/// id. Reads (does NOT pre-remove) the entry, then [`Self::add`] pins the fingerprint and clears
/// the pending entry — an order a parked waiter relies on (see [`Self::wait_for_decision`]).
pub fn approve_pending(
&self,
id: u32,
name_override: Option<&str>,
) -> Result<Option<PairedClient>> {
// Read (do NOT pre-remove) the entry: `add()` pins the fingerprint and THEN clears its
// pending entry — an order `wait_for_decision` relies on so a parked waiter never observes
// the device as "neither pending nor paired" (which would read as a denial). Removing here
// first would open exactly that window.
let (knock_name, fp_hex) = {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
match pending.items.iter().find(|p| p.id == id) {
Some(p) => (p.name.clone(), p.fp_hex.clone()),
None => return Ok(None),
}
}; // pending lock released — add() takes the paired then pending locks
let (knock_name, fp_hex) = match self.approval.read_entry(id) {
Some(x) => x,
None => return Ok(None),
};
let name = name_override.unwrap_or(&knock_name).to_string();
self.add(&name, &fp_hex)?; // pins, clears the pending entry, and notifies waiters
Ok(Some(PairedClient {
@@ -584,106 +212,50 @@ impl NativePairing {
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
/// re-creates an entry — deny is "not now", not a blocklist.
pub fn deny_pending(&self, id: u32) -> bool {
let removed = {
let mut pending = self.pending.lock().unwrap();
let before = pending.items.len();
pending.items.retain(|p| p.id != id);
pending.items.len() != before
};
if removed {
// Wake a parked waiter so it returns `Denied` at once instead of holding the
// connection open until the approval window lapses.
self.changed.notify_waiters();
// Read the entry first so the lifecycle event can carry the device's identity.
let entry = self.approval.read_entry(id);
let denied = self.approval.deny_pending(id);
if denied {
if let Some((name, fp_hex)) = entry {
crate::events::emit(crate::events::EventKind::PairingDenied {
device: crate::events::DeviceRef {
name: sanitize_device_name(&name, &fp_hex),
fingerprint: fp_hex,
plane: crate::events::Plane::Native,
},
});
}
}
removed
denied
}
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
/// `knock_seq` is the generation [`Self::note_pending`] returned for THIS connection's knock.
/// Returns [`PairingDecision::Approved`] the instant the fingerprint is paired (console
/// approve or a concurrent PIN ceremony), [`PairingDecision::Superseded`] the instant a newer
/// knock from the same fingerprint replaces this one (a retrying client — only the newest
/// connection is admitted; three siblings admitted at once has crashed gnome-shell live),
/// [`PairingDecision::Denied`] if its pending entry is dropped without pairing, or
/// [`PairingDecision::TimedOut`] if the window lapses. Holds no lock across the await. The
/// QUIC accept path calls this right after [`Self::note_pending`] to keep the knocking
/// connection open until a human clicks Approve — so the device pairs and streams with no
/// reconnect (delegated approval, roadmap §8b-1).
/// The store-blind approval queue is handed an `is_paired` closure so it can resolve
/// [`PairingDecision::Approved`] the instant the fingerprint pairs. See
/// [`approval::ApprovalQueue::wait_for_decision`] for the full decision contract.
pub async fn wait_for_decision(
&self,
fp_hex: &str,
knock_seq: u32,
timeout: Duration,
) -> PairingDecision {
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
// connection out of the pending queue while the operator decides (#13). Cleared on every
// exit path by the guard's Drop (generation-gated, so a superseded waiter's exit never
// unmarks the newer waiter's flag).
self.set_parked(fp_hex, knock_seq, true);
struct ParkGuard<'a> {
np: &'a NativePairing,
fp: &'a str,
seq: u32,
}
impl Drop for ParkGuard<'_> {
fn drop(&mut self) {
self.np.set_parked(self.fp, self.seq, false);
}
}
let _park = ParkGuard {
np: self,
fp: fp_hex,
seq: knock_seq,
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
// Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that
// lands between the state check and the await still wakes us (no lost notification).
let notified = self.changed.notified();
tokio::pin!(notified);
notified.as_mut().enable();
self.approval
.wait_for_decision(fp_hex, knock_seq, timeout, |fp| self.store.is_paired(fp))
.await
}
// Superseded check FIRST: once a newer knock owns the fingerprint, this connection
// must never be admitted — not even if the approval lands before we wake.
match self.knock_seq_of(fp_hex) {
Some(cur) if cur != knock_seq => return PairingDecision::Superseded,
_ => {}
}
if self.is_paired(fp_hex) {
// Paired with the pending entry already cleared: make sure the approval admitted
// OUR generation. A superseded waiter that first polls after `add()` sees the same
// paired/no-entry state as the winner — the admitted marker breaks the tie.
match self.admitted_seq(fp_hex) {
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
_ => return PairingDecision::Approved,
}
}
if !self.pending_contains(fp_hex) {
// Neither pending nor paired. This is almost always a denial — but it can also be
// the tiny interval inside `add()` between pinning and clearing the pending entry.
// Re-check `is_paired` once: because `add()` pins BEFORE it clears pending, a
// cleared-pending observation that is really an approval will now read as paired —
// with the same generation tie-break as above (the admitted marker is written in
// the same critical section that clears the entry).
if self.is_paired(fp_hex) {
match self.admitted_seq(fp_hex) {
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
_ => return PairingDecision::Approved,
}
}
return PairingDecision::Denied;
}
tokio::select! {
_ = &mut notified => {}
_ = tokio::time::sleep_until(deadline) => return PairingDecision::TimedOut,
}
}
/// Test-only reach into the approval queue's park flag (the behavior tests assert a parked,
/// held-open knock survives a flood).
#[cfg(test)]
fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
self.approval.set_parked(fp_hex, knock_seq, parked)
}
}
#[cfg(test)]
mod tests {
use super::approval::{MAX_PENDING_PER_IP, PENDING_CAP};
use super::*;
fn temp() -> PathBuf {
@@ -0,0 +1,424 @@
//! The pending-approval queue + delegated approval (roadmap §8b-1; plan §W5 — carved out of the
//! [`super`] facade). Owns the pending-knock [`Mutex`] and the change [`Notify`] that wakes a QUIC
//! connection parked in [`ApprovalQueue::wait_for_decision`] the instant an operator acts.
//!
//! This module is deliberately blind to the trust store: whether a fingerprint became paired is
//! injected into [`ApprovalQueue::wait_for_decision`] as an `is_paired` closure, and the store side
//! of admitting a device (persisting the pairing) is the facade's job — this queue only records the
//! admitted knock generation and clears the entry ([`ApprovalQueue::admit_and_clear`]).
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tokio::sync::Notify;
/// An unpaired (but identified) device that knocked on a pairing-required host — held for
/// **delegated approval** from the management console (roadmap §8b-1) instead of being silently
/// forgotten. In-memory only: pending knocks don't survive a restart (the device just knocks
/// again), and they expire after [`PENDING_TTL`].
struct Pending {
id: u32,
name: String,
fp_hex: String,
requested_at: Instant,
/// QUIC-validated source address of the knock — used for the per-source cap (#13), so one host
/// can't fill the queue. `None` if unknown (e.g. tests / a caller that doesn't supply it).
src_ip: Option<IpAddr>,
/// True while a connection is held open in [`ApprovalQueue::wait_for_decision`] for this knock.
/// A live parked knock is a genuine device waiting for the operator — eviction skips it unless
/// every entry is parked, so a cert-rotating flood can't evict the device being onboarded (#13).
parked: bool,
/// Generation of the MOST RECENT knock for this fingerprint. A re-knock bumps it (and wakes
/// waiters), so a stale parked connection resolves [`PairingDecision::Superseded`] instead of
/// being admitted alongside the newest one — one Approve must admit exactly ONE session.
/// (Observed live: a client retried 3× while parked, one console Approve admitted all three,
/// and the three concurrent Mutter virtual monitors segfaulted gnome-shell.)
knock_seq: u32,
}
#[derive(Default)]
struct PendingState {
next_id: u32,
items: Vec<Pending>,
/// Fingerprint → the knock generation an approval admitted, kept briefly after
/// [`ApprovalQueue::admit_and_clear`] clears the pending entry. Closes the last double-admit
/// window: a superseded waiter that only polls AFTER the approval (entry gone, fingerprint
/// paired) can't tell it lost from the entry alone — this marker lets it resolve `Superseded`
/// instead of a second `Approved`. Pruned on the pending TTL and overwritten per fingerprint, so
/// it stays a handful of tuples.
admitted: Vec<(String, u32, Instant)>,
}
/// A pending-approval snapshot for the management API / web console.
pub struct PendingRequest {
/// Per-process id used to address approve/deny (stable for the entry's lifetime).
pub id: u32,
/// Best-effort device label (the client's `Hello` name, else fingerprint-derived).
pub name: String,
/// Hex SHA-256 of the knocking client's certificate — what approval pins.
pub fingerprint: String,
/// Seconds since the (most recent) knock.
pub age_secs: u64,
}
/// The outcome of a `wait_for_decision` park — what an operator did with a parked,
/// unpaired knock (delegated approval, roadmap §8b-1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PairingDecision {
/// The operator clicked Approve (the fingerprint is now paired) — admit the session.
Approved,
/// The operator denied, or the pending entry was otherwise dropped without pairing — reject.
Denied,
/// No decision within the wait window — reject; the device can knock again.
TimedOut,
/// A NEWER knock from the same fingerprint replaced this one — close this connection; the
/// newest parked connection is the one an approval admits (a retrying client abandons its
/// older attempts, and admitting them all crashes compositors — see [`Pending::knock_seq`]).
Superseded,
}
/// Pending knocks older than this are dropped (the device retries; a stale entry shouldn't be
/// approvable days later when the operator no longer remembers the context).
const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
/// Cap on the pending list — a LAN scanner must not grow it unboundedly. Oldest entries drop.
/// (`pub(super)` so the facade's behavior tests can assert the cap holds.)
pub(super) const PENDING_CAP: usize = 32;
/// Max pending knocks one source IP may occupy, so a single host can't fill the whole queue and hide
/// / evict a genuine device's knock (security-review 2026-06-28 #13). The QUIC path is address-
/// validated, so the source IP isn't off-path spoofable; an attacker would need that many real hosts.
/// (`pub(super)` so the facade's behavior tests can assert the per-IP cap holds.)
pub(super) const MAX_PENDING_PER_IP: usize = 4;
/// The pending-approval queue: the pending-knock list behind a [`Mutex`], plus the [`Notify`] that
/// wakes parked waiters when the trust/pending state changes.
pub(super) struct ApprovalQueue {
pending: Mutex<PendingState>,
/// Notified whenever the trust/pending state changes (a fingerprint paired, or a pending knock
/// denied/dropped), so a QUIC connection parked in [`ApprovalQueue::wait_for_decision`] wakes
/// the instant an operator acts in the console — the substrate for delegated approval admitting
/// a session with no client reconnect.
changed: Notify,
}
impl ApprovalQueue {
pub(super) fn new() -> ApprovalQueue {
ApprovalQueue {
pending: Mutex::new(PendingState::default()),
changed: Notify::new(),
}
}
/// Drop expired pending knocks (called under the lock, mirroring the arming expiry). The
/// admitted-generation markers share the TTL — they only matter while a superseded waiter
/// could still be parked, which is bounded by the approval wait (well under the TTL).
fn expire_pending(pending: &mut PendingState) {
pending
.items
.retain(|p| p.requested_at.elapsed() < PENDING_TTL);
pending
.admitted
.retain(|(_, _, at)| at.elapsed() < PENDING_TTL);
}
/// Pick the entry to evict, optionally restricted to a single source IP: the least-recently-active
/// **non-parked** entry (a live parked knock is a genuine device awaiting the operator — never
/// evict it under load); only if every candidate is parked does it fall back to the oldest of
/// those (#13). Returns the index, or `None` if there's nothing to evict.
fn evict_index(items: &[Pending], only_ip: Option<IpAddr>) -> Option<usize> {
let pick = |allow_parked: bool| {
items
.iter()
.enumerate()
.filter(|(_, p)| only_ip.is_none_or(|ip| p.src_ip == Some(ip)))
.filter(|(_, p)| allow_parked || !p.parked)
.min_by_key(|(_, p)| p.requested_at)
.map(|(i, _)| i)
};
pick(false).or_else(|| pick(true))
}
/// Record an unpaired device's knock for delegated approval. Re-knocks from the same fingerprint
/// refresh the existing entry in place (same id; a connect-retry loop must not spam the list) and
/// bump its knock generation — the returned generation is what [`Self::wait_for_decision`] admits,
/// so the NEWEST connection wins and any older parked sibling resolves `Superseded`. A
/// fresh fingerprint gets a new id; the queue is bounded two ways so a flood can't crowd out a
/// genuine knock (#13): a **per-source-IP cap** ([`MAX_PENDING_PER_IP`]) means one host can hold at
/// most a few slots, and the global [`PENDING_CAP`] evicts the least-recently-active **non-parked**
/// entry (never a live, held-open parked knock). The name is sanitized (untrusted).
pub(super) fn note_pending(&self, name: &str, fp_hex: &str, src_ip: Option<IpAddr>) -> u32 {
let name = super::sanitize_device_name(name, fp_hex);
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
if let Some(p) = pending
.items
.iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
{
p.requested_at = Instant::now();
p.name = name;
if p.src_ip.is_none() {
p.src_ip = src_ip;
}
p.knock_seq = p.knock_seq.wrapping_add(1);
let seq = p.knock_seq;
drop(pending);
// Wake the previous knock's parked waiter so it sees it was superseded NOW instead of
// holding its dead connection open until the approval window lapses.
self.changed.notify_waiters();
return seq;
}
// A fresh knock lifecycle: drop any admitted-generation marker left from a previous
// pair→unpair round of this fingerprint, or it would wrongly supersede the new waiter.
pending
.admitted
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
// Per-source-IP cap: a single host can't occupy more than MAX_PENDING_PER_IP slots — evict its
// own oldest entry first so it can't crowd out other devices' knocks (#13).
if let Some(ip) = src_ip {
if pending
.items
.iter()
.filter(|p| p.src_ip == Some(ip))
.count()
>= MAX_PENDING_PER_IP
{
if let Some(i) = Self::evict_index(&pending.items, Some(ip)) {
pending.items.remove(i);
}
}
}
// Global cap: evict the least-recently-active non-parked entry (Vec order no longer tracks
// recency after in-place refreshes, so pick explicitly).
if pending.items.len() >= PENDING_CAP {
if let Some(i) = Self::evict_index(&pending.items, None) {
pending.items.remove(i);
}
}
let id = pending.next_id;
pending.next_id = pending.next_id.wrapping_add(1);
pending.items.push(Pending {
id,
name,
fp_hex: fp_hex.to_string(),
requested_at: Instant::now(),
src_ip,
parked: false,
knock_seq: 0,
});
0
}
/// Mark/unmark the pending entry for `fp_hex` as having a live parked waiter (no-op if it's gone).
/// A parked entry is protected from eviction under load (#13). Gated on `knock_seq` so a
/// superseded waiter's exit can't unmark the flag the NEWER waiter (a bumped generation) owns.
pub(super) fn set_parked(&self, fp_hex: &str, knock_seq: u32, parked: bool) {
let mut pending = self.pending.lock().unwrap();
if let Some(p) = pending
.items
.iter_mut()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex) && p.knock_seq == knock_seq)
{
p.parked = parked;
}
}
/// The current knock generation for `fp_hex`, `None` when no entry is pending. A parked waiter
/// compares this against its own generation to detect it was superseded by a re-knock.
fn knock_seq_of(&self, fp_hex: &str) -> Option<u32> {
let pending = self.pending.lock().unwrap();
pending
.items
.iter()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
.map(|p| p.knock_seq)
}
/// The knock generation the approval of `fp_hex` admitted, if one was recorded (see
/// [`PendingState::admitted`]).
fn admitted_seq(&self, fp_hex: &str) -> Option<u32> {
let pending = self.pending.lock().unwrap();
pending
.admitted
.iter()
.find(|(fp, _, _)| fp.eq_ignore_ascii_case(fp_hex))
.map(|(_, seq, _)| *seq)
}
/// The devices currently awaiting approval (for the management API).
pub(super) fn pending(&self) -> Vec<PendingRequest> {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
pending
.items
.iter()
.map(|p| PendingRequest {
id: p.id,
name: p.name.clone(),
fingerprint: p.fp_hex.clone(),
age_secs: p.requested_at.elapsed().as_secs(),
})
.collect()
}
/// Is a knock for this fingerprint still awaiting approval? (Expired entries are dropped
/// first, so this also reports whether a parked knock is still live.)
pub(super) fn pending_contains(&self, fp_hex: &str) -> bool {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
pending
.items
.iter()
.any(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
}
/// Read (do NOT remove) the `(name, fingerprint)` of the pending entry with `id`, expiring
/// stale entries first. `None` = no such (or expired) id. The facade approves by reading here,
/// pairing the fingerprint in the trust store, and THEN [`Self::admit_and_clear`]-ing — an order
/// [`Self::wait_for_decision`] relies on so a parked waiter never observes the device as
/// "neither pending nor paired" (which would read as a denial). Removing here first would open
/// exactly that window.
pub(super) fn read_entry(&self, id: u32) -> Option<(String, String)> {
let mut pending = self.pending.lock().unwrap();
Self::expire_pending(&mut pending);
pending
.items
.iter()
.find(|p| p.id == id)
.map(|p| (p.name.clone(), p.fp_hex.clone()))
}
/// The pending side of admitting a now-paired device: record WHICH knock generation this pairing
/// admits (so only the waiter holding that generation returns `Approved`; a superseded sibling
/// that polls after the clear resolves `Superseded` off this marker — exactly-one-admission, see
/// [`PendingState::admitted`]), clear the entry, then wake parked waiters. The caller MUST have
/// pinned the fingerprint in the trust store FIRST, so a woken waiter observes the fully settled
/// state (paired = true, no longer pending) — see [`Self::wait_for_decision`].
pub(super) fn admit_and_clear(&self, fp_hex: &str) {
{
let mut pending = self.pending.lock().unwrap();
let admitted_seq = pending
.items
.iter()
.find(|p| p.fp_hex.eq_ignore_ascii_case(fp_hex))
.map(|p| p.knock_seq);
if let Some(seq) = admitted_seq {
pending
.admitted
.retain(|(fp, _, _)| !fp.eq_ignore_ascii_case(fp_hex));
pending
.admitted
.push((fp_hex.to_string(), seq, Instant::now()));
}
pending
.items
.retain(|p| !p.fp_hex.eq_ignore_ascii_case(fp_hex));
}
// Wake any connection parked in `wait_for_decision` for this fingerprint: pairing just
// completed (console approve or the PIN ceremony), so it can admit the session with no
// reconnect. Notified AFTER the pin AND the pending-clear so a woken waiter observes the
// fully settled state — see `wait_for_decision`.
self.changed.notify_waiters();
}
/// Deny (drop) a pending knock. Returns whether one was removed. The device's next knock
/// re-creates an entry — deny is "not now", not a blocklist.
pub(super) fn deny_pending(&self, id: u32) -> bool {
let removed = {
let mut pending = self.pending.lock().unwrap();
let before = pending.items.len();
pending.items.retain(|p| p.id != id);
pending.items.len() != before
};
if removed {
// Wake a parked waiter so it returns `Denied` at once instead of holding the
// connection open until the approval window lapses.
self.changed.notify_waiters();
}
removed
}
/// Park (async) until an operator decides on a knock identified by `fp_hex`, up to `timeout`.
/// `knock_seq` is the generation [`Self::note_pending`] returned for THIS connection's knock;
/// `is_paired` is injected by the facade (this queue is blind to the trust store). Returns
/// [`PairingDecision::Approved`] the instant the fingerprint is paired (console approve or a
/// concurrent PIN ceremony), [`PairingDecision::Superseded`] the instant a newer knock from the
/// same fingerprint replaces this one (a retrying client — only the newest connection is
/// admitted; three siblings admitted at once has crashed gnome-shell live),
/// [`PairingDecision::Denied`] if its pending entry is dropped without pairing, or
/// [`PairingDecision::TimedOut`] if the window lapses. Holds no lock across the await. The
/// QUIC accept path calls this right after [`Self::note_pending`] to keep the knocking
/// connection open until a human clicks Approve — so the device pairs and streams with no
/// reconnect (delegated approval, roadmap §8b-1).
pub(super) async fn wait_for_decision(
&self,
fp_hex: &str,
knock_seq: u32,
timeout: Duration,
is_paired: impl Fn(&str) -> bool,
) -> PairingDecision {
// Mark this knock parked so a cert-rotating flood can't evict the genuine, held-open
// connection out of the pending queue while the operator decides (#13). Cleared on every
// exit path by the guard's Drop (generation-gated, so a superseded waiter's exit never
// unmarks the newer waiter's flag).
self.set_parked(fp_hex, knock_seq, true);
struct ParkGuard<'a> {
q: &'a ApprovalQueue,
fp: &'a str,
seq: u32,
}
impl Drop for ParkGuard<'_> {
fn drop(&mut self) {
self.q.set_parked(self.fp, self.seq, false);
}
}
let _park = ParkGuard {
q: self,
fp: fp_hex,
seq: knock_seq,
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
// Arm the wakeup BEFORE re-reading state, and `enable()` it, so an approve/deny that
// lands between the state check and the await still wakes us (no lost notification).
let notified = self.changed.notified();
tokio::pin!(notified);
notified.as_mut().enable();
// Superseded check FIRST: once a newer knock owns the fingerprint, this connection
// must never be admitted — not even if the approval lands before we wake.
match self.knock_seq_of(fp_hex) {
Some(cur) if cur != knock_seq => return PairingDecision::Superseded,
_ => {}
}
if is_paired(fp_hex) {
// Paired with the pending entry already cleared: make sure the approval admitted
// OUR generation. A superseded waiter that first polls after the pairing sees the
// same paired/no-entry state as the winner — the admitted marker breaks the tie.
match self.admitted_seq(fp_hex) {
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
_ => return PairingDecision::Approved,
}
}
if !self.pending_contains(fp_hex) {
// Neither pending nor paired. This is almost always a denial — but it can also be
// the tiny interval inside the facade's `add()` between pinning and clearing the
// pending entry. Re-check `is_paired` once: because the facade pins BEFORE it clears
// pending, a cleared-pending observation that is really an approval will now read as
// paired — with the same generation tie-break as above (the admitted marker is
// written in the same critical section that clears the entry).
if is_paired(fp_hex) {
match self.admitted_seq(fp_hex) {
Some(adm) if adm != knock_seq => return PairingDecision::Superseded,
_ => return PairingDecision::Approved,
}
}
return PairingDecision::Denied;
}
tokio::select! {
_ = &mut notified => {}
_ = tokio::time::sleep_until(deadline) => return PairingDecision::TimedOut,
}
}
}
}
@@ -0,0 +1,129 @@
//! The on-demand arming PIN window (plan §W5 — carved out of the [`super`] facade). Owns the
//! [`Armed`] state behind a [`Mutex`]: a short-lived (or CLI-flag, no-expiry) PIN the host mints
//! and the operator reads from the web console, optionally bound to one device fingerprint (#9).
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// The current arming window. `pin == None` ⇒ disarmed. `expires_at == None` ⇒ armed with no
/// expiry (the CLI `--allow-pairing` flag); `Some(t)` ⇒ a web-armed window that auto-disarms.
///
/// `bound_fp == Some(fp)` ⇒ the window is **bound to one operator-selected device fingerprint**:
/// only a pairing attempt from that fingerprint may consume it (security-review 2026-06-28 #9). This
/// closes the window-burn DoS — an unpaired LAN peer cannot consume a window armed for a specific
/// device, because the QUIC client-auth proves cert possession (it can't forge the bound fingerprint).
/// `None` ⇒ unbound (the CLI flag / a console "arm open"): any well-formed attempt consumes it (the
/// legacy behavior, retaining the window-burn DoS — acceptable only on a trusted LAN).
#[derive(Default)]
struct Armed {
pin: Option<String>,
expires_at: Option<Instant>,
bound_fp: Option<String>,
}
/// The result of resolving the armed PIN for a specific client fingerprint
/// (`NativePairing::pin_for_attempt`).
pub enum PinAttempt {
/// No window is armed (disarmed/expired) — reject; do not run the ceremony.
Disarmed,
/// A window IS armed but **bound to a different fingerprint** — reject WITHOUT consuming it, so
/// an unrelated (attacker) fingerprint can't burn the operator's armed window (#9).
BoundToOther,
/// Proceed: the PIN to run the ceremony with (the window is unbound, or bound to this fingerprint).
Pin(String),
}
fn random_pin() -> String {
use rand::Rng;
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
}
/// A snapshot of the arming window for the management API: `(armed, pin, expires_in_secs)`.
pub(super) type ArmSnapshot = (bool, Option<String>, Option<u64>);
/// The arming-PIN window behind a [`Mutex`].
pub(super) struct ArmState {
arm: Mutex<Armed>,
}
impl ArmState {
/// A fresh window. If `arm_at_start` (the CLI `--allow-pairing`/`--require-pairing` flags), arm
/// immediately with `fixed_pin` (or a fresh random PIN) and **no expiry** — back-compat with the
/// headless CLI flow. Otherwise disarmed.
pub(super) fn new(arm_at_start: bool, fixed_pin: Option<String>) -> ArmState {
let arm = if arm_at_start {
Armed {
pin: Some(fixed_pin.unwrap_or_else(random_pin)),
expires_at: None,
bound_fp: None,
}
} else {
Armed::default()
};
ArmState {
arm: Mutex::new(arm),
}
}
/// Arm pairing with a fresh random PIN, valid for `ttl`. If `bound_fp` is `Some`, the window is
/// bound to that device fingerprint: only a pairing attempt from it consumes the window, so an
/// unrelated (attacker) fingerprint can neither pair nor burn the window (#9). Returns the PIN.
pub(super) fn arm_for(&self, ttl: Duration, bound_fp: Option<String>) -> String {
let pin = random_pin();
*self.arm.lock().unwrap() = Armed {
pin: Some(pin.clone()),
expires_at: Some(Instant::now() + ttl),
bound_fp,
};
pin
}
/// Resolve the PIN for an attempt from `client_fp_hex`, honoring fingerprint binding (#9):
/// `Disarmed` if no window is armed; `BoundToOther` if a window is armed but bound to a different
/// fingerprint (the caller MUST reject without consuming it); else `Pin` to run the ceremony.
pub(super) fn pin_for_attempt(&self, client_fp_hex: &str) -> PinAttempt {
let mut arm = self.arm.lock().unwrap();
Self::expire(&mut arm);
match &arm.pin {
None => PinAttempt::Disarmed,
Some(pin) => match &arm.bound_fp {
Some(bound) if !bound.eq_ignore_ascii_case(client_fp_hex) => {
PinAttempt::BoundToOther
}
_ => PinAttempt::Pin(pin.clone()),
},
}
}
/// Disarm pairing (no new ceremonies accepted).
pub(super) fn disarm(&self) {
*self.arm.lock().unwrap() = Armed::default();
}
/// Expire a timed window if its deadline passed (called under the lock before any read).
fn expire(arm: &mut Armed) {
if let Some(t) = arm.expires_at {
if Instant::now() >= t {
*arm = Armed::default();
}
}
}
/// The current valid PIN, or `None` if disarmed/expired. The QUIC ceremony reads this
/// per-attempt, so a window that lapsed mid-connection no longer pairs.
pub(super) fn current_pin(&self) -> Option<String> {
let mut arm = self.arm.lock().unwrap();
Self::expire(&mut arm);
arm.pin.clone()
}
/// A snapshot for the management API: `(armed, pin, expires_in_secs)`.
pub(super) fn snapshot(&self) -> ArmSnapshot {
let mut arm = self.arm.lock().unwrap();
Self::expire(&mut arm);
let expires_in_secs = arm
.expires_at
.map(|t| t.saturating_duration_since(Instant::now()).as_secs());
(arm.pin.is_some(), arm.pin.clone(), expires_in_secs)
}
}
@@ -0,0 +1,137 @@
//! The persistent native-pairing trust store: `~/.config/punktfunk/punktfunk1-paired.json`
//! (plan §W5 — carved out of the [`super`] facade). Owns the paired-clients [`Mutex`] and the
//! atomic-replace persistence; the pending-approval side of a pairing lives in [`super::approval`].
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
/// (Separate from GameStream pairing, which has its own store and ceremony.)
#[derive(Default, serde::Serialize, serde::Deserialize)]
pub struct PairedClients {
pub clients: Vec<PairedClient>,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct PairedClient {
pub name: String,
/// Hex SHA-256 of the client's certificate.
pub fingerprint: String,
}
impl PairedClients {
fn contains(&self, fp_hex: &str) -> bool {
self.clients
.iter()
.any(|c| c.fingerprint.eq_ignore_ascii_case(fp_hex))
}
}
struct PairedState {
path: PathBuf,
clients: PairedClients,
}
fn default_path() -> Result<PathBuf> {
// `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the
// native paired-store works without a HOME env var (which a Windows service/task doesn't set).
Ok(crate::gamestream::config_dir().join("punktfunk1-paired.json"))
}
fn load(path: &Path) -> PairedClients {
std::fs::read(path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save(state: &PairedState) -> Result<()> {
if let Some(dir) = state.path.parent() {
crate::gamestream::create_private_dir(dir)?;
}
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which would
// silently lock out every paired client on a --require-pairing host). Temp + rename. The temp is
// written owner-only so a local user can't inject a fingerprint to pair themselves.
let tmp = state.path.with_extension("json.tmp");
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&state.clients)?)?;
std::fs::rename(&tmp, &state.path)?;
Ok(())
}
/// The persistent trust store — the paired-clients set behind a [`Mutex`], backed by an
/// atomic-replace JSON file.
pub(super) struct TrustStore {
paired: Mutex<PairedState>,
}
impl TrustStore {
/// Open (load) the trust store. `store_path = None` uses the default config path.
pub(super) fn open(store_path: Option<PathBuf>) -> Result<TrustStore> {
let path = match store_path {
Some(p) => p,
None => default_path()?,
};
let clients = load(&path);
Ok(TrustStore {
paired: Mutex::new(PairedState { path, clients }),
})
}
/// Is this client (hex SHA-256 fingerprint) in the paired set?
pub(super) fn is_paired(&self, fp_hex: &str) -> bool {
self.paired.lock().unwrap().clients.contains(fp_hex)
}
/// Record a successful pairing (re-pairing the same fingerprint just updates the name —
/// matched case-insensitively, like every other fingerprint comparison here). The name is
/// sanitized (untrusted). On a persist failure the in-memory store is rolled back so it never
/// diverges from disk. (Clearing any pending knock for this fingerprint is the caller's job —
/// see [`super::approval::ApprovalQueue::admit_and_clear`].)
pub(super) fn add(&self, name: &str, fp_hex: &str) -> Result<()> {
let name = super::sanitize_device_name(name, fp_hex);
let mut p = self.paired.lock().unwrap();
let snapshot = p.clients.clients.clone(); // restore on a failed save
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
p.clients.clients.push(PairedClient {
name,
fingerprint: fp_hex.to_string(),
});
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
Ok(())
}
/// The paired clients (for the management API's device list).
pub(super) fn list(&self) -> Vec<PairedClient> {
self.paired.lock().unwrap().clients.clients.clone()
}
/// Remove a paired client by fingerprint. Returns whether one was removed. On a persist
/// failure the in-memory store is rolled back (it never diverges from disk).
pub(super) fn remove(&self, fp_hex: &str) -> Result<bool> {
let mut p = self.paired.lock().unwrap();
let before = p.clients.clients.len();
let snapshot = p.clients.clients.clone();
p.clients
.clients
.retain(|c| !c.fingerprint.eq_ignore_ascii_case(fp_hex));
let removed = p.clients.clients.len() != before;
if removed {
if let Err(e) = save(&p) {
p.clients.clients = snapshot;
return Err(e);
}
}
Ok(removed)
}
/// The number of paired clients (for the status snapshot).
pub(super) fn count(&self) -> u32 {
self.paired.lock().unwrap().clients.clients.len() as u32
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
//! Shared microburst pacing POLICY for the two video send planes (networking-audit deferred
//! plan §5): the native plane (`punktfunk1::paced_submit`, GSO via the core `Session`) and the
//! plan §5): the native plane (`native::paced_submit`, GSO via the core `Session`) and the
//! GameStream compat plane (`gamestream::stream::spawn_sender`, `sendmmsg` over its own RTP
//! socket). Both spread a frame's packets across a time budget in chunked bursts so a real link
//! doesn't drop the frame as one line-rate burst; the syscall layers stay deliberately separate
@@ -244,7 +244,7 @@ pub(crate) fn percentile(v: &mut [u32], q: f64) -> u32 {
mod tests {
use super::*;
/// The native plane's canonical parameters (mirrors `punktfunk1::paced_submit`).
/// The native plane's canonical parameters (mirrors `native::paced_submit`).
fn native_cfg(burst_cap: usize) -> PaceCfg {
PaceCfg {
burst_bytes: Some(burst_cap),
+1 -1
View File
@@ -3,7 +3,7 @@
//!
//! **Goal-1 stage 3** (`design/windows-host-rewrite.md` §2.2): before this, the Windows session decision was
//! re-derived at three call sites — the capture backend inside `capture::capture_virtual_output`, the
//! process topology in `punktfunk1::should_use_helper`, and the encode backend in
//! process topology in `native::should_use_helper`, and the encode backend in
//! `encode::windows_resolved_backend` — each reading [`config`](crate::config) independently, with no
//! single owner (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `SessionPlan`
//! resolves them together, once, so the deployed path reads one typed artifact.
+38 -7
View File
@@ -2,13 +2,13 @@
//!
//! The GameStream media pipeline records its session in `AppState.{launch, stream, streaming}`
//! (consumed by RTSP/media), but the native punktfunk/1 plane never touches `AppState` — by design
//! it is handed only the shared stats recorder ([`crate::punktfunk1::serve`]). So a native session,
//! it is handed only the shared stats recorder ([`crate::native::serve`]). So a native session,
//! which is the DEFAULT plane (GameStream is opt-in, `--gamestream`), was invisible on the Dashboard:
//! `GET /status` reported `video_streaming: false` and no session/stream card while a client was
//! actively streaming (the Stats page worked because it shares the recorder — hence the confusing
//! "stats move but the dashboard says idle").
//!
//! This module is the small shared surface the native video loop ([`crate::punktfunk1::virtual_stream`])
//! This module is the small shared surface the native video loop ([`crate::native::virtual_stream`])
//! publishes a live snapshot to, keyed per session so CONCURRENT native sessions each get an entry
//! (the native server admits up to `max_sessions`, unbounded by default). The loop registers on
//! stream start and the returned [`LiveSessionGuard`] removes the entry on ANY scope exit (return,
@@ -26,7 +26,7 @@ use crate::encode::Codec;
/// second update path — plus the flags the mgmt API flips to control the session.
struct LiveSession {
id: u64,
/// Packed `w:16|h:16|hz:16` ([`crate::punktfunk1::pack_mode`]); updated on a mode switch.
/// Packed `w:16|h:16|hz:16` ([`crate::native::pack_mode`]); updated on a mode switch.
mode: Arc<AtomicU64>,
/// Live encoder target (kbps); updated on an adaptive-bitrate change.
bitrate_kbps: Arc<AtomicU32>,
@@ -36,6 +36,10 @@ struct LiveSession {
/// One-shot force-keyframe flag ([`force_idr_all`] → mgmt `POST /session/idr`); the encode loop
/// drains it alongside a client's decode-recovery keyframe request.
force_idr: Arc<AtomicBool>,
/// Short client label (cert-fingerprint prefix / peer IP) — carried on the lifecycle events.
client: String,
/// Whether the session negotiated HDR — carried on the lifecycle events.
hdr: bool,
}
/// A resolved read of one live session, for the `/status` view.
@@ -58,23 +62,44 @@ fn next_id() -> u64 {
ID.fetch_add(1, Ordering::Relaxed)
}
/// Resolves one session's [`crate::events::SessionRef`] (mode read live) for the lifecycle events.
fn session_ref(s: &LiveSession) -> crate::events::SessionRef {
let (width, height, fps) = crate::native::unpack_mode(s.mode.load(Ordering::Relaxed));
crate::events::SessionRef {
id: s.id,
client: s.client.clone(),
mode: crate::events::mode_str(width, height, fps),
hdr: s.hdr,
}
}
/// Registers a live native session; the returned guard removes it on drop (session end).
/// Emits the `session.started` lifecycle event; the guard's drop emits `session.ended` — RAII,
/// so every exit path (return, `?`, panic-unwind) pairs them.
pub fn register(
mode: Arc<AtomicU64>,
bitrate_kbps: Arc<AtomicU32>,
codec: Codec,
stop: Arc<AtomicBool>,
force_idr: Arc<AtomicBool>,
client: String,
hdr: bool,
) -> LiveSessionGuard {
let id = next_id();
registry().lock().unwrap().push(LiveSession {
let session = LiveSession {
id,
mode,
bitrate_kbps,
codec,
stop,
force_idr,
client,
hdr,
};
crate::events::emit(crate::events::EventKind::SessionStarted {
session: session_ref(&session),
});
registry().lock().unwrap().push(session);
LiveSessionGuard { id }
}
@@ -85,7 +110,14 @@ pub struct LiveSessionGuard {
impl Drop for LiveSessionGuard {
fn drop(&mut self) {
registry().lock().unwrap().retain(|s| s.id != self.id);
let mut reg = registry().lock().unwrap();
if let Some(pos) = reg.iter().position(|s| s.id == self.id) {
let session = reg.remove(pos);
drop(reg); // emit outside the registry lock — the bus takes its own
crate::events::emit(crate::events::EventKind::SessionEnded {
session: session_ref(&session),
});
}
}
}
@@ -101,8 +133,7 @@ pub fn snapshot() -> Vec<SessionSnapshot> {
.unwrap()
.iter()
.map(|s| {
let (width, height, fps) =
crate::punktfunk1::unpack_mode(s.mode.load(Ordering::Relaxed));
let (width, height, fps) = crate::native::unpack_mode(s.mode.load(Ordering::Relaxed));
SessionSnapshot {
width,
height,
+1 -1
View File
@@ -97,6 +97,6 @@ mod imp {
pub use imp::on_hot_thread;
/// No-op on non-Windows (Linux uses `setpriority` nice + CUDA stream priority instead — see
/// `punktfunk1::boost_thread_priority` and `zerocopy::cuda`).
/// `native::boost_thread_priority` and `zerocopy::cuda`).
#[cfg(not(target_os = "windows"))]
pub fn on_hot_thread() {}
+1 -1
View File
@@ -2,7 +2,7 @@
//! [`StatsRecorder`] handle is created once in the unified host entry
//! (`gamestream::serve`) alongside [`crate::native_pairing::NativePairing`], and shared with
//! **both** the management API ([`crate::mgmt`]) and the streaming loops (threaded through
//! [`crate::punktfunk1::serve`] → `SessionContext` and into the GameStream encode loop). The
//! [`crate::native::serve`] → `SessionContext` and into the GameStream encode loop). The
//! operator arms a capture from the web console, plays a session, stops, and reviews the
//! captured time-series as graphs; captures are saved to disk and survive a host restart.
//!
+43 -9
View File
@@ -53,6 +53,21 @@ pub struct StreamInfo {
pub hdr: bool,
/// Client-supplied device name (may be empty); sanitized before it reaches the file.
pub client: String,
/// Store-qualified launch id this session requested, if any — carried on the stream
/// lifecycle events, NOT written to the marker file (its key set is a stable contract).
pub launch: Option<String>,
}
/// The announce points double as the `stream.started`/`stream.stopped` lifecycle fire sites
/// (RFC §4) — only the native loop announces the marker today, hence the fixed plane.
fn stream_ref(info: &StreamInfo) -> crate::events::StreamRef {
crate::events::StreamRef {
mode: crate::events::mode_str(info.width, info.height, info.refresh_hz),
hdr: info.hdr,
client: info.client.clone(),
app: info.launch.clone(),
plane: crate::events::Plane::Native,
}
}
/// RAII handle for one announced session. While it is alive the session counts toward the marker;
@@ -62,25 +77,36 @@ pub struct StreamInfo {
pub struct Guard {
#[cfg(unix)]
id: u64,
/// The announced stream, re-emitted as `stream.stopped` when the guard drops.
stream: crate::events::StreamRef,
}
/// Announce that a client has started streaming at `info`'s mode. Returns a [`Guard`] that must be
/// held for the streaming lifetime — drop it when the session ends.
/// held for the streaming lifetime — drop it when the session ends. Emits `stream.started` on all
/// platforms (the marker file itself is unix-only); the guard's drop emits `stream.stopped`.
pub fn announce(info: StreamInfo) -> Guard {
crate::events::emit(crate::events::EventKind::StreamStarted {
stream: stream_ref(&info),
});
#[cfg(unix)]
{
imp::announce(info)
}
#[cfg(not(unix))]
{
let _ = info;
Guard {}
Guard {
stream: stream_ref(&info),
}
}
}
#[cfg(not(unix))]
impl Drop for Guard {
fn drop(&mut self) {}
fn drop(&mut self) {
crate::events::emit(crate::events::EventKind::StreamStopped {
stream: self.stream.clone(),
});
}
}
#[cfg(unix)]
@@ -141,10 +167,11 @@ mod imp {
}
pub(super) fn announce(info: StreamInfo) -> Guard {
let stream = super::stream_ref(&info);
let mut reg = REGISTRY.lock().unwrap();
let id = reg.insert(info);
rewrite_to(&marker_path(), &reg);
Guard { id }
Guard { id, stream }
}
/// Rewrite (or remove) the marker at `path` to match `reg`. Called under the registry lock.
@@ -199,9 +226,14 @@ mod imp {
impl Drop for Guard {
fn drop(&mut self) {
let mut reg = REGISTRY.lock().unwrap();
reg.remove(self.id);
rewrite_to(&marker_path(), &reg);
{
let mut reg = REGISTRY.lock().unwrap();
reg.remove(self.id);
rewrite_to(&marker_path(), &reg);
}
crate::events::emit(crate::events::EventKind::StreamStopped {
stream: self.stream.clone(),
});
}
}
@@ -219,7 +251,7 @@ mod imp {
// The marker file lifecycle is exercised against a real path so the atomic-write + remove
// logic is covered end to end. It drives a LOCAL registry at an explicit temp path: the
// process-global one is shared with the punktfunk1 integration tests (which announce real
// process-global one is shared with the native integration tests (which announce real
// sessions concurrently), and mutating XDG_RUNTIME_DIR mid-run would race them too.
#[test]
fn marker_appears_while_held_and_vanishes_after() {
@@ -237,6 +269,7 @@ mod imp {
refresh_hz: 120,
hdr: true,
client: "Couch'TV".to_string(),
launch: None,
});
rewrite_to(&path, &reg);
let text = std::fs::read_to_string(&path).expect("marker exists while streaming");
@@ -255,6 +288,7 @@ mod imp {
refresh_hz: 60,
hdr: false,
client: "Phone".to_string(),
launch: None,
});
rewrite_to(&path, &reg);
let text = std::fs::read_to_string(&path).unwrap();
+1 -1
View File
@@ -349,7 +349,7 @@ impl Compositor {
Compositor::Gamescope => P::Gamescope,
// D2: no distinct wire byte for Hyprland — it shares the wlroots-family `Wlroots` pref.
// A client asking for `wlroots`/`hyprland` gets whichever of the two is the live session
// ([`pick_compositor`](crate::punktfunk1::pick_compositor) resolves the family).
// ([`pick_compositor`](crate::native::pick_compositor) resolves the family).
Compositor::Hyprland => P::Wlroots,
}
}
+21 -13
View File
@@ -89,18 +89,24 @@ pub fn acquire(
mode: super::Mode,
quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Result<super::VirtualOutput> {
let backend = vd.name();
#[cfg(target_os = "linux")]
{
linux::acquire(vd, mode, quit)
}
let out = linux::acquire(vd, mode, quit);
#[cfg(not(target_os = "linux"))]
{
let out = {
// Windows leases in the manager (its own linger); its deliberate-quit skip is wired through
// `VirtualDisplay::set_quit_flag` on the backend instance (set by the session before any
// `create`, so the retry-hold lease gets it too) — not through this parameter.
let _ = quit;
vd.create(mode)
};
if out.is_ok() {
crate::events::emit(crate::events::EventKind::DisplayCreated {
backend: backend.to_string(),
mode: crate::events::mode_str(mode.width, mode.height, mode.refresh_hz),
});
}
out
}
/// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read);
@@ -149,20 +155,22 @@ pub fn snapshot() -> Snapshot {
/// released.
pub fn release(slot: Option<u64>) -> usize {
#[cfg(target_os = "windows")]
{
// Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp
// ([`DisplayInfo::slot`]); `None` releases every kept one.
super::manager::force_release(slot)
}
// Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp
// ([`DisplayInfo::slot`]); `None` releases every kept one.
let released = super::manager::force_release(slot);
#[cfg(target_os = "linux")]
{
linux::force_release(slot)
}
let released = linux::force_release(slot);
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
let released = {
let _ = slot;
0
};
if released > 0 {
crate::events::emit(crate::events::EventKind::DisplayReleased {
count: released as u32,
});
}
released
}
/// Tear down a **reused-but-dead** pool entry by its generation stamp (A2). Called by the pipeline
@@ -260,7 +260,7 @@ pub(crate) struct VirtualDisplayManager {
/// Serializes IDD-push session SETUP (preempt + monitor create) — MANAGER-WIDE even with slots:
/// monitor create/teardown stays serialized (the 400 ms async-departure settle and the IddCx
/// slot-budget wedge both want zero concurrent ADD/REMOVE). Held by the session across the
/// pipeline build (was the `IDD_SETUP_LOCK` global in `punktfunk1`).
/// pipeline build (was the `IDD_SETUP_LOCK` global in `native`).
setup_lock: Mutex<()>,
/// Per-SLOT IDD-push session stop flags: a new connection signals only the stop of a session
/// holding *that identity's* slot (the same-client zombie-reconnect preempt, slot-scoped since
@@ -1392,7 +1392,7 @@ impl VirtualDisplayManager {
}
/// Begin an IDD-push session setup (Goal-1 §2.5 — was the `IDD_SETUP_LOCK` / `IDD_SESSION_STOP` /
/// `wait_for_monitor_released` dance smeared across `punktfunk1`). Serializes via the (manager-wide)
/// `wait_for_monitor_released` dance smeared across `native`). Serializes via the (manager-wide)
/// setup lock, registers THIS session's stop flag on its SLOT while signalling the prior session
/// holding that slot to stop, and waits for it to release the slot's monitor — so a reconnect
/// (whose reused IddCx swap-chain is dead) preempts the stale session cleanly before a fresh