diff --git a/Cargo.lock b/Cargo.lock index 85e74742..ab880cfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3184,10 +3184,10 @@ dependencies = [ "anyhow", "ksni", "libc", + "punktfunk-core", "rustls", "serde", "serde_json", - "sha2", "ureq", "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", "windows-service", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index eff15687..194c52c8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -1,6 +1,8 @@ package io.unom.punktfunk import android.content.Context +import android.os.Build +import android.util.Log import android.view.Display /** @@ -249,11 +251,25 @@ fun nativeDisplayMode(context: Context): Triple { */ fun displaySupportsHdr(context: Context): Boolean { val display = runCatching { context.display }.getOrNull() ?: return false - @Suppress("DEPRECATION") // hdrCapabilities is the supported query on minSdk 31 - val caps = display.hdrCapabilities ?: return false - return caps.supportedHdrTypes.any { + val types = buildSet { + // API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The + // deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices + // (Pixel-class panels included), which would make a genuinely HDR display advertise + // no-HDR and pin the whole session to 8-bit SDR. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + display.mode.supportedHdrTypes.forEach { add(it) } + } + // Union the legacy query defensively — the supported one on minSdk 31, and some vendors + // populate only this on newer APIs. + @Suppress("DEPRECATION") + display.hdrCapabilities?.supportedHdrTypes?.forEach { add(it) } + } + // HDR10/HDR10+ only: the stream is BT.2020 PQ — a Dolby-Vision/HLG-only panel can't present it. + val supported = types.any { it == Display.HdrCapabilities.HDR_TYPE_HDR10 || it == Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS } + Log.i("punktfunk", "display HDR types=$types → advertise HDR10=$supported") + return supported } /** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */ diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt index 1c62799f..bd25ae8f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt @@ -39,6 +39,7 @@ internal fun StatsOverlay( s: DoubleArray, verbosity: StatsVerbosity, decoderLabel: String = "", + codecLabel: String = "", modifier: Modifier = Modifier, ) { if (verbosity == StatsVerbosity.OFF || s.size < 10) return @@ -66,7 +67,7 @@ internal fun StatsOverlay( statLine(decoderLabel, Color(0xFFB0D0FF)) } if (detailed) { - videoFeedLine(s)?.let { statLine(it, Color.White) } + videoFeedLine(s, codecLabel)?.let { statLine(it, Color.White) } } if (latValid) { // Display stage (s[22]–s[25], from OnFrameRendered): when a render timestamp landed @@ -151,14 +152,15 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? { } /** - * Format the negotiated video-feed descriptor from the trailing four stats doubles - * `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g. - * `HEVC · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles) + * Format the negotiated video-feed descriptor from [codecLabel] plus the trailing four stats + * doubles `[bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc]`, e.g. + * `AV1 · 10-bit · HDR (BT.2020 PQ) · 4:2:0`. Returns `null` on a pre-video-feed layout (< 14 doubles) * so the overlay simply omits the line. The codes are CICP / H.273: transfer 16 = PQ, 18 = HLG (else - * SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. The - * Android decoder is always HEVC (`video/hevc`). + * SDR); primaries 9 = BT.2020, 1 = BT.709; chroma_format_idc 1 = 4:2:0, 2 = 4:2:2, 3 = 4:4:4. + * [codecLabel] is the host-resolved codec (`nativeVideoCodecLabel`); a blank one falls back to + * `HEVC` (the pre-negotiation default) for the brief window before it's resolved. */ -private fun videoFeedLine(s: DoubleArray): String? { +private fun videoFeedLine(s: DoubleArray, codecLabel: String): String? { if (s.size < 14) return null val bitDepth = s[10].toInt() val primaries = s[11].toInt() @@ -175,5 +177,6 @@ private fun videoFeedLine(s: DoubleArray): String? { 2 -> "4:2:2" else -> "4:2:0" } - return "HEVC · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel" + val codec = codecLabel.ifEmpty { "HEVC" } + return "$codec · $depthLabel · $dynamicRange ($colorSpace) · $chromaLabel" } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 1b8c5c66..d9d862b9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -84,6 +84,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { val initialSettings = remember { SettingsStore(context).load() } var stats by remember { mutableStateOf(null) } var decoderLabel by remember { mutableStateOf("") } + var codecLabel by remember { mutableStateOf("") } var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) } val statsOn = statsVerbosity != StatsVerbosity.OFF // Touch model is fixed per session (re-keys the gesture handler below if it ever changes). @@ -99,6 +100,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { LaunchedEffect(handle, statsOn) { NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn) if (statsOn) { + // Codec is resolved at the handshake (Welcome) — fixed for the session, so read its + // label once up front (before the first snapshot renders the video-feed line). + if (codecLabel.isEmpty()) codecLabel = NativeBridge.nativeVideoCodecLabel(handle) while (true) { delay(1000) stats = NativeBridge.nativeVideoStats(handle) @@ -366,7 +370,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { // BEFORE the transparent gesture layer below, so it shows through and never eats touches. if (statsOn) { stats?.let { - StatsOverlay(it, statsVerbosity, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) + StatsOverlay(it, statsVerbosity, decoderLabel, codecLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) } } // "Hold to quit" hint while the gamepad exit chord is armed — the exit debounces on a ~1 s diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 35b69aea..e9d49ed9 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -214,6 +214,7 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) { ), verbosity = verbosity, decoderLabel = "c2.qti.hevc.decoder · low-latency", + codecLabel = "HEVC", modifier = Modifier.align(Alignment.TopStart).padding(12.dp), ) } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 377dad22..5445f84d 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -161,6 +161,14 @@ object NativeBridge { */ external fun nativeVideoMime(handle: Long): String + /** + * A short human label for the codec the host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / + * `"PyroWave"`), for the stats HUD's video-feed line, or `""` on a `0` handle. Distinct from + * [nativeVideoMime] because the MIME collapses PyroWave onto `video/hevc` and can't name it. + * Fixed for the session (resolved at the handshake); read once. Cheap; UI-safe. + */ + external fun nativeVideoCodecLabel(handle: Long): String + /** * Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs * entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the diff --git a/clients/android/native/src/decode.rs b/clients/android/native/src/decode.rs index 30a0b3d7..769bd305 100644 --- a/clients/android/native/src/decode.rs +++ b/clients/android/native/src/decode.rs @@ -617,6 +617,19 @@ pub(crate) fn codec_mime(codec: u8) -> &'static str { } } +/// A short human label for the codec the host resolved, for the stats HUD's video-feed line +/// (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`). Mirrors [`codec_mime`]'s fallback: anything +/// not H.264/AV1/PyroWave is reported as HEVC (every pre-negotiation host emitted HEVC). Kept +/// beside [`codec_mime`] because the MIME collapses PyroWave onto `video/hevc` and so can't name it. +pub(crate) fn codec_label(codec: u8) -> &'static str { + match codec { + punktfunk_core::quic::CODEC_H264 => "H.264", + punktfunk_core::quic::CODEC_AV1 => "AV1", + punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave", + _ => "HEVC", + } +} + /// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList` /// (`from_codec_name`), falling back to the platform's default decoder for the MIME /// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update). diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 275a8787..6e34efa6 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -102,6 +102,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<' }) } +/// `NativeBridge.nativeVideoCodecLabel(handle): String` — a short human label for the codec the +/// host resolved (`"H.264"` / `"HEVC"` / `"AV1"` / `"PyroWave"`), for the stats HUD's video-feed +/// line. Distinct from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime`] because the MIME +/// collapses PyroWave onto `video/hevc` and can't name it. Empty string on a `0` handle. Cheap; +/// safe on the UI thread. Android-gated (reads `crate::decode`), matching `nativeVideoMime`. +#[cfg(target_os = "android")] +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoCodecLabel<'local>( + env: JNIEnv<'local>, + _this: JObject<'local>, + handle: jlong, +) -> jstring { + jni_guard(std::ptr::null_mut(), || { + if handle == 0 { + return std::ptr::null_mut(); + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + match env.new_string(crate::decode::codec_label(h.client.codec)) { + Ok(s) => s.into_raw(), + Err(_) => std::ptr::null_mut(), + } + }) +} + /// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the /// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one. /// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not diff --git a/crates/punktfunk-core/src/transport/qos.rs b/crates/punktfunk-core/src/transport/qos.rs index 19eed00a..8b94f3cc 100644 --- a/crates/punktfunk-core/src/transport/qos.rs +++ b/crates/punktfunk-core/src/transport/qos.rs @@ -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; diff --git a/crates/punktfunk-core/src/transport/udp.rs b/crates/punktfunk-core/src/transport/udp.rs index 4ba460c4..969eb1ef 100644 --- a/crates/punktfunk-core/src/transport/udp.rs +++ b/crates/punktfunk-core/src/transport/udp.rs @@ -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). diff --git a/crates/punktfunk-host/README.md b/crates/punktfunk-host/README.md index 441a0116..44ff2221 100644 --- a/crates/punktfunk-host/README.md +++ b/crates/punktfunk-host/README.md @@ -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 diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index 86994c86..efa4b9d2 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -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 { diff --git a/crates/punktfunk-host/src/config.rs b/crates/punktfunk-host/src/config.rs index 06a91116..7062e948 100644 --- a/crates/punktfunk-host/src/config.rs +++ b/crates/punktfunk-host/src/config.rs @@ -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, - /// `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") diff --git a/crates/punktfunk-host/src/encode.rs b/crates/punktfunk-host/src/encode.rs index 2e4806a8..ece3ceae 100644 --- a/crates/punktfunk-host/src/encode.rs +++ b/crates/punktfunk-host/src/encode.rs @@ -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>> = 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 diff --git a/crates/punktfunk-host/src/encode/codec.rs b/crates/punktfunk-host/src/encode/codec.rs index 776aeb75..f1b48ca0 100644 --- a/crates/punktfunk-host/src/encode/codec.rs +++ b/crates/punktfunk-host/src/encode/codec.rs @@ -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 { diff --git a/crates/punktfunk-host/src/encode/windows/amf.rs b/crates/punktfunk-host/src/encode/windows/amf.rs index db5e8619..3265c0cc 100644 --- a/crates/punktfunk-host/src/encode/windows/amf.rs +++ b/crates/punktfunk-host/src/encode/windows/amf.rs @@ -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), diff --git a/crates/punktfunk-host/src/encode/windows/nvenc.rs b/crates/punktfunk-host/src/encode/windows/nvenc.rs index d0ebf0be..e130aad5 100644 --- a/crates/punktfunk-host/src/encode/windows/nvenc.rs +++ b/crates/punktfunk-host/src/encode/windows/nvenc.rs @@ -321,7 +321,7 @@ fn retrieve_loop( work_rx: mpsc::Receiver, done_tx: mpsc::Sender, ) { - 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; diff --git a/crates/punktfunk-host/src/events.rs b/crates/punktfunk-host/src/events.rs new file mode 100644 index 00000000..f398775b --- /dev/null +++ b/crates/punktfunk-host/src/events.rs @@ -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, + 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, + 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": "."`, +/// 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, + /// 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, +} + +/// The process-wide event bus: a bounded seq-numbered ring (catch-up) + a broadcast channel +/// (live tail). +pub struct EventBus { + inner: Mutex, + tx: broadcast::Sender, +} + +struct Ring { + events: VecDeque, + 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 = 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![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![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:?}"), + } + } +} diff --git a/crates/punktfunk-host/src/gamestream/gamepad.rs b/crates/punktfunk-host/src/gamestream/gamepad.rs index 535c2c3e..540ee5e1 100644 --- a/crates/punktfunk-host/src/gamestream/gamepad.rs +++ b/crates/punktfunk-host/src/gamestream/gamepad.rs @@ -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; diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 5154fea0..40e0b4b5 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -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 }) } diff --git a/crates/punktfunk-host/src/gamestream/pairing.rs b/crates/punktfunk-host/src/gamestream/pairing.rs index e28ebaa2..d2d05967 100644 --- a/crates/punktfunk-host/src/gamestream/pairing.rs +++ b/crates/punktfunk-host/src/gamestream/pairing.rs @@ -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!( diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 1c3331e7..15ebbe6e 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -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 diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index baaf4900..70c91925 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -11,13 +11,44 @@ //! This module is read-mostly metadata; *launching* a chosen title (mapping [`LaunchSpec`] onto a //! gamescope session) is a later step — the launch hint is carried here so that wiring is trivial. -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; -use utoipa::ToSchema; +// Shared vocabulary re-exported to the submodules (each is `use super::*`). +pub(crate) use anyhow::{Context, Result}; +pub(crate) use serde::{Deserialize, Serialize}; +pub(crate) use sha2::{Digest, Sha256}; +pub(crate) use std::collections::HashSet; +pub(crate) use std::path::{Path, PathBuf}; +pub(crate) use std::time::{SystemTime, UNIX_EPOCH}; +pub(crate) use utoipa::ToSchema; + +mod art; +mod custom; +#[cfg(windows)] +mod epic; +#[cfg(windows)] +mod gog; +#[cfg(target_os = "linux")] +mod heroic; +mod launch; +#[cfg(target_os = "linux")] +mod lutris; +mod steam; +#[cfg(windows)] +mod xbox; + +pub use art::*; +pub use custom::*; +#[cfg(windows)] +pub use epic::*; +#[cfg(windows)] +pub use gog::*; +#[cfg(target_os = "linux")] +pub use heroic::*; +pub use launch::*; +#[cfg(target_os = "linux")] +pub use lutris::*; +pub use steam::*; +#[cfg(windows)] +pub use xbox::*; /// Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for /// custom). The client prefers `portrait` for a grid and falls back to `header` when a title has @@ -76,44 +107,6 @@ pub trait LibraryProvider { fn list(&self) -> Vec; } -// --------------------------------------------------------------------------------------- -// Steam -// --------------------------------------------------------------------------------------- - -/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come -/// from `steamapps/appmanifest_.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 { - let mut by_appid: std::collections::BTreeMap = 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() - } -} - /// Steam art, keyed to one of the four [`Artwork`] fields. Newer/recently-updated titles serve /// their CDN assets from a per-asset-hash path the client can't predict (e.g. /// `.../apps///header.jpg`), so the flat legacy URL [`steam_art`] guesses 404s for them — @@ -163,1567 +156,6 @@ impl ArtKind { } } -/// 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, 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///` across every Steam root, for whichever -/// `` 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, 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///`, -/// 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 { - 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 { - 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 { - 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) -> Vec { - 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 { - let mut seen = HashSet::new(); - let mut dirs = Vec::new(); - let mut push = |steamapps: PathBuf, dirs: &mut Vec| { - 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" ""` 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 { - 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() -} - -/// `"" ""` on a single line → ``. 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::), 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") -} - -// --------------------------------------------------------------------------------------- -// Lutris (Linux) — reads the local `pga.db` (no auth, no network). One provider covers -// everything Lutris manages: Wine/Proton games, GOG/Epic/Battle.net installs, emulators. -// --------------------------------------------------------------------------------------- - -/// 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 { - 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 { - 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> { - 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>(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 `/.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 { - 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 -} - -// --------------------------------------------------------------------------------------- -// Heroic (Linux) — Epic + GOG + Amazon in one provider. Reads Heroic's `store_cache` JSON -// (no auth); cover art is already public Epic/GOG/Amazon CDN URLs the client fetches directly. -// --------------------------------------------------------------------------------------- - -/// 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 { - 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 { - 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> { - 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 (`:`) 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")] -fn heroic_command(value: &str) -> Option { - 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 { - 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()) -} - -// --------------------------------------------------------------------------------------- -// Epic Games Store (Windows) — reads the launcher's local `.item` manifests under ProgramData -// (no auth, launcher need not run). Cover art from the base64 `catcache.bin` (public Epic CDN). -// --------------------------------------------------------------------------------------- - -/// 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 { - 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::(&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, -) -> Option { - 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> { - 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 { - 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::(&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 -/// `::` (colons URL-encoded), or a bare `` fallback. -/// Each part is charset-validated (host-derived, but belt-and-suspenders) so no shell/URI injection. -#[cfg(windows)] -fn epic_launch_uri(value: &str) -> Option { - 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::>().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" - )) -} - -// --------------------------------------------------------------------------------------- -// GOG (Windows) — registry-indexed installs + each game's `goggame-.info` for a direct-exe -// launch (no Galaxy needed, dodges its cold-start/anti-cheat). Art (api.gog.com) is a follow-up. -// --------------------------------------------------------------------------------------- - -/// 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 { - gog_games() - } -} - -#[cfg(windows)] -fn gog_games() -> Vec { - 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::("PATH") else { - continue; - }; - if !Path::new(&path).is_dir() { - continue; - } - let title = k - .get_value::("GAMENAME") - .unwrap_or_else(|_| sub.clone()); - // Resolve the primary play task (exe + args + workdir) from goggame-.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 `\goggame-.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)] -fn gog_spawn(value: &str) -> Option<(String, Option)> { - 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)) -} - -// --------------------------------------------------------------------------------------- -// Xbox / Microsoft Store / Game Pass (Windows) — scans the flat-file `XboxGames` install dirs -// (no auth) for GDK games (each has a Content\MicrosoftGame.config). Launch via the AUMID -// (shell:AppsFolder\!) in the interactive session. Cover art (displaycatalog) deferred. -// --------------------------------------------------------------------------------------- - -/// 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 { - xbox_games() - } -} - -/// Scan each fixed drive's default `:\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 { - 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 ``'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\` 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 { - 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 { - let hash = dir_name.rsplit('_').next()?; - (!hash.is_empty() && hash != dir_name).then(|| format!("{identity}_{hash}")) -} - -// --------------------------------------------------------------------------------------- -// Cover-art resolver + cache (shared by the Windows GOG + Xbox providers, which have no local -// art). A disk cache is the source of truth read by all_games() (so the list/launch path never -// blocks on the network); a host-lifetime background warmer fetches uncached art (GOG's public -// api.gog.com + Xbox's displaycatalog, both no-auth) and persists it. Cross-platform so the -// HTTP/JSON code is compiled + checked everywhere; the warmer simply finds nothing to fetch on a -// host whose stores all carry their own art (Steam CDN / Heroic CDN / Lutris data: URLs). -// --------------------------------------------------------------------------------------- - -/// 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> { - static CACHE: std::sync::OnceLock< - std::sync::Mutex>, - > = 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. -fn cached_art(id: &str) -> Option { - 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 { - 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. -fn fetch_image(url: &str) -> Option<(Vec, String)> { - use base64::Engine as _; - use std::io::Read as _; - if let Some(rest) = url.strip_prefix("data:") { - // data:[][;base64], - 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, 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::().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 -} - -// --------------------------------------------------------------------------------------- -// Custom store (user-curated entries, persisted + CRUD'd via the mgmt API) -// --------------------------------------------------------------------------------------- - -/// 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, -} - -/// 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, -} - -impl From 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 { - 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 { - 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)?; - 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> { - 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)?; - Ok(Some(updated)) -} - -/// Delete a custom entry. `false` ⇒ no entry with that id. -pub fn delete_custom(id: &str) -> Result { - 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)?; - Ok(true) -} - -// --------------------------------------------------------------------------------------- -// Unified library -// --------------------------------------------------------------------------------------- - -/// 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`]). -fn valid_steam_appid(value: &str) -> bool { - !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()) -} - -/// 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 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 { - 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 { - 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: `:` → 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)> { - 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-.info), no Galaxy. - "gog" => gog_spawn(&spec.value), - // Xbox/Game Pass: activate the UWP/GDK package by its AUMID (!) 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 { - 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 { - 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) -} - /// The full library: every store's titles merged + the custom entries, sorted by title. pub fn all_games() -> Vec { let mut games = SteamProvider.list(); @@ -1745,439 +177,3 @@ pub fn all_games() -> Vec { games.sort_by_key(|g| g.title.to_lowercase()); games } - -#[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 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 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")); - } - - #[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 - ); - } - - #[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()); - } - - #[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"); - } - - #[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")); - } - - #[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") - ); - } - - #[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()); - } - - #[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()); - } - - #[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}"); - } - - #[cfg(windows)] - #[test] - fn xbox_parse_config_and_pfn() { - let xml = r#" - - - - - - 9NBLGGH4R315 - -"#; - 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#" - - "#; - 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()); - } -} diff --git a/crates/punktfunk-host/src/library/art.rs b/crates/punktfunk-host/src/library/art.rs new file mode 100644 index 00000000..372ee0e6 --- /dev/null +++ b/crates/punktfunk-host/src/library/art.rs @@ -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> { + static CACHE: std::sync::OnceLock< + std::sync::Mutex>, + > = 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 { + 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 { + 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, String)> { + use base64::Engine as _; + use std::io::Read as _; + if let Some(rest) = url.strip_prefix("data:") { + // data:[][;base64], + 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, 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::().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()); + } +} diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs new file mode 100644 index 00000000..09bc0a9f --- /dev/null +++ b/crates/punktfunk-host/src/library/custom.rs @@ -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, +} + +/// 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, +} + +impl From 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 { + 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 { + 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> { + 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 { + 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"); + } +} diff --git a/crates/punktfunk-host/src/library/epic.rs b/crates/punktfunk-host/src/library/epic.rs new file mode 100644 index 00000000..14e0c520 --- /dev/null +++ b/crates/punktfunk-host/src/library/epic.rs @@ -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 { + 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::(&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, +) -> Option { + 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> { + 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 { + 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::(&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 +/// `::` (colons URL-encoded), or a bare `` 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 { + 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::>().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()); + } +} diff --git a/crates/punktfunk-host/src/library/gog.rs b/crates/punktfunk-host/src/library/gog.rs new file mode 100644 index 00000000..1596552b --- /dev/null +++ b/crates/punktfunk-host/src/library/gog.rs @@ -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 { + gog_games() + } +} + +#[cfg(windows)] +fn gog_games() -> Vec { + 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::("PATH") else { + continue; + }; + if !Path::new(&path).is_dir() { + continue; + } + let title = k + .get_value::("GAMENAME") + .unwrap_or_else(|_| sub.clone()); + // Resolve the primary play task (exe + args + workdir) from goggame-.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 `\goggame-.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)> { + 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}"); + } +} diff --git a/crates/punktfunk-host/src/library/heroic.rs b/crates/punktfunk-host/src/library/heroic.rs new file mode 100644 index 00000000..f99eca8e --- /dev/null +++ b/crates/punktfunk-host/src/library/heroic.rs @@ -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 { + 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 { + 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> { + 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 (`:`) 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 { + 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 { + 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") + ); + } +} diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs new file mode 100644 index 00000000..932c2a69 --- /dev/null +++ b/crates/punktfunk-host/src/library/launch.rs @@ -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 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 { + 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 { + 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: `:` → 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)> { + 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-.info), no Galaxy. + "gog" => gog_spawn(&spec.value), + // Xbox/Game Pass: activate the UWP/GDK package by its AUMID (!) 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 { + 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 { + 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()); + } +} diff --git a/crates/punktfunk-host/src/library/lutris.rs b/crates/punktfunk-host/src/library/lutris.rs new file mode 100644 index 00000000..19d3864d --- /dev/null +++ b/crates/punktfunk-host/src/library/lutris.rs @@ -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 { + 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 { + 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> { + 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>(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 `/.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 { + 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")); + } +} diff --git a/crates/punktfunk-host/src/library/steam.rs b/crates/punktfunk-host/src/library/steam.rs new file mode 100644 index 00000000..6a27cc6f --- /dev/null +++ b/crates/punktfunk-host/src/library/steam.rs @@ -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_.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 { + let mut by_appid: std::collections::BTreeMap = 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, 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///` across every Steam root, for whichever +/// `` 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, 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///`, +/// 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 { + 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 { + 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 { + 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) -> Vec { + 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 { + let mut seen = HashSet::new(); + let mut dirs = Vec::new(); + let mut push = |steamapps: PathBuf, dirs: &mut Vec| { + 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" ""` 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 { + 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() +} + +/// `"" ""` on a single line → ``. 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::), 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")); + } +} diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs new file mode 100644 index 00000000..05a02fb7 --- /dev/null +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -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 { + xbox_games() + } +} + +/// Scan each fixed drive's default `:\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 { + 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 ``'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\` 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 { + 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 { + 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#" + + + + + + 9NBLGGH4R315 + +"#; + 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#" + + "#; + 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()); + } +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 944f8154..e71a24c1 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -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::().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 diff --git a/crates/punktfunk-host/src/metronome.rs b/crates/punktfunk-host/src/metronome.rs index 43a471a5..c51a5d46 100644 --- a/crates/punktfunk-host/src/metronome.rs +++ b/crates/punktfunk-host/src/metronome.rs @@ -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; diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 50f08e36..b35a4638 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -20,31 +20,28 @@ //! loopback. Restore the old loopback-only listener with `--mgmt-bind 127.0.0.1:47990`. The OpenAPI //! document and docs UI are served unauthenticated (the spec is public — it lives in this repo). -use crate::encode::Codec; -use crate::gamestream::{ - tls::{serve_https, PeerAddr, PeerCertFingerprint}, - AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT, -}; -use crate::log_capture::LogPage; -use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus}; +use crate::gamestream::{tls::serve_https, AppState}; use anyhow::{Context, Result}; -use axum::{ - extract::{Path, Query, Request, State}, - http::{header, Method, StatusCode}, - middleware::{self, Next}, - response::{IntoResponse, Response}, - routing::get, - Json, Router, -}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; +use axum::{middleware, routing::get, Json, Router}; use std::net::SocketAddr; -use std::sync::atomic::Ordering; use std::sync::Arc; -use utoipa::{Modify, OpenApi, ToSchema}; +use utoipa::{Modify, OpenApi}; use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_scalar::{Scalar, Servable}; +mod auth; +mod clients; +mod display; +mod gpu; +mod host; +mod library; +mod native; +mod session; +mod shared; +mod stats; +#[cfg(test)] +mod tests; + /// Default management port — adjacent to the GameStream block (47984…48010), and the same /// number Sunshine users already associate with "the config UI". pub const DEFAULT_PORT: u16 = 47990; @@ -68,7 +65,7 @@ impl Default for Options { } /// Axum state for the management routes: the shared control-plane state + auth config. -struct MgmtState { +pub(crate) struct MgmtState { app: Arc, /// Native (punktfunk/1) pairing — shared with the QUIC host when the unified `serve --native` /// runs it. `None` ⇒ GameStream-only host (the native endpoints report `enabled: false`). @@ -146,7 +143,10 @@ fn app( }); let (api_routes, api) = api_router_parts(); api_routes - .route_layer(middleware::from_fn_with_state(shared.clone(), require_auth)) + .route_layer(middleware::from_fn_with_state( + shared.clone(), + auth::require_auth, + )) .with_state(shared) .merge(Scalar::with_url("/api/docs", api.clone())) .route( @@ -165,45 +165,57 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .nest( "/api/v1", OpenApiRouter::new() - .routes(routes!(get_health)) - .routes(routes!(get_host_info)) - .routes(routes!(list_compositors)) - .routes(routes!(list_gpus)) - .routes(routes!(set_gpu_preference)) - .routes(routes!(get_display_settings)) - .routes(routes!(set_display_settings)) - .routes(routes!(get_display_state)) - .routes(routes!(release_display)) - .routes(routes!(set_display_layout)) - .routes(routes!(list_custom_presets, create_custom_preset)) - .routes(routes!(update_custom_preset, delete_custom_preset)) - .routes(routes!(get_status)) - .routes(routes!(get_local_summary)) - .routes(routes!(list_paired_clients)) - .routes(routes!(unpair_client)) - .routes(routes!(get_pairing_status)) - .routes(routes!(submit_pairing_pin)) - .routes(routes!(get_native_pairing)) - .routes(routes!(arm_native_pairing)) - .routes(routes!(disarm_native_pairing)) - .routes(routes!(list_native_clients)) - .routes(routes!(unpair_native_client)) - .routes(routes!(list_pending_devices)) - .routes(routes!(approve_pending_device)) - .routes(routes!(deny_pending_device)) - .routes(routes!(stop_session)) - .routes(routes!(request_idr)) - .routes(routes!(get_library)) - .routes(routes!(create_custom_game)) - .routes(routes!(update_custom_game, delete_custom_game)) - .routes(routes!(get_library_art)) - .routes(routes!(stats_capture_start)) - .routes(routes!(stats_capture_stop)) - .routes(routes!(stats_capture_status)) - .routes(routes!(stats_capture_live)) - .routes(routes!(stats_recordings_list)) - .routes(routes!(stats_recording_get, stats_recording_delete)) - .routes(routes!(logs_get)), + .routes(routes!(host::get_health)) + .routes(routes!(host::get_host_info)) + .routes(routes!(host::list_compositors)) + .routes(routes!(gpu::list_gpus)) + .routes(routes!(gpu::set_gpu_preference)) + .routes(routes!(display::get_display_settings)) + .routes(routes!(display::set_display_settings)) + .routes(routes!(display::get_display_state)) + .routes(routes!(display::release_display)) + .routes(routes!(display::set_display_layout)) + .routes(routes!( + display::list_custom_presets, + display::create_custom_preset + )) + .routes(routes!( + display::update_custom_preset, + display::delete_custom_preset + )) + .routes(routes!(host::get_status)) + .routes(routes!(host::get_local_summary)) + .routes(routes!(clients::list_paired_clients)) + .routes(routes!(clients::unpair_client)) + .routes(routes!(clients::get_pairing_status)) + .routes(routes!(clients::submit_pairing_pin)) + .routes(routes!(native::get_native_pairing)) + .routes(routes!(native::arm_native_pairing)) + .routes(routes!(native::disarm_native_pairing)) + .routes(routes!(native::list_native_clients)) + .routes(routes!(native::unpair_native_client)) + .routes(routes!(native::list_pending_devices)) + .routes(routes!(native::approve_pending_device)) + .routes(routes!(native::deny_pending_device)) + .routes(routes!(session::stop_session)) + .routes(routes!(session::request_idr)) + .routes(routes!(library::get_library)) + .routes(routes!(library::create_custom_game)) + .routes(routes!( + library::update_custom_game, + library::delete_custom_game + )) + .routes(routes!(library::get_library_art)) + .routes(routes!(stats::stats_capture_start)) + .routes(routes!(stats::stats_capture_stop)) + .routes(routes!(stats::stats_capture_status)) + .routes(routes!(stats::stats_capture_live)) + .routes(routes!(stats::stats_recordings_list)) + .routes(routes!( + stats::stats_recording_get, + stats::stats_recording_delete + )) + .routes(routes!(stats::logs_get)), ) .split_for_parts() } @@ -263,3109 +275,3 @@ impl Modify for SecurityAddon { )]); } } - -// --------------------------------------------------------------------------------------- -// Schemas -// --------------------------------------------------------------------------------------- - -/// Liveness + version probe. -#[derive(Serialize, ToSchema)] -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)] -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, - /// 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)] -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")] -enum ApiCodec { - H264, - #[serde(rename = "hevc")] - H265, - Av1, - /// PyroWave — the opt-in wired-LAN intra-only wavelet codec. - PyroWave, -} - -impl From 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)] -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, - /// 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, -} - -/// Client-requested launch parameters (key material is never exposed here). -#[derive(Serialize, ToSchema)] -struct SessionInfo { - width: u32, - height: u32, - fps: u32, -} - -/// RTSP-negotiated stream parameters. -#[derive(Serialize, ToSchema)] -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)] -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, - /// 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, -} - -/// A paired (certificate-pinned) Moonlight client. -#[derive(Serialize, ToSchema)] -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, - /// Certificate validity start (unix seconds). - not_before_unix: Option, - /// Certificate validity end (unix seconds). - not_after_unix: Option, -} - -/// Pairing-flow status. -#[derive(Serialize, ToSchema)] -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)] -struct SubmitPin { - /// 1–16 ASCII digits (Moonlight shows 4). - #[schema(example = "1234")] - pin: String, -} - -/// 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)] -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, - /// Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag). - expires_in_secs: Option, - /// Number of paired native clients. - paired_clients: u32, -} - -/// Arm-native-pairing request body. -#[derive(Deserialize, ToSchema)] -struct ArmNativePairing { - /// Window length in seconds (default 120; clamped to 15–600). - #[schema(example = 120)] - ttl_secs: Option, - /// 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, -} - -/// A paired native (punktfunk/1) client. -#[derive(Serialize, ToSchema)] -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)] -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)] -struct ApprovePending { - /// Operator-chosen label for the device (defaults to the name it knocked with). - #[schema(example = "Living Room TV")] - name: Option, -} - -/// Error envelope for every non-2xx response. -#[derive(Serialize, Deserialize, ToSchema)] -struct ApiError { - error: String, -} - -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. -struct ApiJson(T); - -impl axum::extract::FromRequest for ApiJson -where - Json: axum::extract::FromRequest, - S: Send + Sync, -{ - type Rejection = Response; - - async fn from_request(req: Request, state: &S) -> Result { - match Json::::from_request(req, state).await { - Ok(Json(value)) => Ok(ApiJson(value)), - Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())), - } - } -} - -// --------------------------------------------------------------------------------------- -// Auth -// --------------------------------------------------------------------------------------- - -/// 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. -async fn require_auth(State(st): State>, 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::() - .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::() { - 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::() - .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). -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. -fn token_eq(presented: &str, expected: &str) -> bool { - Sha256::digest(presented.as_bytes()) == Sha256::digest(expected.as_bytes()) -} - -// --------------------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------------------- - -/// 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)) -)] -async fn get_health() -> Json { - 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), - ) -)] -async fn get_host_info(State(st): State>) -> Json { - 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)] -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), - ) -)] -async fn list_compositors() -> Json> { - 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(), - ) -} - -/// One hardware GPU on the host (software/WARP adapters are never listed). -#[derive(Serialize, ToSchema)] -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)] -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)] -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)] -struct GpuState { - /// The host's hardware GPUs. - gpus: Vec, - /// `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, - /// The stored name of the preferred GPU (a usable label even when it is absent). - preferred_name: Option, - /// 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, - /// The GPU the next session will use. - selected: Option, - /// The GPU live sessions use right now (absent while nothing is streaming). - active: Option, -} - -/// Request body for `setGpuPreference`. -#[derive(Deserialize, ToSchema)] -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, -} - -/// Build the [`GpuState`] snapshot (shared by the GET and the PUT's response). -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), - ) -)] -async fn list_gpus() -> Json { - 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), - ) -)] -async fn set_gpu_preference(ApiJson(req): ApiJson) -> 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() -} - -// --------------------------------------------------------------------------------------- -// Display management (design/display-management.md) -// --------------------------------------------------------------------------------------- - -/// 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)] -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)] -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, - /// 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, - /// 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, -} - -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.", - _ => "", - } -} - -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), - ) -)] -async fn get_display_settings() -> Json { - 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), - ) -)] -async fn set_display_settings( - ApiJson(policy): ApiJson, -) -> 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)] -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, - /// Live sessions holding the display. - sessions: u32, - /// Short client label, when the owner tracks it. - client: Option, - /// 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, - /// Effective topology for this display's group (`extend` | `primary` | `exclusive`). - topology: String, -} - -/// The host's managed virtual displays right now. -#[derive(Serialize, ToSchema)] -struct DisplayStateResponse { - displays: Vec, -} - -/// Request body for `releaseDisplay`. -#[derive(Deserialize, ToSchema)] -struct ReleaseDisplayRequest { - /// Slot to release (see `state`); omit to release **all** kept displays. - #[serde(default)] - slot: Option, -} - -/// Result of a `/display/release`. -#[derive(Serialize, ToSchema)] -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), - ) -)] -async fn get_display_state() -> Json { - 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), - ) -)] -async fn release_display( - ApiJson(req): ApiJson, -) -> Json { - 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)] -struct DisplayLayoutRequest { - /// `{"": {"x": …, "y": …}}` — where each arranged display's top-left sits. - #[serde(default)] - positions: std::collections::BTreeMap, -} - -/// 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), - ) -)] -async fn set_display_layout(ApiJson(req): ApiJson) -> 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), - (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), - ) -)] -async fn list_custom_presets() -> Json> { - 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), - ) -)] -async fn create_custom_preset( - ApiJson(input): ApiJson, -) -> 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), - ) -)] -async fn update_custom_preset( - Path(id): Path, - ApiJson(input): ApiJson, -) -> 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), - ) -)] -async fn delete_custom_preset(Path(id): Path) -> 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()), - } -} - -/// 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), - ) -)] -async fn get_status(State(st): State>) -> Json { - // 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), - ) -)] -async fn get_local_summary(State(st): State>) -> Json { - // 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()), - }) -} - -/// 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), - ) -)] -async fn list_paired_clients(State(st): State>) -> Json> { - let ders = st.app.paired.lock().unwrap().clone(); - Json(ders.iter().map(|der| client_info(der)).collect()) -} - -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), - ) -)] -async fn unpair_client( - State(st): State>, - Path(fingerprint): Path, -) -> 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), - ) -)] -async fn get_pairing_status(State(st): State>) -> Json { - 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), - ) -)] -async fn submit_pairing_pin( - State(st): State>, - ApiJson(req): ApiJson, -) -> 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() -} - -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), - ) -)] -async fn get_native_pairing(State(st): State>) -> Json { - 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), - ) -)] -async fn arm_native_pairing( - State(st): State>, - ApiJson(req): ApiJson, -) -> 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), - ) -)] -async fn disarm_native_pairing(State(st): State>) -> 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), - ) -)] -async fn list_native_clients(State(st): State>) -> Json> { - 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), - ) -)] -async fn unpair_native_client( - State(st): State>, - Path(fingerprint): Path, -) -> 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), - (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), - ) -)] -async fn list_pending_devices(State(st): State>) -> Json> { - 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), - ) -)] -async fn approve_pending_device( - State(st): State>, - Path(id): Path, - ApiJson(req): ApiJson, -) -> 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), - ) -)] -async fn deny_pending_device(State(st): State>, Path(id): Path) -> 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") - } -} - -/// 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), - ) -)] -async fn stop_session(State(st): State>) -> 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), - ) -)] -async fn request_idr(State(st): State>) -> 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() -} - -// --------------------------------------------------------------------------------------- -// Library -// --------------------------------------------------------------------------------------- - -/// 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), - ) -)] -async fn get_library() -> Json> { - 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), - ) -)] -async fn create_custom_game(ApiJson(input): ApiJson) -> 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), - ) -)] -async fn update_custom_game( - Path(id): Path, - ApiJson(input): ApiJson, -) -> 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), - ) -)] -async fn delete_custom_game(Path(id): Path) -> 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), - ) -)] -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::().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"), - } -} - -// --------------------------------------------------------------------------------------- -// Streaming stats capture (design/stats-capture-plan.md §2) -// --------------------------------------------------------------------------------------- - -/// 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 -/// 1–2 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), - ) -)] -async fn stats_capture_start(State(st): State>) -> Json { - 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), - ) -)] -async fn stats_capture_stop(State(st): State>) -> 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), - ) -)] -async fn stats_capture_status(State(st): State>) -> Json { - 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), - ) -)] -async fn stats_capture_live(State(st): State>) -> 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), - ) -)] -async fn stats_recordings_list(State(st): State>) -> Json> { - 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), - ) -)] -async fn stats_recording_get(State(st): State>, Path(id): Path) -> 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), - ) -)] -async fn stats_recording_delete( - State(st): State>, - Path(id): Path, -) -> 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)] -struct LogsQuery { - after: Option, - limit: Option, -} - -/// 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, Query, description = "Return entries with seq greater than this (omitted/0 = oldest retained)"), - ("limit" = Option, 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), - ) -)] -async fn logs_get(Query(q): Query) -> Json { - 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)) -} - -// --------------------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::gamestream::{cert::ServerIdentity, Host, LaunchSession, HTTPS_PORT, HTTP_PORT}; - use axum::body::Body; - use http_body_util::BodyExt; - use std::net::{IpAddr, Ipv4Addr}; - 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::new(std::env::temp_dir().join(format!( - "pf-mgmt-stats-{}-{:p}", - std::process::id(), - &0u8 as *const u8 - ))) - } - - fn test_state() -> Arc { - 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, 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, - np: Arc, - ) -> 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, - ) -> (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 { - 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, 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""), - "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!(""); - snapshot["info"]["version"] = serde_json::json!(""); - 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 { - 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 { - 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); - } -} diff --git a/crates/punktfunk-host/src/mgmt/auth.rs b/crates/punktfunk-host/src/mgmt/auth.rs new file mode 100644 index 00000000..0f1d4ab5 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/auth.rs @@ -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>, + 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::() + .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::() { + 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::() + .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()) +} diff --git a/crates/punktfunk-host/src/mgmt/clients.rs b/crates/punktfunk-host/src/mgmt/clients.rs new file mode 100644 index 00000000..a94cddf4 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/clients.rs @@ -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, + /// Certificate validity start (unix seconds). + not_before_unix: Option, + /// Certificate validity end (unix seconds). + not_after_unix: Option, +} + +/// 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 { + /// 1–16 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>, +) -> Json> { + 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>, + Path(fingerprint): Path, +) -> 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>) -> Json { + 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>, + ApiJson(req): ApiJson, +) -> 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() +} diff --git a/crates/punktfunk-host/src/mgmt/display.rs b/crates/punktfunk-host/src/mgmt/display.rs new file mode 100644 index 00000000..383db1d8 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/display.rs @@ -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, + /// 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, + /// 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, +} + +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 { + 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, +) -> 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, + /// Live sessions holding the display. + sessions: u32, + /// Short client label, when the owner tracks it. + client: Option, + /// 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, + /// 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, +} + +/// 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, +} + +/// 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 { + 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, +) -> Json { + 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 { + /// `{"": {"x": …, "y": …}}` — where each arranged display's top-left sits. + #[serde(default)] + positions: std::collections::BTreeMap, +} + +/// 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) -> 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), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn list_custom_presets() -> Json> { + 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, +) -> 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, + ApiJson(input): ApiJson, +) -> 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) -> 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()), + } +} diff --git a/crates/punktfunk-host/src/mgmt/gpu.rs b/crates/punktfunk-host/src/mgmt/gpu.rs new file mode 100644 index 00000000..6fc920f1 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/gpu.rs @@ -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, + /// `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, + /// The stored name of the preferred GPU (a usable label even when it is absent). + preferred_name: Option, + /// 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, + /// The GPU the next session will use. + selected: Option, + /// The GPU live sessions use right now (absent while nothing is streaming). + active: Option, +} + +/// 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, +} + +/// 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 { + 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) -> 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() +} diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs new file mode 100644 index 00000000..301f6f43 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -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, + /// 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 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, + /// 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, +} + +/// 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, + /// 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, +} + +/// 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 { + 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>) -> Json { + 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> { + 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>) -> Json { + // 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>) -> Json { + // 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()), + }) +} diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs new file mode 100644 index 00000000..b00622aa --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -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> { + 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, +) -> 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, + ApiJson(input): ApiJson, +) -> 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) -> 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::().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"), + } +} diff --git a/crates/punktfunk-host/src/mgmt/native.rs b/crates/punktfunk-host/src/mgmt/native.rs new file mode 100644 index 00000000..e7511993 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/native.rs @@ -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, + /// Seconds left in the window (null = disarmed, or armed with no expiry via the CLI flag). + expires_in_secs: Option, + /// 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 15–600). + #[schema(example = 120)] + ttl_secs: Option, + /// 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, +} + +/// 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, +} + +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>) -> Json { + 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>, + ApiJson(req): ApiJson, +) -> 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>) -> 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>, +) -> Json> { + 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>, + Path(fingerprint): Path, +) -> 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), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn list_pending_devices( + State(st): State>, +) -> Json> { + 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>, + Path(id): Path, + ApiJson(req): ApiJson, +) -> 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>, + Path(id): Path, +) -> 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") + } +} diff --git a/crates/punktfunk-host/src/mgmt/session.rs b/crates/punktfunk-host/src/mgmt/session.rs new file mode 100644 index 00000000..a5f56719 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/session.rs @@ -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>) -> 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>) -> 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() +} diff --git a/crates/punktfunk-host/src/mgmt/shared.rs b/crates/punktfunk-host/src/mgmt/shared.rs new file mode 100644 index 00000000..822d652f --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/shared.rs @@ -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(pub(crate) T); + +impl axum::extract::FromRequest for ApiJson +where + Json: axum::extract::FromRequest, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request(req: Request, state: &S) -> Result { + match Json::::from_request(req, state).await { + Ok(Json(value)) => Ok(ApiJson(value)), + Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())), + } + } +} diff --git a/crates/punktfunk-host/src/mgmt/stats.rs b/crates/punktfunk-host/src/mgmt/stats.rs new file mode 100644 index 00000000..6b7c8c87 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/stats.rs @@ -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 +/// 1–2 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>) -> Json { + 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>) -> 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>) -> Json { + 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>) -> 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>, +) -> Json> { + 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>, + Path(id): Path, +) -> 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>, + Path(id): Path, +) -> 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, + limit: Option, +} + +/// 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, Query, description = "Return entries with seq greater than this (omitted/0 = oldest retained)"), + ("limit" = Option, 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) -> Json { + 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)) +} diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs new file mode 100644 index 00000000..0aa6a920 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -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::new(std::env::temp_dir().join(format!( + "pf-mgmt-stats-{}-{:p}", + std::process::id(), + &0u8 as *const u8 + ))) +} + +fn test_state() -> Arc { + 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, 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, np: Arc) -> 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) -> (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 { + 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, 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""), + "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!(""); + snapshot["info"]["version"] = serde_json::json!(""); + 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 { + 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 { + 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); +} diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/native.rs similarity index 67% rename from crates/punktfunk-host/src/punktfunk1.rs rename to crates/punktfunk-host/src/native.rs index b6d7e51b..f0d48d9e 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/native.rs @@ -32,9 +32,9 @@ use punktfunk_core::config::{ use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::packet::{FLAG_PIC, FLAG_PROBE, FLAG_SOF}; use punktfunk_core::quic::{ - endpoint, io, BitrateChanged, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport, - PairChallenge, PairProof, PairRequest, PairResult, ProbeRequest, ProbeResult, Reconfigure, - Reconfigured, RequestKeyframe, RfiRequest, SetBitrate, Start, Welcome, + endpoint, io, BitrateChanged, ClockEcho, ClockProbe, ColorInfo, Hello, LossReport, PairRequest, + ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RfiRequest, SetBitrate, + Start, Welcome, }; use punktfunk_core::transport::UdpTransport; use punktfunk_core::Session; @@ -42,6 +42,38 @@ use rand::RngCore; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering}; use std::sync::Arc; +/// Per-thread OS scheduling QoS lives in its own module (plan §W1); re-exported so +/// `crate::native::boost_thread_priority` stays stable (the GameStream path and the direct-NVENC +/// send thread reach it there). +mod thread_qos; +pub(crate) use thread_qos::boost_thread_priority; + +/// Compositor-preference resolution (plan §W1); `serve_session` reaches `resolve_compositor` here. +mod compositor; +use compositor::resolve_compositor; + +/// Virtual-gamepad backend resolution (plan §W1); `serve_session` + the `Pads` state machine reach +/// `resolve_gamepad`/`resolve_pad_kind`/`route_decision` here. +mod gamepad; +use gamepad::{resolve_gamepad, resolve_pad_kind, route_decision}; + +/// The SPAKE2 pairing ceremony (plan §W1); `serve_session` dispatches a PairRequest connection here. +mod pairing; +use pairing::pair_ceremony; + +/// The native audio plane (plan §W1); the session setup spawns `audio_thread` here. +mod audio; +use audio::audio_thread; + +/// The native input plane (plan §W1); the session setup spawns `input_thread` and feeds it a +/// channel of `ClientInput`. The `Pads` router + rumble live there too. +mod input; +use input::{input_thread, ClientInput}; + +/// The Hello→Welcome→Start negotiation (plan §W1); `serve_session` calls `handshake::negotiate` +/// after the pairing gate. +mod handshake; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Punktfunk1Source { /// Deterministic test frames (protocol verification; the client byte-checks them). @@ -542,10 +574,6 @@ fn apply_fec_target(session: &mut Session, fec_target: &AtomicU8) { /// and a daemon-side node churn — per session. (Drop now tears a capturer down cleanly.) type AudioCapSlot = Arc>>>; -/// 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); - /// How long the host keeps an unpaired knock PARKED — connection held open — waiting for the /// operator to click Approve in the console (delegated approval, roadmap §8b-1). The QUIC /// keep-alive (4 s, under the 8 s idle timeout) holds the path warm meanwhile, so on approval the @@ -554,81 +582,6 @@ const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); /// connection (the host stops waiting at once). const PENDING_APPROVAL_WAIT: std::time::Duration = std::time::Duration::from_secs(180); -/// 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. -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(()) -} - /// One client session: handshake → input/audio planes → data plane until done/disconnect. /// Everything torn down on return (RAII: virtual output, encoder, threads via channel close). /// A connection whose first message is a PairRequest runs the pairing ceremony instead. @@ -815,351 +768,14 @@ async fn serve_session( let source = opts.source; let frames = opts.frames; let data_port = opts.data_port; - let handshake = async { - 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 ") - // 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: HEVC Main10 only when the client advertised it AND the host - // opted in (PUNKTFUNK_10BIT). A client that can't decode 10-bit (caps bit clear, or an older - // client) always gets the 8-bit stream. PUNKTFUNK_10BIT is the host policy gate until a - // mgmt/console toggle replaces it. 10-bit is HEVC-only (like the 4:4:4 gate below): now that - // the client can steer the codec to H.264/AV1, a non-HEVC session must stay 8-bit — the - // encoders' 10-bit path is Main10, and AV1 10-bit stays off until live-confirmed (the same - // stance as the GameStream Main10 advertisement). - let host_wants_10bit = crate::config::config().ten_bit; - let client_supports_10bit = hello.video_caps & punktfunk_core::quic::VIDEO_CAP_10BIT != 0; - let bit_depth: u8 = - if host_wants_10bit && client_supports_10bit && codec == crate::encode::Codec::H265 { - 10 - } else { - 8 - }; - tracing::info!( - bit_depth, - host_wants_10bit, - client_supports_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(&mut send, &welcome.encode()).await?; - - let start = Start::decode(&io::read_msg(&mut recv).await?) - .map_err(|e| anyhow!("Start decode: {e:?}"))?; - Ok::<_, anyhow::Error>(( - hello, welcome, udp_port, data_sock, direct, start, compositor, - )) - }; - let (hello, welcome, udp_port, data_sock, direct, start, compositor) = - tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake) - .await - .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; + let (hello, welcome, udp_port, data_sock, direct, start, compositor) = tokio::time::timeout( + HANDSHAKE_TIMEOUT, + handshake::negotiate( + &conn, &mut send, &mut recv, &first, source, frames, data_port, + ), + ) + .await + .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; let (mut ctrl_send, mut ctrl_recv) = (send, recv); // Can this session's backend live-reconfigure (mid-stream Reconfigure)? Gated OFF for: // * gamescope (all sub-modes): a spawn respawn restarts the game, managed restarts the box's @@ -1473,6 +1089,17 @@ async fn serve_session( // client closed the connection with `QUIT_CODE` — a user "stop", which skips the keep-alive linger. // A bare disconnect / idle timeout leaves it false → the display lingers for a reconnect. let quit = Arc::new(AtomicBool::new(false)); + // Lifecycle events (RFC §4): this point — handshake complete, pairing/admission passed — is + // where the client counts as CONNECTED; the close watcher below pairs it with the + // disconnect + its decoded reason. A client rejected earlier never emits either. + let event_client = crate::events::ClientRef { + name: hello.name.clone().unwrap_or_default(), + fingerprint: endpoint::peer_fingerprint(&conn).map(|fp| fingerprint_hex(&fp)), + plane: crate::events::Plane::Native, + }; + crate::events::emit(crate::events::EventKind::ClientConnected { + client: event_client.clone(), + }); { let stop = stop.clone(); let quit = quit.clone(); @@ -1485,6 +1112,19 @@ async fn serve_session( quit.store(true, Ordering::SeqCst); } stop.store(true, Ordering::SeqCst); + let why = match &reason { + quinn::ConnectionError::ApplicationClosed(ac) + if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE) => + { + crate::events::DisconnectReason::Quit + } + quinn::ConnectionError::TimedOut => crate::events::DisconnectReason::Timeout, + _ => crate::events::DisconnectReason::Error, + }; + crate::events::emit(crate::events::EventKind::ClientDisconnected { + client: event_client, + reason: why, + }); }); } @@ -1596,6 +1236,7 @@ async fn serve_session( refresh_hz: mode.refresh_hz, hdr: welcome.color.is_hdr(), client: hello.name.clone().unwrap_or_default(), + launch: hello.launch.clone(), }); // The session's launch, threaded into the data plane. Windows carries the store-qualified id // (spawned into the interactive user session once capture is live); other hosts resolve the id @@ -1771,1124 +1412,11 @@ async fn serve_session( result } -/// 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; - /// Backoff between reopen attempts after a host-lifetime service's backend (a capturer) fails /// to open or its worker dies, so a persistently-unavailable resource isn't hammered. (The /// virtual mic has its own tuning — see [`crate::audio::MicPump`].) const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2); -/// 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; MAX_WIRE_PADS], - xbox360: Option, - #[cfg(target_os = "linux")] - xboxone: Option, - #[cfg(target_os = "linux")] - dualsense: Option, - #[cfg(target_os = "linux")] - dualsense_edge: Option, - #[cfg(target_os = "linux")] - dualshock4: Option, - #[cfg(target_os = "linux")] - steamdeck: Option, - #[cfg(target_os = "linux")] - switchpro: Option, - #[cfg(target_os = "linux")] - steamctrl: Option, - #[cfg(target_os = "linux")] - steamctrl2: Option, - #[cfg(target_os = "linux")] - steamctrl2_puck: Option, - #[cfg(target_os = "windows")] - dualsense_win: Option, - #[cfg(target_os = "windows")] - dualsense_edge_win: Option, - #[cfg(target_os = "windows")] - dualshock4_win: Option, - #[cfg(target_os = "windows")] - steamdeck_win: Option, -} - -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); - } - } - } -} - -/// 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. -fn route_decision( - owner: Option, - declared: GamepadPref, - present: bool, -) -> (GamepadPref, Option) { - 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). -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)) -} - -/// 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). -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 2–3 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 = 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). -fn input_thread( - rx: std::sync::mpsc::Receiver, - conn: quinn::Connection, - inj_tx: std::sync::mpsc::Sender, - 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 = Vec::new(); - let mut last_motion: Option = 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; 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::().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 2–3 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 = std::collections::HashSet::new(); - let mut held_keys: std::collections::HashSet = 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, - }); - } -} - -/// 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 { - 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 { - 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"))] -fn audio_thread( - conn: quinn::Connection, - stop: Arc, - 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 = 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 = 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 = 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")))] -fn audio_thread( - _conn: quinn::Connection, - _stop: Arc, - _audio_cap: AudioCapSlot, - _channels: u8, -) { - tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it"); -} - /// Advance the intra-refresh wave position and decide whether this emitted AU is a wave boundary /// that should carry [`USER_FLAG_RECOVERY_POINT`](punktfunk_core::packet::USER_FLAG_RECOVERY_POINT). /// @@ -2951,336 +1479,6 @@ fn synthetic_stream( Ok(()) } -/// 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. -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 -} - -/// 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, -) -> Option { - 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`). -fn resolve_compositor( - pref: CompositorPref, - dedicated_launch: bool, -) -> Result { - 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) - } -} - /// Bounds a speed-test [`ProbeRequest`] before bursting: a 3 Gbps / 5 s ceiling keeps a probe from /// monopolizing the link or stalling the stream for too long. The ceiling is set ABOVE the session /// bitrate cap ([`MAX_BITRATE_KBPS`], 2 Gbps) on purpose — a probe should be able to demonstrate @@ -3508,72 +1706,6 @@ struct FrameMsg { /// speed-test probe bursts (which also need the Session). Decoupling the paced send from encoding /// lets the encode of frame N+1 overlap the transmit of frame N instead of waiting behind its tail. /// Runs until the encode thread drops the frame channel (end of stream) or `stop` is set. -/// 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; - } -} - /// Everything the send thread needs to emit web-console stats samples at its 2 s aggregation /// boundary: the shared recorder (whose `is_armed()` gates emission) plus the negotiated /// mode/codec/client to seed the capture's `CaptureMeta` on the first armed registration. @@ -4237,7 +2369,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { rec: stats.clone(), mode: live_mode.clone(), codec: plan.codec.label(), - client: client_label, + client: client_label.clone(), bitrate_kbps: live_bitrate.clone(), }; let send_thread = std::thread::Builder::new() @@ -4272,6 +2404,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { plan.codec, stop.clone(), force_idr.clone(), + client_label, + plan.hdr, ); // Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit @@ -5402,34 +3536,6 @@ fn build_pipeline( mod tests { use super::*; - #[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 live_mode_pack_roundtrips_and_interval_recovers_hz() { // The live-stats mode slot (H3): pack → unpack is exact for real modes. @@ -5525,65 +3631,6 @@ mod tests { assert!(mark_recovery_boundary(&mut pos, false, period)); // pos 4 → mark } - #[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 = 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); - } - #[test] fn pyrowave_bitrate_pins_to_bpp_default() { use punktfunk_core::config::Mode; @@ -5659,160 +3706,6 @@ mod tests { ); } - #[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) - ); - } - - #[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 - ); - } - #[test] fn permanent_errors_short_circuit_retry() { // Permanent: config / version / missing-tool — retrying within a session can't fix these. @@ -5836,39 +3729,6 @@ mod tests { assert!(!is_permanent_build_error("open NVENC: device busy")); } - 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))); - } - /// Freeze the gamepad wire contract: every button bit + axis id pinned to its exact value, read /// through the GameStream namespace (`crate::gamestream::gamepad`, which re-exports /// `punktfunk_core::input::gamepad` — the punktfunk/1 native wire and the GameStream/Limelight diff --git a/crates/punktfunk-host/src/native/audio.rs b/crates/punktfunk-host/src/native/audio.rs new file mode 100644 index 00000000..5c894599 --- /dev/null +++ b/crates/punktfunk-host/src/native/audio.rs @@ -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 { + 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 { + 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, + 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 = 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 = 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 = 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, + _audio_cap: AudioCapSlot, + _channels: u8, +) { + tracing::warn!("punktfunk/1 audio requires Linux or Windows — session continues without it"); +} diff --git a/crates/punktfunk-host/src/native/compositor.rs b/crates/punktfunk-host/src/native/compositor.rs new file mode 100644 index 00000000..885a1043 --- /dev/null +++ b/crates/punktfunk-host/src/native/compositor.rs @@ -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, +) -> Option { + 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 { + 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) + ); + } +} diff --git a/crates/punktfunk-host/src/native/gamepad.rs b/crates/punktfunk-host/src/native/gamepad.rs new file mode 100644 index 00000000..bd9f6e89 --- /dev/null +++ b/crates/punktfunk-host/src/native/gamepad.rs @@ -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, + declared: GamepadPref, + present: bool, +) -> (GamepadPref, Option) { + 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 + ); + } +} diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs new file mode 100644 index 00000000..823643f1 --- /dev/null +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -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, +) -> Result<( + Hello, + Welcome, + u16, + std::net::UdpSocket, + bool, + Start, + Option, +)> { + 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 ") + // 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, + )) +} diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs new file mode 100644 index 00000000..c3d1f38f --- /dev/null +++ b/crates/punktfunk-host/src/native/input.rs @@ -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; MAX_WIRE_PADS], + xbox360: Option, + #[cfg(target_os = "linux")] + xboxone: Option, + #[cfg(target_os = "linux")] + dualsense: Option, + #[cfg(target_os = "linux")] + dualsense_edge: Option, + #[cfg(target_os = "linux")] + dualshock4: Option, + #[cfg(target_os = "linux")] + steamdeck: Option, + #[cfg(target_os = "linux")] + switchpro: Option, + #[cfg(target_os = "linux")] + steamctrl: Option, + #[cfg(target_os = "linux")] + steamctrl2: Option, + #[cfg(target_os = "linux")] + steamctrl2_puck: Option, + #[cfg(target_os = "windows")] + dualsense_win: Option, + #[cfg(target_os = "windows")] + dualsense_edge_win: Option, + #[cfg(target_os = "windows")] + dualshock4_win: Option, + #[cfg(target_os = "windows")] + steamdeck_win: Option, +} + +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 2–3 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 = 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, + conn: quinn::Connection, + inj_tx: std::sync::mpsc::Sender, + 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 = Vec::new(); + let mut last_motion: Option = 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; 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::().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 2–3 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 = std::collections::HashSet::new(); + let mut held_keys: std::collections::HashSet = 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 = 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))); + } +} diff --git a/crates/punktfunk-host/src/native/pairing.rs b/crates/punktfunk-host/src/native/pairing.rs new file mode 100644 index 00000000..34d8f107 --- /dev/null +++ b/crates/punktfunk-host/src/native/pairing.rs @@ -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(()) +} diff --git a/crates/punktfunk-host/src/native/thread_qos.rs b/crates/punktfunk-host/src/native/thread_qos.rs new file mode 100644 index 00000000..b4cf0d1c --- /dev/null +++ b/crates/punktfunk-host/src/native/thread_qos.rs @@ -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; + } +} diff --git a/crates/punktfunk-host/src/native_pairing.rs b/crates/punktfunk-host/src/native_pairing.rs index e20525e2..8dc932bf 100644 --- a/crates/punktfunk-host/src/native_pairing.rs +++ b/crates/punktfunk-host/src/native_pairing.rs @@ -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, -} +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, - expires_at: Option, - bound_fp: Option, -} - -/// 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, - /// 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, - /// 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, - paired: Mutex, - pending: Mutex, - /// 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 { - // `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, arm_at_start: bool, ) -> Result { - 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 { - 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 { - 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 { - 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 { - 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) -> Option { - 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) -> 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 { - 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 { - 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 { - 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> { - // 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 { diff --git a/crates/punktfunk-host/src/native_pairing/approval.rs b/crates/punktfunk-host/src/native_pairing/approval.rs new file mode 100644 index 00000000..4fecb88b --- /dev/null +++ b/crates/punktfunk-host/src/native_pairing/approval.rs @@ -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, + /// 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, + /// 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, + /// 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) -> Option { + 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) -> 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 { + 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 { + 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 { + 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, + } + } + } +} diff --git a/crates/punktfunk-host/src/native_pairing/arming.rs b/crates/punktfunk-host/src/native_pairing/arming.rs new file mode 100644 index 00000000..8230bc9a --- /dev/null +++ b/crates/punktfunk-host/src/native_pairing/arming.rs @@ -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, + expires_at: Option, + bound_fp: Option, +} + +/// 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, Option); + +/// The arming-PIN window behind a [`Mutex`]. +pub(super) struct ArmState { + arm: Mutex, +} + +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) -> 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 { + 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 { + 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) + } +} diff --git a/crates/punktfunk-host/src/native_pairing/store.rs b/crates/punktfunk-host/src/native_pairing/store.rs new file mode 100644 index 00000000..9505a525 --- /dev/null +++ b/crates/punktfunk-host/src/native_pairing/store.rs @@ -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, +} + +#[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 { + // `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, +} + +impl TrustStore { + /// Open (load) the trust store. `store_path = None` uses the default config path. + pub(super) fn open(store_path: Option) -> Result { + 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 { + 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 { + 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 + } +} diff --git a/crates/punktfunk-host/src/send_pacing.rs b/crates/punktfunk-host/src/send_pacing.rs index a740bb45..78b245ce 100644 --- a/crates/punktfunk-host/src/send_pacing.rs +++ b/crates/punktfunk-host/src/send_pacing.rs @@ -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), diff --git a/crates/punktfunk-host/src/session_plan.rs b/crates/punktfunk-host/src/session_plan.rs index 0144b4a0..cf92f495 100644 --- a/crates/punktfunk-host/src/session_plan.rs +++ b/crates/punktfunk-host/src/session_plan.rs @@ -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. diff --git a/crates/punktfunk-host/src/session_status.rs b/crates/punktfunk-host/src/session_status.rs index f7c6b5cd..01f24327 100644 --- a/crates/punktfunk-host/src/session_status.rs +++ b/crates/punktfunk-host/src/session_status.rs @@ -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, /// Live encoder target (kbps); updated on an adaptive-bitrate change. bitrate_kbps: Arc, @@ -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, + /// 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, bitrate_kbps: Arc, codec: Codec, stop: Arc, force_idr: Arc, + 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 { .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, diff --git a/crates/punktfunk-host/src/session_tuning.rs b/crates/punktfunk-host/src/session_tuning.rs index fcc04bae..c23f0820 100644 --- a/crates/punktfunk-host/src/session_tuning.rs +++ b/crates/punktfunk-host/src/session_tuning.rs @@ -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() {} diff --git a/crates/punktfunk-host/src/stats_recorder.rs b/crates/punktfunk-host/src/stats_recorder.rs index faeccdab..c8b6aa2c 100644 --- a/crates/punktfunk-host/src/stats_recorder.rs +++ b/crates/punktfunk-host/src/stats_recorder.rs @@ -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. //! diff --git a/crates/punktfunk-host/src/stream_marker.rs b/crates/punktfunk-host/src/stream_marker.rs index 26908851..2d5670ba 100644 --- a/crates/punktfunk-host/src/stream_marker.rs +++ b/crates/punktfunk-host/src/stream_marker.rs @@ -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, +} + +/// 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(), ®); - 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(), ®); + { + let mut reg = REGISTRY.lock().unwrap(); + reg.remove(self.id); + rewrite_to(&marker_path(), ®); + } + 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, ®); 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, ®); let text = std::fs::read_to_string(&path).unwrap(); diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index da45dc2a..f242454f 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -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, } } diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index d2232aa7..6de4aa00 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -89,18 +89,24 @@ pub fn acquire( mode: super::Mode, quit: std::sync::Arc, ) -> Result { + 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) -> 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 diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index f2a786e7..9772f7d9 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -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