fix(host): make client game launches work on every Linux compositor
Client-initiated launches (Hello.launch / GameStream applist) were only wired to gamescope's bare-spawn path via the process-global PUNKTFUNK_GAMESCOPE_APP env — which leaked across sessions, was never read by kwin/mutter/wlroots (launch was a silent no-op there), and was unreachable on gamescope anyway because apply_input_env unconditionally defaulted to the managed session (which bails on non-Bazzite/SteamOS boxes and ignores the launch command in all its modes). - Thread the launch per-session: resolve the library id at handshake, carry it on SessionContext (Windows: id; else: resolved command), and hand it to the backend instance via set_launch_command — the global env write is gone (the env stays as an operator fallback in spawn). - Gamescope sub-mode ladder (pick_gamescope_mode, pure + unit-tested): managed only when session-plus/SteamOS infra exists, attach for an explicit request or a foreign (non-host-descendant) gamescope, else bare spawn — which nests the launch and is now reachable on plain distros instead of the guaranteed managed-mode bail. - launch_session_command: one launch entry point for both planes once capture is live — desktop compositors plain-spawn into the retargeted session (the virtual output is primary); managed/attached gamescope spawns with the live session's DISPLAY/GAMESCOPE_WAYLAND_DISPLAY discovered from /proc (steam:// URIs also forward over Steam's own pipe). launch_is_nested gates bare spawn against double-launching. - GameStream unified onto the same dispatch; also nests library-id picks into gamescope (previously only apps.json cmd was nested). Validated live on the dev box up to the missing-GPU wall: handshake resolution, Spawn sub-mode on plain Ubuntu, gamescope spawned with the command nested. On-glass validation (kwin spawn on the streamed output, Bazzite/Deck managed forward) pending GPU reattach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -132,19 +132,15 @@ fn run(
|
|||||||
"video source: virtual display (native client resolution)"
|
"video source: virtual display (native client resolution)"
|
||||||
);
|
);
|
||||||
// Launch the app's command now that capture is live, for the backends that DON'T nest it via
|
// Launch the app's command now that capture is live, for the backends that DON'T nest it via
|
||||||
// set_launch_command above: Windows (no gamescope) and Linux kwin/mutter/wlroots (which stream
|
// set_launch_command above: Windows (no gamescope) and, on Linux, everything but gamescope's
|
||||||
// the existing desktop, so the app must be spawned into the session to land on the streamed
|
// bare-spawn sub-mode (kwin/mutter/wlroots stream the existing desktop; a managed/attached
|
||||||
// output). Linux gamescope already nested it via set_launch_command, so skip it there.
|
// gamescope is a running session to launch INTO — `launch_session_command` routes both).
|
||||||
|
// A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its
|
||||||
|
// store-qualified id — resolved against the host's OWN library (the client can only pick an
|
||||||
|
// existing title, never inject a command). An apps.json entry instead carries an
|
||||||
|
// operator-typed `cmd`. Library id wins when both are set.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let launch_here = true;
|
{
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
|
|
||||||
#[cfg(any(windows, target_os = "linux"))]
|
|
||||||
if launch_here {
|
|
||||||
// A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its
|
|
||||||
// store-qualified id — resolve + launch it against the host's OWN library (the client can
|
|
||||||
// only pick an existing title, never inject a command). An apps.json entry instead carries
|
|
||||||
// an operator-typed `cmd`. Library id wins when both are set.
|
|
||||||
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
|
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
|
||||||
if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
|
if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
|
||||||
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
|
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
|
||||||
@@ -158,6 +154,17 @@ fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if !crate::vdisplay::launch_is_nested(compositor) {
|
||||||
|
if let Some(cmd) = crate::library::resolve_session_launch(
|
||||||
|
app.and_then(|a| a.library_id.as_deref()),
|
||||||
|
app.and_then(|a| a.cmd.as_deref()),
|
||||||
|
) {
|
||||||
|
if let Err(e) = crate::library::launch_session_command(compositor, &cmd) {
|
||||||
|
tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live
|
// Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live
|
||||||
// compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place
|
// compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place
|
||||||
// without a Moonlight reconnect. (A resolution change can't be followed mid-stream on
|
// without a Moonlight reconnect. (A resolution change can't be followed mid-stream on
|
||||||
@@ -248,7 +255,16 @@ fn open_gs_virtual_source(
|
|||||||
};
|
};
|
||||||
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
|
||||||
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
// Carry the resolved launch command on the backend instance (per-session) rather than a
|
||||||
// process-global env var, so concurrent sessions can't stomp each other's launch target.
|
// process-global env var, so concurrent sessions can't stomp each other's launch target. On
|
||||||
|
// Linux resolve a library-id selection to its command too, so gamescope's bare spawn nests a
|
||||||
|
// library title exactly like an apps.json command (it previously nested only `cmd`, silently
|
||||||
|
// dropping library picks).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
vd.set_launch_command(crate::library::resolve_session_launch(
|
||||||
|
app.and_then(|a| a.library_id.as_deref()),
|
||||||
|
app.and_then(|a| a.cmd.as_deref()),
|
||||||
|
));
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
|
vd.set_launch_command(app.and_then(|a| a.cmd.clone()));
|
||||||
let vout = vd
|
let vout = vd
|
||||||
.create(punktfunk_core::Mode {
|
.create(punktfunk_core::Mode {
|
||||||
|
|||||||
@@ -1639,55 +1639,89 @@ fn steam_exe() -> Option<std::path::PathBuf> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the live
|
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
|
||||||
/// session, AFTER capture is up. Used by the GameStream path for the backends that DON'T nest the
|
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
|
||||||
/// command via [`VirtualDisplay::set_launch_command`]: Windows (no gamescope) and Linux
|
/// through the compositor-aware [`launch_session_command`] instead.
|
||||||
/// kwin/mutter/wlroots (which stream the existing desktop). The caller skips this for Linux gamescope,
|
#[cfg(windows)]
|
||||||
/// which already nested it. On Windows it runs in the interactive USER session (the host is SYSTEM);
|
|
||||||
/// on Linux the host is already inside the user's graphical session, so a plain spawn lands the app on
|
|
||||||
/// the streamed (primary) output.
|
|
||||||
#[cfg(any(windows, target_os = "linux"))]
|
|
||||||
pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
|
pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
|
||||||
let cmd = cmd.trim();
|
let cmd = cmd.trim();
|
||||||
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
||||||
#[cfg(windows)]
|
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
|
||||||
{
|
// client-influenced string (same trust as the custom-store `command` kind).
|
||||||
// cmd.exe /c is fine here: the value is the host operator's own apps.json command, not a
|
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
|
||||||
// client-influenced string (same trust as the custom-store `command` kind).
|
.context("spawn gamestream command in the interactive session")?;
|
||||||
let pid = crate::interactive::spawn_in_active_session(&format!("cmd.exe /c {cmd}"), None)
|
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
|
||||||
.context("spawn gamestream command in the interactive session")?;
|
Ok(())
|
||||||
tracing::info!(command = %cmd, pid, "gamestream: launched app in the interactive session");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
let child = std::process::Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(cmd)
|
|
||||||
.spawn()
|
|
||||||
.context("spawn gamestream command")?;
|
|
||||||
tracing::info!(command = %cmd, pid = child.id(), "gamestream: launched app into the session");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
|
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
|
||||||
/// on the `AppEntry`, resolved from the numeric Moonlight appid). Windows spawns it into the interactive
|
/// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user
|
||||||
/// user session ([`launch_title`]); Linux resolves its shell command ([`launch_command`]) and runs it
|
/// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can
|
||||||
/// into the live session ([`launch_gamestream_command`]). The id is resolved against the host's OWN
|
/// only ever pick an existing title — never inject a command. Linux resolves the id via
|
||||||
/// library, so a client can only ever pick an existing title — never inject a command.
|
/// [`launch_command`] and goes through [`launch_session_command`] instead.
|
||||||
#[cfg(any(windows, target_os = "linux"))]
|
#[cfg(windows)]
|
||||||
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
||||||
#[cfg(windows)]
|
launch_title(id)
|
||||||
{
|
}
|
||||||
launch_title(id)
|
|
||||||
}
|
/// Launch a resolved shell command into the **live Linux session** for the session's compositor —
|
||||||
#[cfg(target_os = "linux")]
|
/// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called
|
||||||
{
|
/// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved
|
||||||
let cmd = launch_command(id)
|
/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a
|
||||||
.ok_or_else(|| anyhow::anyhow!("library id '{id}' has no launch recipe"))?;
|
/// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed)
|
||||||
launch_gamestream_command(&cmd)
|
/// desktop/session rather than tearing the stream down.
|
||||||
|
///
|
||||||
|
/// * **KWin / Mutter / wlroots** — the host runs inside the user's graphical session (the process
|
||||||
|
/// env was retargeted at it by `apply_session_env`, and the per-session virtual output is
|
||||||
|
/// promoted primary), so a plain spawn lands the app on the streamed output.
|
||||||
|
/// * **gamescope (managed / SteamOS / attach)** — the app must open *inside* the running gamescope
|
||||||
|
/// session: spawned with the session's own `DISPLAY`/Wayland env
|
||||||
|
/// ([`crate::vdisplay::launch_into_gamescope_session`]). A `steam steam://…` command additionally
|
||||||
|
/// forwards over the running Steam instance's own pipe, so the dominant Steam case is
|
||||||
|
/// env-independent.
|
||||||
|
/// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope
|
||||||
|
/// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> {
|
||||||
|
let cmd = cmd.trim();
|
||||||
|
anyhow::ensure!(!cmd.is_empty(), "empty command");
|
||||||
|
let child = match compositor {
|
||||||
|
crate::vdisplay::Compositor::Gamescope => {
|
||||||
|
crate::vdisplay::launch_into_gamescope_session(cmd)?
|
||||||
|
}
|
||||||
|
_ => std::process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(cmd)
|
||||||
|
.spawn()
|
||||||
|
.context("spawn launch command")?,
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
command = %cmd,
|
||||||
|
pid = child.id(),
|
||||||
|
compositor = compositor.id(),
|
||||||
|
"launched app into the live session"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the launch command for a session app selection on Linux: a store-qualified library id
|
||||||
|
/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an
|
||||||
|
/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option<String> {
|
||||||
|
if let Some(id) = library_id {
|
||||||
|
match launch_command(id) {
|
||||||
|
Some(cmd) => return Some(cmd),
|
||||||
|
None => tracing::warn!(
|
||||||
|
launch_id = id,
|
||||||
|
"requested launch id not in this host's library (or no launch recipe) — ignoring"
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
command
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|c| !c.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
||||||
|
|||||||
@@ -680,36 +680,12 @@ async fn serve_session(
|
|||||||
Punktfunk1Source::Synthetic => None,
|
Punktfunk1Source::Synthetic => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve a requested library launch (the client sends only the store-qualified id;
|
// A requested library launch (the client sends only the store-qualified id; we look it up
|
||||||
// we look it up in OUR library so a client can't inject a command). The bare-spawn gamescope
|
// in OUR library so a client can't inject a command) is resolved below — after the Welcome,
|
||||||
// backend picks this up via the `PUNKTFUNK_GAMESCOPE_APP` env fallback in `spawn` (on a shared
|
// where it's threaded per-session into the data plane as `SessionContext.launch` (no
|
||||||
// desktop / attach-to-existing session it's a harmless no-op). This is the process-global env
|
// process-global env: the old `PUNKTFUNK_GAMESCOPE_APP` write leaked across sessions, and
|
||||||
// path; the write is serialized via `vdisplay::with_env_lock` so concurrent native-session
|
// only gamescope's bare-spawn path ever read it, so launches on every other backend were
|
||||||
// handshakes can't race the `set_var` (security-review 2026-06-28 #7). The remaining
|
// silently dropped).
|
||||||
// cross-session *value* confusion (B's launch id stomping A's pending gamescope spawn) wants
|
|
||||||
// the command resolved into the per-session VirtualDisplay via `set_launch_command` (as the
|
|
||||||
// GameStream path does) — a follow-up; the data-race UB is closed here.
|
|
||||||
if let Some(id) = hello.launch.as_deref() {
|
|
||||||
// Linux: resolve the id to a gamescope-nested command and stash it in the env the
|
|
||||||
// gamescope backend reads. Windows has no gamescope to nest into — the data plane launches
|
|
||||||
// the title into the interactive user session via `library::launch_title` once capture is
|
|
||||||
// live (threaded as `SessionContext.launch` below), so there is nothing to do here.
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
match crate::library::launch_command(id) {
|
|
||||||
Some(cmd) => {
|
|
||||||
tracing::info!(launch_id = id, command = %cmd, "launching library title");
|
|
||||||
crate::vdisplay::with_env_lock(|| {
|
|
||||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_APP", &cmd)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
None => tracing::warn!(
|
|
||||||
launch_id = id,
|
|
||||||
"client requested a launch id not in this host's library — ignoring"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
let _ = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
|
// Resolve the client's gamepad-backend preference (pure env/cfg check — no probing
|
||||||
// needed; the actual pads are created lazily by the input thread).
|
// needed; the actual pads are created lazily by the input thread).
|
||||||
@@ -1101,10 +1077,29 @@ 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;
|
||||||
// Windows: the store-qualified launch id, threaded into the data plane so the title can be
|
// The session's launch, threaded into the data plane. Windows carries the store-qualified id
|
||||||
// launched into the interactive session once capture is live (no gamescope nesting on Windows).
|
// (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
|
||||||
|
// existing title, never send a command — and the data plane runs it per-backend (nested into a
|
||||||
|
// bare-spawn gamescope, or spawned into the live session once capture is up).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let launch_for_dp = hello.launch.clone();
|
let launch_for_dp = hello.launch.clone();
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let launch_for_dp = hello.launch.as_deref().and_then(|id| {
|
||||||
|
match crate::library::launch_command(id) {
|
||||||
|
Some(cmd) => {
|
||||||
|
tracing::info!(launch_id = id, command = %cmd, "resolved library launch for this session");
|
||||||
|
Some(cmd)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
launch_id = id,
|
||||||
|
"client requested a launch id not in this host's library — ignoring"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
|
let bitrate_kbps = welcome.bitrate_kbps; // resolved encoder bitrate (Hello clamped, or default)
|
||||||
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
|
let bit_depth = welcome.bit_depth; // resolved encode bit depth (8, or 10 when negotiated)
|
||||||
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
|
// Resolved chroma — derive the typed value back from the wire byte the Welcome carried (so the
|
||||||
@@ -1180,7 +1175,6 @@ async fn serve_session(
|
|||||||
conn: conn_stream,
|
conn: conn_stream,
|
||||||
stats: stats_dp,
|
stats: stats_dp,
|
||||||
client_label,
|
client_label,
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
launch: launch_for_dp,
|
launch: launch_for_dp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2108,7 +2102,8 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
|
|||||||
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
|
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
|
||||||
})?;
|
})?;
|
||||||
if !overridden {
|
if !overridden {
|
||||||
// Point input at the same backend and select gamescope ATTACH (no churny managed restart).
|
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
|
||||||
|
// session infra exists, attach to a foreign gamescope, else per-session bare spawn).
|
||||||
crate::vdisplay::apply_input_env(chosen);
|
crate::vdisplay::apply_input_env(chosen);
|
||||||
}
|
}
|
||||||
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
|
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
|
||||||
@@ -2747,10 +2742,10 @@ struct SessionContext {
|
|||||||
/// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on
|
/// Short client label (cert-fingerprint prefix, else peer IP) seeded into the capture meta on
|
||||||
/// the first armed stats registration.
|
/// the first armed stats registration.
|
||||||
client_label: String,
|
client_label: String,
|
||||||
/// Windows: the store-qualified library id to launch into the interactive user session once
|
/// The session's requested launch, `None` = none. On Windows the store-qualified library id
|
||||||
/// capture is live (no gamescope nesting on Windows). `None` = no launch requested. Linux uses the
|
/// (spawned into the interactive user session once capture is live); on other hosts the shell
|
||||||
/// gamescope `PUNKTFUNK_GAMESCOPE_APP` path resolved at handshake, so this field is Windows-only.
|
/// command already resolved against the host's own library — nested into gamescope's bare spawn
|
||||||
#[cfg(target_os = "windows")]
|
/// via `set_launch_command`, or spawned into the live session once capture is up.
|
||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2785,7 +2780,6 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
conn,
|
conn,
|
||||||
stats,
|
stats,
|
||||||
client_label,
|
client_label,
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
launch,
|
launch,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -2804,6 +2798,12 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends
|
// reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends
|
||||||
// and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
|
// and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
|
||||||
vd.set_client_identity(endpoint::peer_fingerprint(&conn));
|
vd.set_client_identity(endpoint::peer_fingerprint(&conn));
|
||||||
|
// Per-session launch (non-Windows): hand the resolved command to the backend instance so
|
||||||
|
// gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions
|
||||||
|
// can't stomp each other's launch target. The other backends' default `set_launch_command` is a
|
||||||
|
// no-op; they get the command spawned into the live session after capture is up (below).
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
vd.set_launch_command(launch.clone());
|
||||||
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a
|
// IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a
|
||||||
// reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it
|
// reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it
|
||||||
// to release its monitor (instead of tearing a monitor out from under a still-live session), and
|
// to release its monitor (instead of tearing a monitor out from under a still-live session), and
|
||||||
@@ -2819,16 +2819,29 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
drop(_idd_setup_guard);
|
drop(_idd_setup_guard);
|
||||||
|
|
||||||
// Windows: capture is live — launch the requested library title into the
|
// Capture is live — launch the requested title so it renders onto the streamed output and
|
||||||
// interactive user session so it renders onto the captured desktop and grabs foreground. Linux
|
// grabs focus. Windows spawns the library id into the interactive user session; Linux spawns
|
||||||
// nests its launch in gamescope instead (the handshake `PUNKTFUNK_GAMESCOPE_APP` path). Best-effort:
|
// the resolved command into the live session for every backend that didn't already nest it
|
||||||
// a launch failure (no recipe for the kind, no interactive user) leaves the user on the desktop.
|
// (gamescope's bare spawn ran it inside the fresh gamescope — launching again would start it
|
||||||
|
// twice). Best-effort: a launch failure (no recipe, launcher missing, no interactive user)
|
||||||
|
// leaves the user on the streamed desktop/session, never tears the stream down. Launched ONCE
|
||||||
|
// here — the mid-stream rebuild paths below must not re-spawn it.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
if let Some(id) = launch.as_deref() {
|
if let Some(id) = launch.as_deref() {
|
||||||
if let Err(e) = crate::library::launch_title(id) {
|
if let Err(e) = crate::library::launch_title(id) {
|
||||||
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
|
tracing::warn!(launch_id = id, error = %e, "could not launch requested library title");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(cmd) = launch.as_deref() {
|
||||||
|
if crate::vdisplay::launch_is_nested(compositor) {
|
||||||
|
tracing::info!(command = %cmd, "launch nested into the per-session gamescope");
|
||||||
|
} else if let Err(e) = crate::library::launch_session_command(compositor, cmd) {
|
||||||
|
tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||||
|
let _ = &launch;
|
||||||
|
|
||||||
let perf = crate::config::config().perf;
|
let perf = crate::config::config().perf;
|
||||||
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately;
|
// Microburst cap (applied in send_loop/paced_submit): a frame ≤ this bursts out immediately;
|
||||||
|
|||||||
@@ -369,9 +369,10 @@ fn find_wayland_socket(runtime: &str, uid: u32) -> Option<String> {
|
|||||||
/// the default concurrent native sessions each running `resolve_compositor` in its own
|
/// the default concurrent native sessions each running `resolve_compositor` in its own
|
||||||
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
|
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
|
||||||
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
|
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
|
||||||
/// streaming reads cached config, not env. This removes the memory-unsafety; it is NOT a full fix
|
/// streaming reads cached config, not env. This removes the memory-unsafety; the launch command is
|
||||||
/// for cross-session env *value* confusion (that needs per-session `SessionContext` threading, as the
|
/// additionally threaded per-session (`SessionContext.launch` → `set_launch_command`) so it never
|
||||||
/// GameStream/Windows path already does via `set_launch_command`).
|
/// rides the process env at all — the remaining knobs here (session retarget, gamescope sub-mode)
|
||||||
|
/// still carry a cross-session *value* confusion window inherent to a process-global env.
|
||||||
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
|
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
|
||||||
@@ -384,7 +385,7 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
|
|||||||
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
|
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
|
||||||
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
|
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
|
||||||
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
|
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
|
||||||
/// re-applies. Same `set_var` discipline used for `PUNKTFUNK_GAMESCOPE_APP` on the launch path.
|
/// re-applies.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn apply_session_env(active: &ActiveSession) {
|
pub fn apply_session_env(active: &ActiveSession) {
|
||||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
@@ -468,14 +469,57 @@ pub fn settle_desktop_portal(chosen: Compositor) {
|
|||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn settle_desktop_portal(_chosen: Compositor) {}
|
pub fn settle_desktop_portal(_chosen: Compositor) {}
|
||||||
|
|
||||||
|
/// How a gamescope-backed session is realized. Chosen per connect by [`pick_gamescope_mode`],
|
||||||
|
/// written into the env knobs `GamescopeDisplay::create` dispatches on.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum GamescopeMode {
|
||||||
|
/// Host-managed `gamescope-session-plus` / SteamOS session at the client's mode.
|
||||||
|
Managed,
|
||||||
|
/// Attach to an already-running gamescope (capture + inject, no lifecycle ownership).
|
||||||
|
Attach,
|
||||||
|
/// Bare-spawn a headless gamescope per session, nesting the session's launch command.
|
||||||
|
Spawn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure sub-mode ladder for gamescope (unit-testable — the env/probe inputs are parameters):
|
||||||
|
/// explicit `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed; explicit ATTACH/NODE forces attach; an
|
||||||
|
/// operator-set `PUNKTFUNK_GAMESCOPE_SESSION` keeps managed; otherwise managed only **when the box
|
||||||
|
/// actually has the session infrastructure** (gamescope-session-plus / SteamOS — the old code
|
||||||
|
/// defaulted to managed unconditionally and then bailed on a plain distro, killing the session);
|
||||||
|
/// a foreign (not host-spawned) gamescope on an infra-less box is attached to; and the final
|
||||||
|
/// default is a per-session bare spawn — the path that nests the client's launch command.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn pick_gamescope_mode(
|
||||||
|
force_managed: bool,
|
||||||
|
attach_env: bool,
|
||||||
|
node_env: bool,
|
||||||
|
session_env: bool,
|
||||||
|
managed_infra: bool,
|
||||||
|
foreign_gamescope: bool,
|
||||||
|
) -> GamescopeMode {
|
||||||
|
if force_managed {
|
||||||
|
GamescopeMode::Managed
|
||||||
|
} else if attach_env || node_env {
|
||||||
|
GamescopeMode::Attach
|
||||||
|
} else if session_env || managed_infra {
|
||||||
|
GamescopeMode::Managed
|
||||||
|
} else if foreign_gamescope {
|
||||||
|
GamescopeMode::Attach
|
||||||
|
} else {
|
||||||
|
GamescopeMode::Spawn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
|
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
|
||||||
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope, the **default is a managed
|
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors. For gamescope the sub-mode ladder
|
||||||
/// session at the client's mode** (tears the TV's autologin down on connect; restored on a debounced
|
/// ([`pick_gamescope_mode`]) selects **managed** (a host-managed session at the client's mode —
|
||||||
/// idle) — so the client gets ITS resolution (capture == encode == client mode), not the TV's, and a
|
/// tears the TV's autologin down on connect, restored on a debounced idle; only where
|
||||||
/// quick reconnect reuses the warm session (no churn). Opt out to **attach** (mirror the running TV
|
/// session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at its own mode;
|
||||||
/// session at its own mode, gaming stays live on the panel, no Steam restart) with
|
/// explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a
|
||||||
/// `PUNKTFUNK_GAMESCOPE_ATTACH`; an explicit `PUNKTFUNK_GAMESCOPE_NODE` also implies attach, and
|
/// foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless gamescope
|
||||||
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over either.
|
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
|
||||||
|
/// forces managed over all of it.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn apply_input_env(chosen: Compositor) {
|
pub fn apply_input_env(chosen: Compositor) {
|
||||||
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
@@ -490,26 +534,61 @@ pub fn apply_input_env(chosen: Compositor) {
|
|||||||
};
|
};
|
||||||
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
|
||||||
if chosen == Compositor::Gamescope {
|
if chosen == Compositor::Gamescope {
|
||||||
let force_managed = std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some();
|
let mode = pick_gamescope_mode(
|
||||||
let attach = !force_managed
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
|
||||||
&& (std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some()
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
|
||||||
|| std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some());
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
|
||||||
if attach {
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_some(),
|
||||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
gamescope::managed_session_available(),
|
||||||
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
|
gamescope::foreign_gamescope_running(),
|
||||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
|
);
|
||||||
|
tracing::info!(?mode, "gamescope sub-mode");
|
||||||
|
match mode {
|
||||||
|
GamescopeMode::Attach => {
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
||||||
|
if std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none() {
|
||||||
|
std::env::set_var("PUNKTFUNK_GAMESCOPE_NODE", "auto");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
GamescopeMode::Managed => {
|
||||||
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
|
if std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none() {
|
||||||
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
|
std::env::set_var("PUNKTFUNK_GAMESCOPE_SESSION", "steam");
|
||||||
|
}
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
||||||
|
}
|
||||||
|
GamescopeMode::Spawn => {
|
||||||
|
// Bare spawn: `create` must fall through to the spawn path, so neither knob may
|
||||||
|
// linger from an earlier connect's managed/attach selection.
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_SESSION");
|
||||||
|
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
||||||
}
|
}
|
||||||
std::env::remove_var("PUNKTFUNK_GAMESCOPE_NODE");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
pub fn apply_input_env(_chosen: Compositor) {}
|
pub fn apply_input_env(_chosen: Compositor) {}
|
||||||
|
|
||||||
|
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
|
||||||
|
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
|
||||||
|
/// into the session — it would start twice. Read AFTER [`apply_input_env`] resolved the gamescope
|
||||||
|
/// sub-mode (the env knobs are that resolution's output).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn launch_is_nested(compositor: Compositor) -> bool {
|
||||||
|
compositor == Compositor::Gamescope
|
||||||
|
&& with_env_lock(|| {
|
||||||
|
std::env::var_os("PUNKTFUNK_GAMESCOPE_SESSION").is_none()
|
||||||
|
&& std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_none()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch `cmd` into the live gamescope session (managed/attach — see
|
||||||
|
/// [`gamescope::launch_into_session`]). Split out so `library.rs` doesn't reach into the private
|
||||||
|
/// backend module.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
||||||
|
gamescope::launch_into_session(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
|
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
|
||||||
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
|
||||||
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||||
@@ -692,6 +771,28 @@ mod tests {
|
|||||||
assert_eq!(compositor_for_kind(ActiveKind::None), None);
|
assert_eq!(compositor_for_kind(ActiveKind::None), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn gamescope_mode_ladder() {
|
||||||
|
use GamescopeMode::*;
|
||||||
|
let pick = pick_gamescope_mode;
|
||||||
|
// (force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
|
||||||
|
// Plain distro, nothing running: bare spawn — the path that nests the launch command.
|
||||||
|
assert_eq!(pick(false, false, false, false, false, false), Spawn);
|
||||||
|
// Bazzite/SteamOS (session infra present): managed, as validated live.
|
||||||
|
assert_eq!(pick(false, false, false, false, true, false), Managed);
|
||||||
|
assert_eq!(pick(false, false, false, false, true, true), Managed);
|
||||||
|
// Foreign gamescope on an infra-less box: attach and mirror it.
|
||||||
|
assert_eq!(pick(false, false, false, false, false, true), Attach);
|
||||||
|
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
|
||||||
|
assert_eq!(pick(false, false, false, true, false, false), Managed);
|
||||||
|
// Explicit attach/node wins over infra…
|
||||||
|
assert_eq!(pick(false, true, false, false, true, false), Attach);
|
||||||
|
assert_eq!(pick(false, false, true, true, true, false), Attach);
|
||||||
|
// …and force-managed wins over everything.
|
||||||
|
assert_eq!(pick(true, true, true, false, false, false), Managed);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_active_session_is_side_effect_free_and_terminates() {
|
fn detect_active_session_is_side_effect_free_and_terminates() {
|
||||||
// A pure probe of /proc + the runtime dir: it must not panic and must return promptly on
|
// A pure probe of /proc + the runtime dir: it must not panic and must return promptly on
|
||||||
|
|||||||
@@ -233,6 +233,165 @@ fn steamos_session_present() -> bool {
|
|||||||
&& !std::path::Path::new(SESSION_PLUS_BIN).exists()
|
&& !std::path::Path::new(SESSION_PLUS_BIN).exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does this box have the infrastructure the MANAGED gamescope mode drives — Bazzite's
|
||||||
|
/// `gamescope-session-plus` or SteamOS's `gamescope-session`? The sub-mode ladder
|
||||||
|
/// ([`crate::vdisplay::apply_input_env`]) only defaults to managed when this is true; a plain
|
||||||
|
/// distro (neither present) falls through to the bare-spawn path instead of the old behaviour of
|
||||||
|
/// defaulting to managed and then bailing on the missing session script.
|
||||||
|
pub fn managed_session_available() -> bool {
|
||||||
|
std::path::Path::new(SESSION_PLUS_BIN).exists()
|
||||||
|
|| std::path::Path::new(STEAMOS_SESSION_BIN).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is a gamescope WE DIDN'T SPAWN running for our uid right now? Used by the sub-mode ladder to
|
||||||
|
/// pick ATTACH (mirror the foreign session) over a bare spawn on a box without managed-session
|
||||||
|
/// infra. Our own per-session bare-spawn gamescopes are children of this host process — excluded by
|
||||||
|
/// walking each candidate's ppid chain — so one client's nested gamescope never makes the next
|
||||||
|
/// client attach to it.
|
||||||
|
pub fn foreign_gamescope_running() -> bool {
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
let our_pid = std::process::id();
|
||||||
|
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let name = e.file_name();
|
||||||
|
let Some(pid_str) = name.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(pid) = pid_str.parse::<u32>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(md) = std::fs::metadata(e.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
if md.uid() != uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(comm) = std::fs::read_to_string(e.path().join("comm")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !matches!(comm.trim(), "gamescope" | "gamescope-wl") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !descends_from(pid, our_pid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is `pid` a descendant of (or equal to) `ancestor`? Walks the ppid chain via `/proc/<pid>/stat`
|
||||||
|
/// with a hop cap so a racing/exiting process can't loop us.
|
||||||
|
fn descends_from(mut pid: u32, ancestor: u32) -> bool {
|
||||||
|
for _ in 0..64 {
|
||||||
|
if pid == ancestor {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if pid <= 1 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Field 4 (ppid) follows the parenthesized comm — split after the LAST ')' since comm can
|
||||||
|
// itself contain parentheses.
|
||||||
|
let Some(rest) = stat.rsplit_once(')').map(|(_, r)| r) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(ppid) = rest.split_whitespace().nth(1).and_then(|s| s.parse().ok()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
pid = ppid;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch `cmd` INTO the live gamescope session (the managed / SteamOS / attach modes, where the
|
||||||
|
/// session already exists and [`spawn`]'s nesting doesn't apply). The child gets the session's own
|
||||||
|
/// `DISPLAY` (gamescope's Xwayland) and Wayland socket, discovered from a process already inside the
|
||||||
|
/// session — so X11 and Wayland clients alike land on the streamed gamescope output. Discovery is
|
||||||
|
/// best-effort: without it we still spawn with the host env and warn (a `steam steam://…` launch
|
||||||
|
/// still works there — the running Steam instance picks the URI up over its own pipe, no display
|
||||||
|
/// env needed).
|
||||||
|
pub fn launch_into_session(cmd: &str) -> Result<std::process::Child> {
|
||||||
|
let mut c = Command::new("sh");
|
||||||
|
c.arg("-c").arg(cmd);
|
||||||
|
match discover_session_display_env() {
|
||||||
|
Some((x11, wayland)) => {
|
||||||
|
tracing::info!(
|
||||||
|
command = %cmd,
|
||||||
|
x11_display = x11.as_deref().unwrap_or("-"),
|
||||||
|
wayland = wayland.as_deref().unwrap_or("-"),
|
||||||
|
"gamescope: launching into the live session"
|
||||||
|
);
|
||||||
|
if let Some(d) = x11 {
|
||||||
|
c.env("DISPLAY", d);
|
||||||
|
}
|
||||||
|
if let Some(w) = wayland {
|
||||||
|
c.env("WAYLAND_DISPLAY", w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => tracing::warn!(
|
||||||
|
command = %cmd,
|
||||||
|
"gamescope: could not discover the session's display env — spawning with the host env \
|
||||||
|
(a `steam steam://…` launch still reaches the running Steam; other apps may not land \
|
||||||
|
in the session)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
c.spawn()
|
||||||
|
.context("spawn launch command into gamescope session")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the live gamescope session's `(DISPLAY, WAYLAND_DISPLAY)` by scanning same-uid processes
|
||||||
|
/// for one whose environment carries `GAMESCOPE_WAYLAND_DISPLAY` (gamescope sets it for everything
|
||||||
|
/// it runs — Steam, the game, our own nested `sh`). The Wayland value returned is that gamescope
|
||||||
|
/// socket; `DISPLAY` is the nested Xwayland. Either can be individually absent.
|
||||||
|
fn discover_session_display_env() -> Option<(Option<String>, Option<String>)> {
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
for e in std::fs::read_dir("/proc").ok()?.flatten() {
|
||||||
|
let name = e.file_name();
|
||||||
|
let Some(pid_str) = name.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !pid_str.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(md) = std::fs::metadata(e.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
if md.uid() != uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(raw) = std::fs::read(e.path().join("environ")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut display = None;
|
||||||
|
let mut gs_wayland = None;
|
||||||
|
for kv in raw.split(|&b| b == 0) {
|
||||||
|
let kv = String::from_utf8_lossy(kv);
|
||||||
|
if let Some(v) = kv.strip_prefix("GAMESCOPE_WAYLAND_DISPLAY=") {
|
||||||
|
if !v.is_empty() {
|
||||||
|
gs_wayland = Some(v.to_string());
|
||||||
|
}
|
||||||
|
} else if let Some(v) = kv.strip_prefix("DISPLAY=") {
|
||||||
|
if !v.is_empty() {
|
||||||
|
display = Some(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only a process INSIDE a gamescope session (it has the marker var) is a valid source.
|
||||||
|
if gs_wayland.is_some() {
|
||||||
|
return Some((display, gs_wayland));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a `systemctl --user` subcommand best-effort — a failure just means the session won't change,
|
/// Run a `systemctl --user` subcommand best-effort — a failure just means the session won't change,
|
||||||
/// which the caller's node-wait surfaces.
|
/// which the caller's node-wait surfaces.
|
||||||
fn systemctl_user(args: &[&str]) {
|
fn systemctl_user(args: &[&str]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user