fix(security): remaining audit findings — mgmt admin gate, RTSP DoS bounds, FEC drop, ALPN, ct-compare
apple / swift (push) Successful in 56s
windows-host / package (push) Successful in 2m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m10s
android / android (push) Successful in 4m42s
ci / rust (push) Successful in 4m44s
ci / web (push) Successful in 30s
ci / docs-site (push) Successful in 35s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m0s
deb / build-publish (push) Successful in 2m10s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m43s
flatpak / build-publish (push) Successful in 3m59s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m28s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m13s

Addresses the lower-severity findings from docs/security-review.md (#4-#12). Each fix was
adversarially re-reviewed (5-agent pass); two review catches folded in (the Apple client's
GET /library cert path; an RTSP header-cap bypass + a spawn-panic counter leak).

- #4 [low] mgmt mTLS-paired-cert no longer grants full admin. A paired STREAMING cert authorizes
  only a read-only allowlist (GET /host,/compositors,/status,/clients,/native/clients,/library);
  every state-changing route and every PIN-exposing route (/pair, /native/pair) requires the
  operator's bearer token. New cert_auth_is_a_read_only_allowlist test. (/library kept on the
  allowlist — the native clients browse it cert-only; its mutations stay token-only.)
- #6 [low] RTSP pre-auth DoS bounds: a concurrent-connection cap (RAII slot guard), a per-read
  timeout (slow-loris), and Content-Length/header/message size caps — closing an unauthenticated
  slow-loris / memory-growth / thread-exhaustion vector on TCP 48010.
- #11 [info] A FEC reconstruction failure is now a counted drop (discard the block, keep the
  session) instead of being stream-fatal — a lossy link can't be torn down by one bad block.
- #10 [info] Fixed ALPN ("pkf1") on both native QUIC endpoints (defense-in-depth; a deliberate
  coordinated client+host upgrade — a new host rejects an ALPN-less old client).
- #8 [info] Constant-time GameStream pairing phase-4 hash compare (crypto::ct_eq).
- #7 [low] New VirtualDisplay::set_launch_command carries the launch command per-session on the
  GameStream path (no process-global env stomp under concurrent sessions); native path keeps the
  env under today's single-session model (documented; plumb per-session with concurrent sessions).
- #5 [low] Legacy GameStream GCM nonce reuse: documented as inherent to Nvidia's old-style control
  encryption (Apollo/Moonlight identical; key is client-known) — unfixable on the legacy wire; the
  real fix is V2 control-encryption negotiation. Code comment at control.rs.
