feat(host): script-facing stream-active runtime marker file
ci / web (push) Successful in 1m54s
ci / docs-site (push) Successful in 1m50s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
ci / rust (push) Failing after 7m8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m12s
ci / bench (push) Successful in 6m10s
docker / deploy-docs (push) Successful in 27s
arch / build-publish (push) Successful in 11m11s
android / android (push) Successful in 14m47s
windows-host / package (push) Successful in 15m22s
deb / build-publish (push) Successful in 14m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m6s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m43s
apple / swift (push) Successful in 4m58s
apple / screenshots (push) Successful in 21m8s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 18:40:05 +02:00
parent a7d4a93ff2
commit a93f5a71ce
3 changed files with 271 additions and 0 deletions
+1
View File
@@ -71,6 +71,7 @@ mod session_plan;
mod session_tuning; mod session_tuning;
mod spike; mod spike;
mod stats_recorder; mod stats_recorder;
mod stream_marker;
mod vdisplay; mod vdisplay;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "windows/win_adapter.rs"] #[path = "windows/win_adapter.rs"]
+11
View File
@@ -1582,6 +1582,17 @@ async fn serve_session(
let source = opts.source; let source = opts.source;
let (seconds, frames) = (opts.seconds, opts.frames); let (seconds, frames) = (opts.seconds, opts.frames);
let mode = hello.mode; 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 // 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 // (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 // to its shell command HERE against the host's own library — a client can only ever pick an
+259
View File
@@ -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<Registry> = 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(&reg);
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(&reg);
}
}
#[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");
}
}
}