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
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:
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(®);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user