- #9 [info] GameStream plain-HTTP pairing: documented (inherent to GFE compat; use punktfunk/1).
- #12 [low] Web global NODE_TLS_REJECT_UNAUTHORIZED: fix designed (undici dispatcher scoped to the
  loopback mgmt fetch) but DEFERRED — needs `bun add undici` in the web build env; reverted to keep
  the web working. Latent-only (the loopback mgmt fetch is the console's only outbound TLS).

fmt + clippy -D warnings clean; 94 host + core tests green; no C-ABI/OpenAPI drift. (The HDR
Steps 1-2 client work in the tree is the user's parallel WIP — deliberately NOT included here.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 09:50:24 +00:00
parent 551012bb43
commit 3c55ec37fa
12 changed files with 273 additions and 28 deletions
+19 -4
View File
@@ -296,8 +296,9 @@ impl Reassembler {
stats: &StatsCounters,
) -> Result<Option<Frame>> {
// 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,
+11 -2
View File
@@ -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));
@@ -103,6 +103,18 @@ pub fn spawn(state: Arc<AppState>) -> 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 {
@@ -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()]);
@@ -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 {
{
+51 -1
View File
@@ -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<AppState>) -> Result<()> {
let listener = TcpListener::bind(("0.0.0.0", RTSP_PORT))
@@ -32,8 +54,19 @@ pub fn spawn(state: Arc<AppState>) -> 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<AppState>) -> 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<u8> = 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<AppState>) -> Result<()> {
fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Request>> {
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::<usize>().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<u8>) -> Result<Option<Requ
buf.drain(..total);
return Ok(Some(parse_request(&head, body)));
}
} else if buf.len() > 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<u8>) -> Result<Option<Requ
return Ok(None); // peer closed
}
buf.extend_from_slice(&tmp[..n]);
if buf.len() > MAX_RTSP_MSG {
anyhow::bail!("RTSP message exceeds limit");
}
}
}
@@ -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,
+110 -4
View File
@@ -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<Arc<MgmtState>>, 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::<PeerCertFingerprint>() {
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<Arc<MgmtState>>, 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<Body>, fp: &str) -> StatusCode {
req.extensions_mut()
.insert(PeerCertFingerprint(Some(fp.to_string())));
app.clone().oneshot(req).await.expect("infallible").status()
}
/// A paired *streaming* cert (mTLS, no bearer) authorizes only the read-only allowlist; every
/// state-changing or PIN-exposing route still requires the operator's bearer token (audit #4).
#[tokio::test]
async fn cert_auth_is_a_read_only_allowlist() {
let np = Arc::new(
crate::native_pairing::NativePairing::load_with(
Some(
std::env::temp_dir().join(format!("pf-mgmt-cert-{}.json", std::process::id())),
),
None,
false,
)
.unwrap(),
);
let fp = "deadbeefcafe";
np.add("streaming-client", fp).unwrap();
let app = test_app_native(test_state(), np);
// Allowlisted read-only GETs → the cert authorizes them (not 401).
for p in [
"/api/v1/host",
"/api/v1/status",
"/api/v1/compositors",
"/api/v1/clients",
"/api/v1/native/clients",
"/api/v1/library",
] {
assert_ne!(
send_cert(&app, get_req(p), fp).await,
StatusCode::UNAUTHORIZED,
"a paired streaming cert should authorize GET {p}"
);
}
// PIN-exposing GET + state-changing routes → token-only (cert rejected without a bearer).
assert_eq!(
send_cert(&app, get_req("/api/v1/native/pair"), fp).await,
StatusCode::UNAUTHORIZED,
"GET /native/pair exposes the PIN → must require the bearer token"
);
assert_eq!(
send_cert(
&app,
post_json(
"/api/v1/native/pair/arm",
serde_json::json!({"ttl_secs": 60})
),
fp,
)
.await,
StatusCode::UNAUTHORIZED,
"arming pairing must require the bearer token"
);
assert_eq!(
send_cert(
&app,
axum::http::Request::delete("/api/v1/native/clients/deadbeefcafe")
.body(Body::empty())
.unwrap(),
fp,
)
.await,
StatusCode::UNAUTHORIZED,
"unpair (DELETE) must require the bearer token"
);
// An UNPAIRED cert is rejected even on an allowlisted path.
assert_eq!(
send_cert(&app, get_req("/api/v1/status"), "not-paired").await,
StatusCode::UNAUTHORIZED,
"an unpaired cert must be rejected"
);
}
#[tokio::test]
async fn health_is_open_and_versioned() {
let app = test_app(test_state(), None);
+6 -4
View File
@@ -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) => {
+5
View File
@@ -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<VirtualOutput>;
/// 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<String>) {}
}
/// Compositors punktfunk knows how to drive (plan §6).
@@ -25,7 +25,12 @@ use std::time::{Duration, Instant};
/// * `PUNKTFUNK_GAMESCOPE_NODE=<id|auto>` — 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<String>,
}
/// 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<bool> = std::sync::Mutex::new(false);
impl GamescopeDisplay {
pub fn new() -> Result<Self> {
Ok(GamescopeDisplay)
Ok(GamescopeDisplay::default())
}
}
@@ -88,6 +93,10 @@ impl VirtualDisplay for GamescopeDisplay {
"gamescope"
}
fn set_launch_command(&mut self, cmd: Option<String>) {
self.cmd = cmd;
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// 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<Child> {
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<Child> {
// 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"])