feat(host): attach to a running gamescope session (Bazzite headless Steam)
Bazzite (and SteamOS-like hosts) run Steam Big Picture inside their OWN gamescope-session-plus session. Nesting a second gamescope+Steam can't work — the second Steam sees the first and exits, taking the nested gamescope down with it (crash in its exit handlers), killing both video and input. The robust model is to let punktfunk OWN that session: run gamescope-session-plus headless at the client's resolution (full Steam Deck UI polish: MangoApp, VRR, controller config) and have the host ATTACH to it rather than spawn its own. The video half already existed (PUNKTFUNK_GAMESCOPE_NODE=<id> attaches to a PipeWire node). This finishes it: - PUNKTFUNK_GAMESCOPE_NODE=auto discovers the gamescope Video/Source node, so the (dynamic) node id needn't be hand-wired. - The attach path now also points the libei injector at the running session's EIS socket: find_gamescope_eis_socket() scans XDG_RUNTIME_DIR for gamescope-<N>-ei, connect()-probes each (stale dead-session sockets refuse), and writes the newest live one to the relay file the injector reads. So input reaches the attached session with zero manual config. scripts/punktfunk-steam-session.service: a systemd --user unit that runs gamescope-session-plus headless at a configured resolution, with the one-time headless-appliance setup (linger + multi-user.target) documented inline. Validated live on bazzite (RTX 4090): the full Steam Big Picture session streams (1499 frames, p50 ~1ms) with mouse/keyboard injected into it (device resumed, all caps, emitted=true), node + EIS socket both auto-detected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,12 +34,43 @@ impl VirtualDisplay for GamescopeDisplay {
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Attach to an already-running gamescope (debug / Steam-launched session) instead of
|
||||
// spawning one: PUNKTFUNK_GAMESCOPE_NODE=<pipewire node id>.
|
||||
// Attach to an already-running gamescope (e.g. a headless `gamescope-session-plus` Steam
|
||||
// session, or a debug/Steam-launched one) instead of spawning our own: capture its node
|
||||
// AND inject into its EIS socket. PUNKTFUNK_GAMESCOPE_NODE=<id|auto>; "auto" discovers the
|
||||
// gamescope `Video/Source` node so nothing has to be hand-wired. This is the Bazzite path:
|
||||
// a persistent headless Steam-Deck-UI session (full gamescope-session-plus polish, at the
|
||||
// client's resolution) that punktfunk streams + drives, instead of nesting a second Steam.
|
||||
if let Ok(id) = std::env::var("PUNKTFUNK_GAMESCOPE_NODE") {
|
||||
let node_id: u32 = id
|
||||
.parse()
|
||||
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id")?;
|
||||
let node_id: u32 = if id.trim().eq_ignore_ascii_case("auto") {
|
||||
find_gamescope_node().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"PUNKTFUNK_GAMESCOPE_NODE=auto but no running gamescope Video/Source node \
|
||||
was found — is the headless gamescope/Steam session up?"
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
id.parse()
|
||||
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id or 'auto'")?
|
||||
};
|
||||
// Point the libei injector at the running gamescope's EIS socket: it reads the relay
|
||||
// file [`EI_SOCKET_FILE`], so write the live socket's name there. Best-effort — video
|
||||
// still works without it (input just won't reach the attached session).
|
||||
match find_gamescope_eis_socket() {
|
||||
Some(sock) => match std::fs::write(EI_SOCKET_FILE, &sock) {
|
||||
Ok(()) => tracing::info!(
|
||||
socket = %sock,
|
||||
"gamescope attach: pointed injector at the running session's EIS socket"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
"gamescope attach: could not write the EIS relay file — input may not reach the session"
|
||||
),
|
||||
},
|
||||
None => tracing::warn!(
|
||||
"gamescope attach: no connectable gamescope EIS socket found — input injection \
|
||||
will not reach the attached session"
|
||||
),
|
||||
}
|
||||
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
|
||||
return Ok(VirtualOutput {
|
||||
node_id,
|
||||
@@ -198,6 +229,37 @@ fn find_gamescope_node() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing
|
||||
/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file).
|
||||
///
|
||||
/// gamescope names its EIS socket `gamescope-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
|
||||
/// `gamescope-<display>` wayland socket). Stale sockets from dead sessions linger, so we don't
|
||||
/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the
|
||||
/// most recently created (the live session). Returns the bare socket *name* (the injector
|
||||
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
|
||||
fn find_gamescope_eis_socket() -> Option<String> {
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
|
||||
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// The EIS socket itself, not its `.lock` sidecar or the bare wayland socket.
|
||||
if !(name.starts_with("gamescope-") && name.ends_with("-ei")) {
|
||||
continue;
|
||||
}
|
||||
// Connectable == a live listener is behind it (a dead session's socket refuses).
|
||||
if std::os::unix::net::UnixStream::connect(entry.path()).is_err() {
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
live.push((mtime, name));
|
||||
}
|
||||
live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first
|
||||
live.into_iter().next().map(|(_, n)| n)
|
||||
}
|
||||
|
||||
/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does
|
||||
/// not require any particular desktop to be running. Quiet (no version warning — that's for the
|
||||
/// create path); just checks the binary executes.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# punktfunk headless Steam session — systemd USER unit (Bazzite / SteamOS-like hosts).
|
||||
#
|
||||
# Runs the FULL gamescope-session-plus Steam Deck UI (MangoApp/VRR/controller config — all the
|
||||
# polish) headless, at your streaming client's resolution, with no physical display. punktfunk's
|
||||
# host then ATTACHES to it (capture its PipeWire node + inject into its libei socket) instead of
|
||||
# nesting a second, conflicting Steam — set the host's PUNKTFUNK_GAMESCOPE_NODE=auto +
|
||||
# PUNKTFUNK_INPUT_BACKEND=gamescope (see scripts/host.env.example).
|
||||
#
|
||||
# Prereq — free Steam from the local gaming session (so this owns it), on a headless box:
|
||||
# sudo loginctl enable-linger $USER # user services run without a graphical login
|
||||
# sudo systemctl set-default multi-user.target # don't auto-start the local gaming session
|
||||
# sudo systemctl isolate multi-user.target # stop it now (or reboot)
|
||||
#
|
||||
# Install:
|
||||
# mkdir -p ~/.config/systemd/user && cp scripts/punktfunk-steam-session.service ~/.config/systemd/user/
|
||||
# # edit SCREEN_WIDTH/HEIGHT below to your client's resolution, then:
|
||||
# systemctl --user daemon-reload && systemctl --user enable --now punktfunk-steam-session
|
||||
#
|
||||
# Revert to local gaming mode anytime:
|
||||
# systemctl --user disable --now punktfunk-steam-session
|
||||
# sudo systemctl set-default graphical.target && sudo systemctl isolate graphical.target
|
||||
[Unit]
|
||||
Description=punktfunk headless Steam Big Picture session (gamescope-session-plus)
|
||||
After=pipewire.service pipewire-pulse.service
|
||||
Wants=pipewire.service
|
||||
|
||||
[Service]
|
||||
# Headless gamescope at the streamed resolution. gamescope-session-plus reads all of these from
|
||||
# the environment (see /usr/share/gamescope-session-plus/gamescope-session-plus). Set the WIDTH/
|
||||
# HEIGHT to your client's mode; CUSTOM_REFRESH_RATES advertises selectable rates to Big Picture.
|
||||
Environment=BACKEND=headless
|
||||
Environment=SCREEN_WIDTH=5120
|
||||
Environment=SCREEN_HEIGHT=1440
|
||||
Environment=CUSTOM_REFRESH_RATES=60,120,240
|
||||
Environment=STEAM_DISPLAY_REFRESH_LIMITS=60,240
|
||||
ExecStart=/usr/share/gamescope-session-plus/gamescope-session-plus steam
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
Reference in New Issue
Block a user