From a93f5a71ced5dc46d359f2e23c3c49ec3cb6b73b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 15 Jul 2026 18:40:05 +0200 Subject: [PATCH] feat(host): script-facing stream-active runtime marker file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintain $XDG_RUNTIME_DIR/punktfunk/stream while any client is streaming, holding the primary session's negotiated mode. A per-title launch wrapper can branch on it: present → session is already at the stream mode, run the game as-is; absent → run the local (e.g. multi-head gamescope) path. - New stream_marker module: RAII Guard registered per session, refcounted for concurrent clients, atomic (temp+rename) writes, injection-safe single-quoted client name. POSIX-sh-sourceable KEY=value, namespaced PF_STREAM_* keys, schema-versioned, additive-only. - Hooked into serve_session so every exit path (disconnect, error, panic-unwind) retracts the marker. File exists iff a stream is live. Unblocks the downstream triple-head gamescope launch-wrapper use case. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/main.rs | 1 + crates/punktfunk-host/src/punktfunk1.rs | 11 + crates/punktfunk-host/src/stream_marker.rs | 259 +++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 crates/punktfunk-host/src/stream_marker.rs diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 557cd496..0925697b 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -71,6 +71,7 @@ mod session_plan; mod session_tuning; mod spike; mod stats_recorder; +mod stream_marker; mod vdisplay; #[cfg(target_os = "windows")] #[path = "windows/win_adapter.rs"] diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 138117f0..63876ba7 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1582,6 +1582,17 @@ async fn serve_session( let source = opts.source; let (seconds, frames) = (opts.seconds, opts.frames); let mode = hello.mode; + // Script-facing runtime marker: `$XDG_RUNTIME_DIR/punktfunk/stream` exists (with this session's + // negotiated mode) for exactly as long as this session streams. Held by RAII to session end, so + // every exit path — clean disconnect, error, panic-unwind — retracts it. Lets a launch wrapper + // branch "streaming → run the game as-is; not → my local multi-head gamescope" (see the module). + let _stream_marker = crate::stream_marker::announce(crate::stream_marker::StreamInfo { + width: mode.width, + height: mode.height, + refresh_hz: mode.refresh_hz, + hdr: welcome.color.is_hdr(), + client: hello.name.clone().unwrap_or_default(), + }); // 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 // to its shell command HERE against the host's own library — a client can only ever pick an diff --git a/crates/punktfunk-host/src/stream_marker.rs b/crates/punktfunk-host/src/stream_marker.rs new file mode 100644 index 00000000..ef96e902 --- /dev/null +++ b/crates/punktfunk-host/src/stream_marker.rs @@ -0,0 +1,259 @@ +//! Script-facing **"a stream is live"** marker file. +//! +//! While one or more clients are actively streaming, the host maintains a small, shell-sourceable +//! file at `$XDG_RUNTIME_DIR/punktfunk/stream`. It is created when the first session goes live and +//! removed when the last one ends, so a per-title launch script (a Steam launch-option wrapper, a +//! `.desktop` Exec, whatever) can branch on presence + read the negotiated mode: +//! +//! ```sh +//! #!/bin/sh +//! STATE="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/punktfunk/stream" +//! if [ -f "$STATE" ]; then +//! . "$STATE" # sets PF_STREAM_WIDTH / _HEIGHT / _REFRESH … +//! exec "$@" # session already at the stream mode → run the game directly +//! fi +//! exec gamescope -W 5760 -H 1080 -f -- "$@" # not streaming → local triple-head +//! ``` +//! +//! ## Contract (stable — scripts depend on it) +//! +//! * The file EXISTS iff at least one client is actively streaming. Absence is the "no stream" +//! signal; a script must treat a missing file as "not streaming", never error on it. +//! * It is a POSIX-`sh`-sourceable list of `KEY=value` lines — safe to `. "$file"`. Every value is +//! either a bare integer or a single-quoted string with quotes/controls stripped, so sourcing can +//! never inject or run anything. Keys are namespaced `PF_STREAM_*` so sourcing won't clobber a +//! caller's `WIDTH`/`HEIGHT`. +//! * Keys only get ADDED across versions, never repurposed; `PF_STREAM_SCHEMA` bumps if an existing +//! key's meaning ever changes. Current keys: +//! - `PF_STREAM=1` — presence flag (redundant with the file existing; handy in `env`) +//! - `PF_STREAM_SCHEMA=1` — format version +//! - `PF_STREAM_WIDTH` — negotiated width (px) +//! - `PF_STREAM_HEIGHT` — negotiated height (px) +//! - `PF_STREAM_REFRESH` — negotiated refresh (Hz, integer) +//! - `PF_STREAM_HDR` — `1` if the session negotiated HDR, else `0` +//! - `PF_STREAM_SESSIONS` — number of clients currently streaming +//! - `PF_STREAM_CLIENT` — the primary (oldest live) client's name, single-quoted +//! * Writes are atomic (temp + `rename`), so a script that sources mid-update always sees a complete +//! prior version, never a torn file. +//! +//! ## Semantics +//! +//! The marker reflects the **primary** session — the oldest still-live one — which is the single +//! mode a single-user host is at. With several concurrent clients at different modes there is only +//! one file, so `PF_STREAM_SESSIONS>1` is the script's cue that width/height/refresh describe just +//! one of them. The mode published is the one negotiated at session start; a mid-stream resize is +//! not currently re-published (gamescope — the main script consumer — can't live-resize anyway). + +/// What one live session contributes to the marker. +#[derive(Clone, Debug)] +pub struct StreamInfo { + pub width: u32, + pub height: u32, + pub refresh_hz: u32, + pub hdr: bool, + /// Client-supplied device name (may be empty); sanitized before it reaches the file. + pub client: String, +} + +/// RAII handle for one announced session. While it is alive the session counts toward the marker; +/// dropping it (session end, error return, panic-unwind) deregisters and rewrites/removes the file. +/// Created by [`announce`]; never constructed directly. +#[must_use = "dropping the guard immediately retracts the stream marker"] +pub struct Guard { + #[cfg(unix)] + id: u64, +} + +/// 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. +pub fn announce(info: StreamInfo) -> Guard { + #[cfg(unix)] + { + imp::announce(info) + } + #[cfg(not(unix))] + { + let _ = info; + Guard {} + } +} + +#[cfg(not(unix))] +impl Drop for Guard { + fn drop(&mut self) {} +} + +#[cfg(unix)] +mod imp { + use super::{Guard, StreamInfo}; + use std::io::Write; + use std::path::PathBuf; + use std::sync::Mutex; + + /// The live sessions, oldest first (so `[0]` is the primary), each with the id its `Guard` holds. + struct Registry { + next_id: u64, + sessions: Vec<(u64, StreamInfo)>, + } + + static REGISTRY: Mutex = Mutex::new(Registry { + next_id: 1, + sessions: Vec::new(), + }); + + /// `$XDG_RUNTIME_DIR/punktfunk/` — per-user tmpfs (cleared on logout, so no marker survives a + /// reboot to lie about a stream). Falls back to `/tmp` only if the runtime dir is unset, matching + /// the gamescope-log convention already in this crate. + fn dir() -> PathBuf { + let base = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + base.join("punktfunk") + } + + fn marker_path() -> PathBuf { + dir().join("stream") + } + + /// Make a client name safe to place inside single quotes in a sourceable file: drop the single + /// quote itself and any control characters (newlines especially), and cap the length so a hostile + /// or absurd name can't bloat the file. Empty stays empty. + fn sanitize(name: &str) -> String { + name.chars() + .filter(|c| *c != '\'' && !c.is_control()) + .take(96) + .collect() + } + + pub(super) fn announce(info: StreamInfo) -> Guard { + let mut reg = REGISTRY.lock().unwrap(); + let id = reg.next_id; + reg.next_id += 1; + reg.sessions.push((id, info)); + rewrite(®); + Guard { id } + } + + /// Rewrite (or remove) the marker to match the current registry. Called under the lock. + fn rewrite(reg: &Registry) { + let path = marker_path(); + let Some((_, primary)) = reg.sessions.first() else { + // Last session gone — remove the marker. Best-effort: a missing file is already the + // desired end state. + let _ = std::fs::remove_file(&path); + return; + }; + + let body = format!( + "# punktfunk stream marker — auto-generated. Present only while a client is streaming.\n\ + # Sourceable from a launch script: `. \"$XDG_RUNTIME_DIR/punktfunk/stream\"`.\n\ + PF_STREAM=1\n\ + PF_STREAM_SCHEMA=1\n\ + PF_STREAM_WIDTH={w}\n\ + PF_STREAM_HEIGHT={h}\n\ + PF_STREAM_REFRESH={hz}\n\ + PF_STREAM_HDR={hdr}\n\ + PF_STREAM_SESSIONS={n}\n\ + PF_STREAM_CLIENT='{client}'\n", + w = primary.width, + h = primary.height, + hz = primary.refresh_hz, + hdr = u8::from(primary.hdr), + n = reg.sessions.len(), + client = sanitize(&primary.client), + ); + + if let Err(e) = write_atomic(&path, body.as_bytes()) { + tracing::debug!(error = %e, path = %path.display(), "could not write stream marker"); + } + } + + /// Write `bytes` to `path` atomically: create the parent, write a sibling temp file, `rename` it + /// over the target. A concurrent reader (`. "$file"`) therefore always sees either the old + /// complete file or the new complete file, never a partial one. Serialized by the registry lock, + /// so a fixed temp name is safe. + fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("tmp"); + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + } + std::fs::rename(&tmp, path) + } + + impl Drop for Guard { + fn drop(&mut self) { + let mut reg = REGISTRY.lock().unwrap(); + if let Some(pos) = reg.sessions.iter().position(|(id, _)| *id == self.id) { + reg.sessions.remove(pos); + } + rewrite(®); + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn sanitize_strips_quotes_and_controls() { + assert_eq!(sanitize("Living Room TV"), "Living Room TV"); + assert_eq!(sanitize("evil';rm -rf ~\n"), "evil;rm -rf ~"); + assert_eq!(sanitize(&"x".repeat(200)).len(), 96); + assert_eq!(sanitize(""), ""); + } + + // The marker file lifecycle is exercised against a real path so the atomic-write + remove + // logic is covered end to end. Serialized because the registry is process-global. + #[test] + fn marker_appears_while_held_and_vanishes_after() { + // Isolate this test's marker from any real host by pinning XDG_RUNTIME_DIR to a temp dir. + let tmp = std::env::temp_dir().join(format!("pf-marker-test-{}", std::process::id())); + std::env::set_var("XDG_RUNTIME_DIR", &tmp); + let path = marker_path(); + let _ = std::fs::remove_file(&path); + + let g = super::super::announce(StreamInfo { + width: 2560, + height: 1440, + refresh_hz: 120, + hdr: true, + client: "Couch'TV".to_string(), + }); + let text = std::fs::read_to_string(&path).expect("marker exists while streaming"); + assert!(text.contains("PF_STREAM_WIDTH=2560")); + assert!(text.contains("PF_STREAM_HEIGHT=1440")); + assert!(text.contains("PF_STREAM_REFRESH=120")); + assert!(text.contains("PF_STREAM_HDR=1")); + assert!(text.contains("PF_STREAM_SESSIONS=1")); + // The apostrophe must have been stripped, leaving a well-formed single-quoted value. + assert!(text.contains("PF_STREAM_CLIENT='CouchTV'")); + + // A second concurrent session bumps the count but keeps the primary's mode. + let g2 = super::super::announce(StreamInfo { + width: 1920, + height: 1080, + refresh_hz: 60, + hdr: false, + client: "Phone".to_string(), + }); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("PF_STREAM_SESSIONS=2")); + assert!( + text.contains("PF_STREAM_WIDTH=2560"), + "primary mode is retained" + ); + + drop(g2); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("PF_STREAM_SESSIONS=1")); + + drop(g); + assert!(!path.exists(), "marker removed once the last session ends"); + } + } +}