Files
punktfunk/crates/punktfunk-host/src/stream_marker.rs
T
enricobuehler ecfa71212d
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
chore: consolidate all in-progress parallel-session WIP
Wholesale commit of every uncommitted change across the tree, at the user's
explicit request — host refactor-campaign W1 (native.rs facade + native/ dir,
library/ + mgmt/ splits), Android, core. These streams were mid-flight and not
individually built/tested together; this supersedes the per-session HOLD
markers. Consolidating so everything lands on main in one pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:08:29 +02:00

313 lines
12 KiB
Rust

//! 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,
/// Store-qualified launch id this session requested, if any — carried on the stream
/// lifecycle events, NOT written to the marker file (its key set is a stable contract).
pub launch: Option<String>,
}
/// The announce points double as the `stream.started`/`stream.stopped` lifecycle fire sites
/// (RFC §4) — only the native loop announces the marker today, hence the fixed plane.
fn stream_ref(info: &StreamInfo) -> crate::events::StreamRef {
crate::events::StreamRef {
mode: crate::events::mode_str(info.width, info.height, info.refresh_hz),
hdr: info.hdr,
client: info.client.clone(),
app: info.launch.clone(),
plane: crate::events::Plane::Native,
}
}
/// 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,
/// The announced stream, re-emitted as `stream.stopped` when the guard drops.
stream: crate::events::StreamRef,
}
/// 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. Emits `stream.started` on all
/// platforms (the marker file itself is unix-only); the guard's drop emits `stream.stopped`.
pub fn announce(info: StreamInfo) -> Guard {
crate::events::emit(crate::events::EventKind::StreamStarted {
stream: stream_ref(&info),
});
#[cfg(unix)]
{
imp::announce(info)
}
#[cfg(not(unix))]
{
Guard {
stream: stream_ref(&info),
}
}
}
#[cfg(not(unix))]
impl Drop for Guard {
fn drop(&mut self) {
crate::events::emit(crate::events::EventKind::StreamStopped {
stream: self.stream.clone(),
});
}
}
#[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(),
});
impl Registry {
fn insert(&mut self, info: StreamInfo) -> u64 {
let id = self.next_id;
self.next_id += 1;
self.sessions.push((id, info));
id
}
fn remove(&mut self, id: u64) {
if let Some(pos) = self.sessions.iter().position(|(sid, _)| *sid == id) {
self.sessions.remove(pos);
}
}
}
/// `$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 stream = super::stream_ref(&info);
let mut reg = REGISTRY.lock().unwrap();
let id = reg.insert(info);
rewrite_to(&marker_path(), &reg);
Guard { id, stream }
}
/// Rewrite (or remove) the marker at `path` to match `reg`. Called under the registry lock.
fn rewrite_to(path: &std::path::Path, reg: &Registry) {
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();
reg.remove(self.id);
rewrite_to(&marker_path(), &reg);
}
crate::events::emit(crate::events::EventKind::StreamStopped {
stream: self.stream.clone(),
});
}
}
#[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. It drives a LOCAL registry at an explicit temp path: the
// process-global one is shared with the native integration tests (which announce real
// sessions concurrently), and mutating XDG_RUNTIME_DIR mid-run would race them too.
#[test]
fn marker_appears_while_held_and_vanishes_after() {
let dir = std::env::temp_dir().join(format!("pf-marker-test-{}", std::process::id()));
let path = dir.join("stream");
let _ = std::fs::remove_file(&path);
let mut reg = Registry {
next_id: 1,
sessions: Vec::new(),
};
let g = reg.insert(StreamInfo {
width: 2560,
height: 1440,
refresh_hz: 120,
hdr: true,
client: "Couch'TV".to_string(),
launch: None,
});
rewrite_to(&path, &reg);
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 = reg.insert(StreamInfo {
width: 1920,
height: 1080,
refresh_hz: 60,
hdr: false,
client: "Phone".to_string(),
launch: None,
});
rewrite_to(&path, &reg);
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"
);
reg.remove(g2);
rewrite_to(&path, &reg);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("PF_STREAM_SESSIONS=1"));
reg.remove(g);
rewrite_to(&path, &reg);
assert!(!path.exists(), "marker removed once the last session ends");
let _ = std::fs::remove_dir(&dir);
}
}
}