diff --git a/crates/punktfunk-core/src/packet.rs b/crates/punktfunk-core/src/packet.rs index 3d02e158..78cd1de2 100644 --- a/crates/punktfunk-core/src/packet.rs +++ b/crates/punktfunk-core/src/packet.rs @@ -296,8 +296,9 @@ impl Reassembler { stats: &StatsCounters, ) -> Result> { // On a lossy datagram link a malformed or non-video packet is dropped, never - // fatal: it must not abort `poll_frame`. Only a genuine FEC reconstruction - // failure propagates as an error. + // fatal: it must not abort `poll_frame`. A FEC reconstruction failure (corrupt or + // incompatible shards that passed the header checks) likewise drops the block rather + // than killing the whole session — the stream recovers at the next keyframe/RFI. if pkt.len() < HEADER_LEN { StatsCounters::add(&stats.packets_dropped, 1); return Ok(None); @@ -407,8 +408,22 @@ impl Reassembler { .iter() .filter(|s| s.is_some()) .count(); - let recovered = - coder.reconstruct(block.data_shards, block.recovery_shards, &mut block.shards)?; + let recovered = match coder.reconstruct( + block.data_shards, + block.recovery_shards, + &mut block.shards, + ) { + Ok(r) => r, + Err(_) => { + // Corrupt/incompatible shards that slipped past the header checks: discard this + // block (mark done so later shards for it are ignored) and keep the session + // alive — a lossy link must not be torn down by one unrecoverable block; the + // frame stays incomplete and the client recovers at the next keyframe/RFI. + block.done = true; + StatsCounters::add(&stats.packets_dropped, 1); + return Ok(None); + } + }; block.done = true; StatsCounters::add( &stats.fec_recovered_shards, diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index fa9dc1b8..2be5f9a3 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -1490,6 +1490,12 @@ pub mod endpoint { server_from_der(cert_der, key_der, addr) } + /// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the + /// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value; + /// a host with ALPN configured rejects a client that offers none, so client + host must be updated + /// together (acceptable while the protocol/ABI is still evolving). + const QUIC_ALPN: &[u8] = b"pkf1"; + fn server_from_der( cert_der: rustls::pki_types::CertificateDer<'static>, key_der: rustls::pki_types::PrivateKeyDer<'static>, @@ -1500,10 +1506,11 @@ pub mod endpoint { // identity is fingerprinted post-handshake (pairing / --require-pairing checks); // one that presents none still connects (and is rejected at the app layer when // pairing is required). - let rustls_cfg = rustls::ServerConfig::builder() + let mut rustls_cfg = rustls::ServerConfig::builder() .with_client_cert_verifier(Arc::new(AcceptAnyClientCert)) .with_single_cert(vec![cert_der], key_der) .map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?; + rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()]; let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg) .map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?; let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg)); @@ -1580,7 +1587,7 @@ pub mod endpoint { pin, observed: observed.clone(), })); - let rustls_cfg = match identity { + let mut rustls_cfg = match identity { None => builder.with_no_client_auth(), Some((cert_pem, key_pem)) => { use rustls::pki_types::pem::PemObject; @@ -1596,6 +1603,8 @@ pub mod endpoint { .map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))? } }; + // Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected. + rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()]; let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg) .map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?; let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg)); diff --git a/crates/punktfunk-host/src/gamestream/control.rs b/crates/punktfunk-host/src/gamestream/control.rs index 8b4fa7a2..23b3962b 100644 --- a/crates/punktfunk-host/src/gamestream/control.rs +++ b/crates/punktfunk-host/src/gamestream/control.rs @@ -103,6 +103,18 @@ pub fn spawn(state: Arc) -> Result<()> { } // Service the pads' force-feedback protocol every tick (games block inside // EVIOCSFF until answered) and relay mixed rumble levels to the client. + // + // SECURITY NOTE (audit #5, legacy GCM nonce reuse): on the LEGACY control scheme + // (`NonceKind::Legacy*`, which we hit because we advertise no encryption) the nonce is + // just the per-direction `seq` (`iv[0]=seq&0xff`, rest zero) with NO direction byte — + // so host rumble (this `rumble_seq`) and client input (its own seq) share the same + // (key, nonce) space when their seqs collide. This is INHERENT to Nvidia's old-style + // GameStream control encryption (Apollo/moonlight-common-c are identical: only the V2 + // scheme adds `iv[10..12] = 'H','C'` to separate the host direction). It can't be fixed + // on the legacy wire without breaking Moonlight; the GCM key is the client-supplied + // `rikey` (so only a passive eavesdropper who missed the HTTPS /launch is the + // adversary). The real fix is V2 control-encryption negotiation; for untrusted networks + // use the native punktfunk/1 plane (correct per-direction nonces + seq-as-AAD). if let (Some(pid), Some(scheme)) = (peer, detected) { let key = state.launch.lock().unwrap().map(|s| s.gcm_key); if let Some(key) = key { diff --git a/crates/punktfunk-host/src/gamestream/crypto.rs b/crates/punktfunk-host/src/gamestream/crypto.rs index 6f91c014..437f7512 100644 --- a/crates/punktfunk-host/src/gamestream/crypto.rs +++ b/crates/punktfunk-host/src/gamestream/crypto.rs @@ -25,6 +25,12 @@ pub fn sha256(parts: &[&[u8]]) -> [u8; 32] { h.finalize().into() } +/// Constant-time byte-slice equality — no early exit, so a timing side-channel can't probe the +/// expected value byte-by-byte. Returns false on a length mismatch (the length isn't secret here). +pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + /// The PIN-derived AES-128 key: `SHA-256(salt || pin)[..16]` (salt first, PIN as ASCII). pub fn pin_key(salt: &[u8; 16], pin: &str) -> [u8; 16] { let d = sha256(&[salt, pin.as_bytes()]); diff --git a/crates/punktfunk-host/src/gamestream/pairing.rs b/crates/punktfunk-host/src/gamestream/pairing.rs index 29f0fb96..3ad0ecc6 100644 --- a/crates/punktfunk-host/src/gamestream/pairing.rs +++ b/crates/punktfunk-host/src/gamestream/pairing.rs @@ -224,7 +224,8 @@ impl Pairing { let client_secret = &data[..16]; let client_sig = &data[16..]; let expected = crypto::sha256(&[&s.server_challenge, &s.client_cert_sig, client_secret]); - let hash_ok = expected[..] == s.client_hash[..]; + // Constant-time compare so a timing side-channel can't probe the expected hash. + let hash_ok = crypto::ct_eq(&expected, &s.client_hash); let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok(); if hash_ok && sig_ok { { diff --git a/crates/punktfunk-host/src/gamestream/rtsp.rs b/crates/punktfunk-host/src/gamestream/rtsp.rs index 3fb061a6..1376d0f5 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -15,12 +15,34 @@ use anyhow::{Context, Result}; use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; +use std::time::Duration; /// Opaque per-session payload the client echoes as its first UDP datagram (port-learning). const PING_PAYLOAD: &str = "0011223344556677"; +// The RTSP listener is UNAUTHENTICATED (no TLS/pairing) and one-thread-per-connection, so bound +// every attacker-controllable dimension to deny a pre-auth slow-loris / memory-growth DoS: a hard +// cap on concurrent connections, a per-read timeout so a stalled peer can't pin a thread, and +// size caps on the request headers + body (real GameStream RTSP messages are a few hundred bytes). +const MAX_RTSP_CONNS: usize = 8; +const RTSP_READ_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_RTSP_HEADER: usize = 16 * 1024; +const MAX_RTSP_BODY: usize = 64 * 1024; +const MAX_RTSP_MSG: usize = 128 * 1024; + +/// Live RTSP connection count, so a flood can't spawn unbounded threads. Decremented by [`ConnGuard`]. +static RTSP_ACTIVE: AtomicUsize = AtomicUsize::new(0); + +/// Decrements [`RTSP_ACTIVE`] when a connection thread exits (normally OR on panic). +struct ConnGuard; +impl Drop for ConnGuard { + fn drop(&mut self) { + RTSP_ACTIVE.fetch_sub(1, Ordering::Relaxed); + } +} + /// Bind 48010 and accept RTSP connections on a dedicated thread. pub fn spawn(state: Arc) -> Result<()> { let listener = TcpListener::bind(("0.0.0.0", RTSP_PORT)) @@ -32,8 +54,19 @@ pub fn spawn(state: Arc) -> Result<()> { for conn in listener.incoming() { match conn { Ok(stream) => { + // Reserve a slot; over the cap, drop the connection (close) without a thread. + if RTSP_ACTIVE.fetch_add(1, Ordering::Relaxed) >= MAX_RTSP_CONNS { + RTSP_ACTIVE.fetch_sub(1, Ordering::Relaxed); + tracing::warn!("RTSP: too many concurrent connections — dropping"); + continue; // `stream` drops → connection closed + } + // Construct the slot guard BEFORE spawning and move it into the worker, so the + // slot is released even if `thread::spawn` itself panics (OS thread-limit) — + // the closure (and its captured guard) is dropped during the unwind. + let guard = ConnGuard; let st = state.clone(); std::thread::spawn(move || { + let _guard = guard; // releases the slot on exit/panic if let Err(e) = handle_conn(stream, st) { tracing::warn!(error = %format!("{e:#}"), "RTSP connection ended"); } @@ -57,6 +90,8 @@ struct Request { fn handle_conn(mut stream: TcpStream, state: Arc) -> Result<()> { let peer = stream.peer_addr().ok(); + // A per-read timeout so a stalled/slow-loris peer can't pin this thread indefinitely. + let _ = stream.set_read_timeout(Some(RTSP_READ_TIMEOUT)); let mut buf: Vec = Vec::new(); // GameStream RTSP is one request per TCP connection: moonlight-common-c reads the // response until EOF, so we answer one message and close the connection (which signals @@ -82,10 +117,19 @@ fn handle_conn(mut stream: TcpStream, state: Arc) -> Result<()> { fn read_message(stream: &mut TcpStream, buf: &mut Vec) -> Result> { loop { if let Some(end) = find_subslice(buf, b"\r\n\r\n") { + // Cap the header section even when the terminator IS present (a single oversized header + // block that fits a `\r\n\r\n` would otherwise skip the no-terminator cap below). + if end > MAX_RTSP_HEADER { + anyhow::bail!("RTSP headers exceed limit"); + } let head = std::str::from_utf8(&buf[..end]).context("RTSP header utf8")?; let content_len = header_value(head, "content-length") .and_then(|v| v.trim().parse::().ok()) .unwrap_or(0); + // Reject an absurd Content-Length before waiting to buffer it (allocation amplification). + if content_len > MAX_RTSP_BODY { + anyhow::bail!("RTSP Content-Length {content_len} exceeds limit"); + } let total = end + 4 + content_len; if buf.len() < total { // headers complete but body still arriving — read more @@ -95,6 +139,9 @@ fn read_message(stream: &mut TcpStream, buf: &mut Vec) -> Result MAX_RTSP_HEADER { + // No header terminator within the cap — a slow-loris dribbling headers forever. + anyhow::bail!("RTSP headers exceed limit"); } let mut tmp = [0u8; 8192]; let n = stream.read(&mut tmp).context("RTSP read")?; @@ -102,6 +149,9 @@ fn read_message(stream: &mut TcpStream, buf: &mut Vec) -> Result MAX_RTSP_MSG { + anyhow::bail!("RTSP message exceeds limit"); + } } } diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 7673f15f..874706fd 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -104,16 +104,11 @@ fn run( // output is released when this capturer drops at stream end (RAII via its keepalive). if std::env::var("PUNKTFUNK_VIDEO_SOURCE").as_deref() == Ok("virtual") { // The launched app picks the compositor (e.g. gamescope for game entries) and the - // nested command; env vars remain manual overrides / fallbacks. + // nested command. let compositor = app .and_then(|a| a.compositor) .map(Ok) .unwrap_or_else(|| crate::vdisplay::detect().context("detect compositor"))?; - if let Some(cmd) = app.and_then(|a| a.cmd.as_deref()) { - // The gamescope backend reads the nested command from this env var; setting it - // per-launch is safe (one stream session at a time). - std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", cmd); - } tracing::info!( ?compositor, app = ?app.map(|a| &a.title), @@ -122,6 +117,9 @@ fn run( "video source: virtual display (native client resolution)" ); let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; + // Carry the resolved launch command on the backend instance (per-session) rather than a + // process-global env var, so concurrent sessions can't stomp each other's launch target. + vd.set_launch_command(app.and_then(|a| a.cmd.clone())); let vout = vd .create(punktfunk_core::Mode { width: cfg.width, diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 79156f14..b50c7eb5 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -23,7 +23,7 @@ use crate::gamestream::{ use anyhow::{Context, Result}; use axum::{ extract::{Path, Request, State}, - http::{header, StatusCode}, + http::{header, Method, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::get, @@ -461,10 +461,15 @@ async fn require_auth(State(st): State>, req: Request, next: Next return next.run(req).await; // liveness probe is always open } // A paired native client authenticates by its mTLS certificate — the same identity + trust the - // QUIC data plane uses — so it never needs a bearer token. The fingerprint is attached by - // `serve_https` from the verified peer cert; we authorize it iff it's in the paired store. + // 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 st.native.as_ref().is_some_and(|n| n.is_paired(fp)) { + 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; } } @@ -487,6 +492,27 @@ async fn require_auth(State(st): State>, req: Request, next: Next } } +/// 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" + ) +} + /// 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 { @@ -1274,6 +1300,86 @@ mod tests { 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" + ); + } + #[tokio::test] async fn health_is_open_and_versioned() { let app = test_app(test_state(), None); diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index f3f99987..f6cc8afa 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -564,10 +564,12 @@ async fn serve_session( }; // Resolve 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). Set the gamescope - // backend's app env var, exactly as the GameStream /launch path does — safe per-session - // (one session at a time). Only the bare-spawn gamescope path reads it; on a shared - // desktop (kwin/mutter/wlroots) or an attach-to-existing session it's a harmless no-op. + // we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope + // backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared + // desktop / attach-to-existing session it's a harmless no-op). This is the process-global env + // path — safe under today's ONE-session-at-a-time model; when concurrent native sessions land + // (`what's left` §3), resolve the command into the per-session VirtualDisplay via + // `set_launch_command` (as the GameStream path now does) so sessions can't stomp each other. if let Some(id) = hello.launch.as_deref() { match crate::library::launch_command(id) { Some(cmd) => { diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 169e268e..f351fbe0 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -50,6 +50,11 @@ pub trait VirtualDisplay: Send { /// Create a virtual output of the given mode. Teardown is RAII: drop the returned /// [`VirtualOutput`]'s `keepalive`. fn create(&mut self, mode: Mode) -> Result; + /// Set the per-session command this display should launch into its nested output (the resolved + /// app/game). Carried on the backend instance — NOT a process-global env var — so concurrent + /// sessions can't stomp each other's launch target. Default: no-op (backends that attach to an + /// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it). + fn set_launch_command(&mut self, _cmd: Option) {} } /// Compositors punktfunk knows how to drive (plan §6). diff --git a/crates/punktfunk-host/src/vdisplay/gamescope.rs b/crates/punktfunk-host/src/vdisplay/gamescope.rs index e35733f8..c7da60c9 100644 --- a/crates/punktfunk-host/src/vdisplay/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/gamescope.rs @@ -25,7 +25,12 @@ use std::time::{Duration, Instant}; /// * `PUNKTFUNK_GAMESCOPE_NODE=` — ATTACH to an already-running gamescope (capture + /// inject, no lifecycle ownership). /// * else — SPAWN a bare headless gamescope sized to the mode, running `PUNKTFUNK_GAMESCOPE_APP`. -pub struct GamescopeDisplay; +#[derive(Default)] +pub struct GamescopeDisplay { + /// The resolved per-session launch command (set via [`VirtualDisplay::set_launch_command`]); the + /// bare-spawn path runs it instead of reading the process-global `PUNKTFUNK_GAMESCOPE_APP`. + cmd: Option, +} /// A running host-managed session (its transient systemd --user unit) + the mode it was launched at. struct SessionState { @@ -79,7 +84,7 @@ static STEAMOS_TOOK_OVER: std::sync::Mutex = std::sync::Mutex::new(false); impl GamescopeDisplay { pub fn new() -> Result { - Ok(GamescopeDisplay) + Ok(GamescopeDisplay::default()) } } @@ -88,6 +93,10 @@ impl VirtualDisplay for GamescopeDisplay { "gamescope" } + fn set_launch_command(&mut self, cmd: Option) { + self.cmd = cmd; + } + fn create(&mut self, mode: Mode) -> Result { // Host-managed gamescope-session-plus at the CLIENT's mode (the Bazzite path): launch the // full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE @@ -121,7 +130,12 @@ impl VirtualDisplay for GamescopeDisplay { }); } check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions - let proc = GamescopeProc(spawn(mode.width, mode.height, mode.refresh_hz.max(1))?); + let proc = GamescopeProc(spawn( + mode.width, + mode.height, + mode.refresh_hz.max(1), + self.cmd.as_deref(), + )?); // gamescope creates its PipeWire node a moment after start; poll for it (the proc is held // alive meanwhile, and killed if we give up). let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| { @@ -626,9 +640,17 @@ pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei"; /// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell /// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`EI_SOCKET_FILE`] /// so the input injector can connect to gamescope's EIS server from outside. -fn spawn(w: u32, h: u32, hz: u32) -> Result { - let app = - std::env::var("PUNKTFUNK_GAMESCOPE_APP").unwrap_or_else(|_| "sleep infinity".to_string()); +fn spawn(w: u32, h: u32, hz: u32, cmd: Option<&str>) -> Result { + // A non-empty per-session command (set via `set_launch_command`) wins; else the + // `PUNKTFUNK_GAMESCOPE_APP` env var (the documented manual fallback); else a no-op that keeps + // gamescope alive. Each level is taken only if non-empty, so a blank per-session cmd transparently + // falls through to the env (matching the pre-fix behaviour). + let app = cmd + .map(str::to_string) + .filter(|s| !s.trim().is_empty()) + .or_else(|| std::env::var("PUNKTFUNK_GAMESCOPE_APP").ok()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "sleep infinity".to_string()); let _ = std::fs::remove_file(EI_SOCKET_FILE); // stale socket path from a previous session let mut cmd = Command::new("gamescope"); cmd.args(["--backend", "headless"])