diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 543bb1e9..1d3b20ff 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -87,9 +87,10 @@ pub use session::{session_epoch, try_recover_session}; #[path = "vdisplay/routing.rs"] pub(crate) mod routing; pub use routing::{ - apply_input_env, managed_session_available, resolve_gamescope_route, restore_managed_session, - restore_takeover_now, restore_takeover_on_startup, start_restore_worker, - wants_dedicated_game_session, GamescopeRoute, + apply_input_env, managed_session_available, preflight_takeover_privilege, + resolve_gamescope_route, restore_managed_session, restore_takeover_now, + restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session, + GamescopeRoute, }; #[cfg(target_os = "linux")] pub use routing::{ diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 53538899..68d4613b 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -218,6 +218,12 @@ fn clear_takeover() { /// box back to gaming mode). No-op when no takeover file exists (a clean start). Call once from /// `serve` alongside [`start_restore_worker`]. pub fn restore_takeover_on_startup() { + // A stale gamescope bind drop-in means a previous host died mid-stream (a clean exit removes + // it via [`restore_takeover_now`]). Unlike the takeover statics it is a FILE on the operator's + // box, so it outlives the process that wrote it and would otherwise keep reshaping the box's + // own game mode with no host to own it. Drop-ins apply at unit start, so removing it here + // cannot disturb a session that is already running. + remove_session_bind_dropin(); let Ok(bytes) = std::fs::read(takeover_state_path()) else { return; // no takeover file — clean start }; @@ -1084,6 +1090,17 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result { &format!("SCREEN_HEIGHT={}", mode.height), &format!("CUSTOM_REFRESH_RATES={}", mode.refresh_hz.max(1)), ]); + // On a box whose session script hardcodes an absolute gamescope path, this restart is also the + // one chance to make that path mean the patched binary — the drop-in only takes effect at unit + // start. It is a no-op everywhere the `GAMESCOPE_BIN` wrapper already works, and on a box that + // cannot take the bind at all ([`session_bind_setting`] returns `None`), so the restart below + // is unchanged from today's behaviour in both of those cases. + // + // HDR is deliberately not requested here. This is the DEGRADED attach path — the host does not + // own this session's lifecycle, never claimed its capabilities, and does not run + // `verify_managed_spawn_flags` against it — so the bind is used for the one thing that is + // unambiguously right either way: the client's refresh rate reaching the compositor. + let mut bound = arm_session_bind_dropin(game_hz(mode.refresh_hz)); systemctl_user(&["restart", &unit]); // Wait for the relaunched session to come up at the new size and publish its capture node. The // node appears when gamescope is up (well before Steam finishes booting); the caller's @@ -1101,6 +1118,27 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result { return Ok(node); } } + // The bind's one catastrophic failure mode, on the unit where it would hurt most: this is + // the box's OWN game mode, so a unit that cannot build its mount namespace leaves the + // operator with no Game Mode until someone deletes a file by hand. Take the drop-in back + // out (which reloads systemd) and restart plain — the box lands exactly where it would + // have without this mechanism. + // + // Only once the unit has actually SETTLED: `ExecMainStatus` reports the last main process + // to exit, so consulting it mid-`activating` can read the previous run's number and disarm + // a bind that is working. + if bound && !unit_starting_or_active(&unit) && unit_failed_namespace(&unit) { + tracing::warn!( + %unit, + "gamescope: the box's game-mode unit could not be given a mount namespace \ + (exit {EXIT_NAMESPACE}) — removing the gamescope bind drop-in and restarting \ + without it" + ); + note_session_bind_unusable(); + remove_session_bind_dropin(); + bound = false; + systemctl_user(&["restart", &unit]); + } if Instant::now() >= deadline { bail!( "box game-mode session did not come up at {}x{} within 45s after relaunch \ @@ -1303,19 +1341,31 @@ fn verify_managed_spawn_flags(hdr: bool) -> Result<()> { return Ok(()); } note_spawn_flags_lost(); + // This check is also the post-hoc oracle for the mount-namespace bind ([`session_bind_setting`]): + // a unit that FAILED to start is a different fault with its own detector ([`unit_failed_namespace`]), + // but a unit that started and still has no flags means the bind was armed and did not deliver + // — and an operator told to "install punktfunk-gamescope" on a box where it is already + // installed and bound would be sent the wrong way, which is the mistake this file has made + // before. So say which of the two mechanisms was actually in play. + let route = if session_bind_setting().is_some() { + "the session's hardcoded gamescope path was bind-mounted onto our wrapper and the flags \ + still did not arrive" + } else { + "it ignored GAMESCOPE_BIN / the PATH shim and ran a stock gamescope" + }; // Warn as well as erroring: the latch is a process-wide capability change, and whichever // caller consumes this error decides on its own how loudly to report it. tracing::warn!( missing = %missing.join(" "), - "gamescope: the session ignored GAMESCOPE_BIN / the PATH shim and ran a stock gamescope — \ - HDR and the in-node cursor are now off for this host process" + %route, + "gamescope: the session did not receive our flags — HDR and the in-node cursor are now off \ + for this host process" ); Err(anyhow!( - "the gamescope session started without {} — it ignored GAMESCOPE_BIN / the PATH shim and \ - ran a stock gamescope. Refusing it rather than streaming a session whose shape was \ - planned around flags that never arrived (a missing cursor flag has no symptom but an \ - absent pointer). Those capabilities are off for this host now; reconnect for a plain SDR \ - session, or install punktfunk-gamescope as the box's `gamescope`", + "the gamescope session started without {} — {route}. Refusing it rather than streaming a \ + session whose shape was planned around flags that never arrived (a missing cursor flag \ + has no symptom but an absent pointer). Those capabilities are off for this host now; \ + reconnect for a plain SDR session, or install punktfunk-gamescope as the box's `gamescope`", missing.join(" ") )) } @@ -1489,21 +1539,261 @@ const DM_HELPER_PATHS: &[&str] = &[ "/usr/lib/punktfunk/pf-dm-helper", ]; -/// Run the packaged DM helper (`stop` | `restore` | `linger`) via pkexec. `false` when the helper -/// isn't installed (tarball/old package), pkexec is missing, or polkit denies the action. -fn dm_helper(verb: &str) -> bool { - let Some(helper) = DM_HELPER_PATHS +/// The group `pf-dm-helper` authorizes on. The polkit action has to stay `allow_any` (the host +/// commonly runs as a LINGERING user unit, which has no logind session for polkit to classify), so +/// the helper makes the real decision itself: only a member of this group may run the verbs. Every +/// package CREATES the group and adds NOBODY to it — writing the usbip `attach` node it also gates +/// materialises arbitrary emulated USB hardware, so joining stays a deliberate act. +const DM_HELPER_GROUP: &str = "punktfunk"; + +/// The packaged helper on this box, if any (see [`DM_HELPER_PATHS`]). +fn installed_dm_helper() -> Option<&'static str> { + DM_HELPER_PATHS .iter() + .copied() .find(|p| std::path::Path::new(p).exists()) - else { - return false; +} + +/// Why the packaged DM helper did not perform a verb. +/// +/// This used to be a bare `false`, and that is the whole reason a Nobara box spent a release +/// telling its owner to "reinstall the punktfunk package, or install the display-manager polkit +/// rule from the docs" while the true cause was **group membership** — which neither remedy +/// touches (field, Nobara 44 / 0.27.0-0.ci12635, 2026-08-09). The polkit action was installed, +/// `allow_any`, annotated at the right path, and pkexec DID run the helper; the helper then +/// printed the exact fix on stderr and `.status()` threw it away, leaving the caller to guess — +/// and guess wrong, silently, on every connect (the takeover degrades to attach, so nothing fails +/// loudly). +/// +/// The four shapes are kept apart because they need four different fixes, and because a helper +/// that could not even be EXECUTED must never masquerade as one that ran and refused. +enum DmHelperError { + /// Nothing to run: no packaged helper on this box (a tarball/source install, or a package + /// older than the helper). The polkit-rule route from the docs applies; the group does not. + NotInstalled, + /// The helper is installed but `pkexec` could not be spawned at all (no polkit on this box, or + /// the spawn failed). Nothing evaluated the request, so nothing refused it. + NotExecutable { helper: &'static str, io: String }, + /// `pkexec` itself refused before the helper's own gate: the action is missing/overridden, or + /// the authorization could not be obtained. 126/127 are pkexec's OWN exit codes — the helper + /// only ever exits 0, 1 or 2 — so this is distinguishable from a refusal. + Denied { + helper: &'static str, + code: i32, + stderr: String, + }, + /// The helper RAN and failed, and said why on stderr. That text is the answer: it names the + /// user, the group, and the `usermod` line that fixes it. Pass it through verbatim rather than + /// inventing a diagnosis on top of it. + Refused { + helper: &'static str, + code: Option, + stderr: String, + }, +} + +impl std::fmt::Display for DmHelperError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotInstalled => write!( + f, + "no packaged pf-dm-helper on this box (looked in {}) — install the punktfunk \ + package, or add a display-manager polkit rule for your user (see \ + https://docs.punktfunk.unom.io/docs/gamescope)", + DM_HELPER_PATHS.join(" and ") + ), + Self::NotExecutable { helper, io } => write!( + f, + "{helper} is installed but could not be run via pkexec ({io}) — this box appears \ + to have no polkit; add a display-manager polkit rule for your user instead (see \ + https://docs.punktfunk.unom.io/docs/gamescope)" + ), + Self::Denied { + helper, + code, + stderr, + } => write!( + f, + "pkexec never ran {helper} (exit {code}{}) — polkit did not authorize \ + io.unom.punktfunk.dm-helper, so the action is missing or overridden; reinstall \ + the punktfunk package, or add a display-manager polkit rule for your user (see \ + https://docs.punktfunk.unom.io/docs/gamescope)", + suffix(stderr) + ), + Self::Refused { + helper, + code, + stderr, + } if stderr.is_empty() => write!( + f, + "{helper} ran and failed (exit {}) without printing a reason", + code.map(|c| c.to_string()) + .unwrap_or_else(|| "signal".to_string()) + ), + Self::Refused { helper, stderr, .. } => { + write!(f, "{helper} ran and refused: {stderr}") + } + } + } +} + +/// `": "`, or nothing at all when there is no text — so a message never ends in a dangling +/// colon on a helper that said nothing. +fn suffix(stderr: &str) -> String { + if stderr.is_empty() { + String::new() // no dangling ": " when the child was silent + } else { + format!(": {stderr}") + } +} + +/// Run the packaged DM helper (`stop` | `restore` | `linger`) via pkexec. +/// +/// **Captures stderr and keeps the exit code** ([`DmHelperError`]): the helper's own message is +/// the only place the actionable cause exists (group membership, a missing +/// `display-manager.service` alias), and every caller here turns a failure into text an operator +/// reads. `output()` rather than `status()` also gives the child a NULL stdin, so a pkexec that +/// decides to prompt gets EOF immediately instead of blocking a stream thread on a tty read it can +/// never satisfy. +/// +/// Deliberately unbounded in time: the `stop`/`restore` verbs shell out to `systemctl`, whose stop +/// job legitimately takes seconds on a busy seat, and a budget here would kill the takeover +/// mid-flight rather than diagnose it. +fn dm_helper(verb: &str) -> std::result::Result<(), DmHelperError> { + let Some(helper) = installed_dm_helper() else { + return Err(DmHelperError::NotInstalled); }; - Command::new("pkexec") + let out = Command::new("pkexec") .arg(helper) .arg(verb) - .status() - .map(|s| s.success()) - .unwrap_or(false) + .output() + .map_err(|e| DmHelperError::NotExecutable { + helper, + io: e.to_string(), + })?; + if out.status.success() { + return Ok(()); + } + // One line: these land in a `tracing` field, and the helper's two-line refusal (reason + + // `Grant it with: …`) has to survive the trip intact. + let stderr = String::from_utf8_lossy(&out.stderr) + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" "); + match out.status.code() { + // pkexec's own codes: 127 "not authorized / could not execute the program", 126 + // "authentication dialog dismissed". The helper only ever exits 0, 1 or 2, so either of + // these means the request never reached its group gate. + Some(c @ (126 | 127)) => Err(DmHelperError::Denied { + helper, + code: c, + stderr, + }), + // `None` = killed by a signal, and [`DmHelperError::Refused`] renders that as "signal" + // rather than inventing a plausible-looking exit code. + code => Err(DmHelperError::Refused { + helper, + code, + stderr, + }), + } +} + +/// Startup preflight for the takeover's one prerequisite nothing can automate: this user being in +/// the `punktfunk` group. +/// +/// The failure it catches is **silent by construction**. Missing membership doesn't fail a unit, +/// doesn't fail the connect and doesn't fail the stream: the takeover simply degrades to ATTACH +/// (or, under SDDM, to mask-only), which on a box whose panel is off reads as "a black screen on +/// every connect" and nothing else. It also only ever surfaces mid-stream, in a warn line nobody +/// is watching while they're trying to play. So say it once, at startup, where an operator +/// actually reads the log — and say it with the command that fixes it. +/// +/// Deliberately **not** unconditional; a box that will never attempt a takeover must not be +/// nagged. Four gates, each of which alone makes the group irrelevant: +/// * **root** — the plain system-bus `systemctl` verbs succeed, so the helper is never reached; +/// * **no display manager** — [`dm_plan`] only stops a DM that exists, and a getty-autologin / +/// enabled-user-unit box has none; +/// * **no gamescope session infrastructure** ([`managed_session_available`]) — no +/// `gamescope-session-plus`/SteamOS means no autologin gaming session to free, so +/// [`stop_autologin_sessions`] returns before it looks at the DM at all; +/// * **no packaged helper** — a tarball/source/Nix install has neither the helper nor the group, +/// and its route is the hand-written polkit rule from the docs, which this group has no part in. +/// +/// Membership is read from the **user database**, not from our own `getgroups()`, because that is +/// what the gate we are predicting reads: `pf-dm-helper` runs as root and resolves the caller's +/// groups with `id -nG `. A `usermod -aG` therefore satisfies the helper immediately — but +/// the remedy still says to log back in, because the same group gates the usbip nodes the virtual +/// Steam Deck pad attaches through, and THAT is a credential check against this process, whose +/// supplementary groups were fixed when its `systemd --user` manager started. +pub fn preflight_takeover_privilege() { + if crate::proc::current_uid() == 0 { + return; // root: `systemctl stop ` succeeds outright, the helper is never consulted + } + let Some(dm) = display_manager_unit() else { + return; // no DM drives this box's logins — nothing for the takeover to stop + }; + if !managed_session_available() { + return; // no session-plus/SteamOS ⇒ no autologin gaming session ⇒ no takeover + } + let Some(helper) = installed_dm_helper() else { + return; // unpackaged install: no helper, no group, the polkit-rule route applies instead + }; + let Some(user) = current_user_name() else { + return; // cannot name the user ⇒ cannot give a usable `usermod` line; stay quiet + }; + let group = DM_HELPER_GROUP; + if user_in_group(&user, group) { + return; + } + tracing::warn!( + %user, + %dm, + helper, + group, + "gamescope: the managed takeover on this box has to stop {dm} for a stream, which runs \ + through {helper} — and that helper only serves members of the '{group}' group, which \ + '{user}' is not in. Every takeover will degrade silently: the stream mirrors the box's \ + own session instead, which with the panel off looks like a black screen on every \ + connect. Fix it once with `sudo usermod -aG {group} {user}`, then log out and back in — \ + a `systemd --user` session keeps the group set it started with, and the same group gates \ + the virtual Steam Deck pad's usbip nodes. It can present arbitrary emulated USB devices, \ + so join it only on a machine you trust." + ); +} + +/// This process's login name, for a `usermod` line the operator can paste. From `id -un ` +/// rather than `$USER`: a `systemd --user` unit's environment is whatever the manager was started +/// with, and the uid is the thing pkexec will actually resolve. +fn current_user_name() -> Option { + let out = crate::proc::output_within( + Command::new("id").args(["-un", &uid_string()]), + Duration::from_secs(5), + ) + .ok()?; + let name = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (out.status.success() && !name.is_empty()).then_some(name) +} + +/// Is `user` in `group` **according to the user database** — the same question, asked the same +/// way, that `pf-dm-helper` answers as root before it will do anything. Budgeted: `id` resolves +/// through NSS, which on a box with a remote directory can block, and this runs on the startup +/// path. +fn user_in_group(user: &str, group: &str) -> bool { + let Ok(out) = crate::proc::output_within( + Command::new("id").args(["-nG", user]), + Duration::from_secs(5), + ) else { + return true; // couldn't ask ⇒ don't accuse: a false alarm here sends people down a wrong path + }; + if !out.status.success() { + return true; + } + String::from_utf8_lossy(&out.stdout) + .split_whitespace() + .any(|g| g == group) } /// `systemctl` on the SYSTEM bus, **never interactively**. Every privileged verb below runs on the @@ -1536,12 +1826,17 @@ fn systemctl_system(args: &[&str]) -> bool { /// what breaks the dependency: logind keeps the user manager up with no session at all. So ensure /// it BEFORE touching the DM, and refuse the takeover when it can't be ensured — the caller then /// degrades to attach, which mirrors the box's own session and never stops the DM. -fn ensure_host_survives_dm_stop() -> bool { +/// +/// `Err` carries **why** it could not be ensured, because the helper path is reached here first: +/// on a sessionless host the `linger` verb goes through the same [`dm_helper`] gate the `stop` +/// verb does, so a user outside the `punktfunk` group fails at THIS step and never reaches the +/// DM-stop one. Dropping the reason here would just move the misdiagnosis one message earlier. +fn ensure_host_survives_dm_stop() -> std::result::Result<(), String> { if !host_is_under_user_manager() { - return true; // root / a system unit — the DM stop cannot reach us + return Ok(()); // root / a system unit — the DM stop cannot reach us } if linger_enabled() { - return true; + return Ok(()); } // `set-self-linger` is `allow_active` in logind's own policy, so a host started inside the // user's session can do this itself; a sessionless one (the packaged unit) goes through the @@ -1550,16 +1845,28 @@ fn ensure_host_survives_dm_stop() -> bool { let _ = Command::new("loginctl") .args(["--no-ask-password", "enable-linger", &uid]) .status(); - if linger_enabled() || (dm_helper("linger") && linger_enabled()) { - tracing::info!( - uid, - "enabled lingering for this user — the managed takeover stops the display manager, \ - which ends this login session, and without lingering logind would stop the host \ - along with it (`loginctl disable-linger` reverts it)" - ); - return true; + let helper = if linger_enabled() { + Ok(()) // the plain verb was enough — the helper was never needed + } else { + dm_helper("linger").map_err(|e| e.to_string()) + }; + match helper { + Ok(()) if linger_enabled() => { + tracing::info!( + uid, + "enabled lingering for this user — the managed takeover stops the display manager, \ + which ends this login session, and without lingering logind would stop the host \ + along with it (`loginctl disable-linger` reverts it)" + ); + Ok(()) + } + // The verb reported success and `loginctl` still says no: not a privilege problem, so say + // that instead of blaming the grant the operator would then go and re-check. + Ok(()) => Err(format!( + "`loginctl enable-linger {uid}` reported success but lingering is still off" + )), + Err(why) => Err(why), } - false } /// Is this process's lifetime tied to a `systemd --user` manager (i.e. would logind's user-manager @@ -1597,20 +1904,29 @@ fn linger_enabled() -> bool { /// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on /// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit /// (see docs); fails cleanly otherwise ("interactive authentication required") — then the -/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to -/// attach. -fn try_stop_display_manager(dm: &str) -> bool { - systemctl_system(&["stop", dm]) || dm_helper("stop") +/// packaged pkexec helper. The `Err` is the HELPER's reason (the plain verb's failure is expected +/// and carries no information: an unprivileged host is meant to fail it), and the caller puts it +/// in front of the operator instead of guessing. +fn try_stop_display_manager(dm: &str) -> std::result::Result<(), DmHelperError> { + if systemctl_system(&["stop", dm]) { + return Ok(()); + } + dm_helper("stop") } /// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start /// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin /// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an /// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same -/// two steps as root. -fn restore_display_manager(dm: &str) -> bool { +/// two steps as root. The `Err` carries the helper's own reason: this is the failure that leaves a +/// box with **no graphical session at all**, so the log line it produces has to be the one that +/// solves it. +fn restore_display_manager(dm: &str) -> std::result::Result<(), DmHelperError> { let _ = systemctl_system(&["reset-failed", dm]); - systemctl_system(&["restart", dm]) || dm_helper("restore") + if systemctl_system(&["restart", dm]) { + return Ok(()); + } + dm_helper("restore") } /// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the @@ -1696,9 +2012,10 @@ fn honor_session_select_switch(dm: String) { clear_takeover(); *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit - if !restore_display_manager(&dm) { + if let Err(e) = restore_display_manager(&dm) { tracing::warn!( %dm, + reason = %e, "gamescope: display-manager start was denied — the desktop switch may need a manual \ `systemctl restart` of the DM" ); @@ -1831,19 +2148,30 @@ fn stop_autologin_sessions() -> Result<()> { // box's display manager down and nobody left to bring it back. On a mask-fragile flavor, // degrading to attach is strictly better than a black screen that needs a VT to recover; // where masking is safe, mask-only (the storm tax) is strictly better than attach. - let dm_stopped = if !ensure_host_survives_dm_stop() { + // + // Both failure arms below quote the REASON they were handed rather than describing one. + // 0.26.0/0.27.0 described one — "the packaged pf-dm-helper polkit action is missing or was + // denied (reinstall the punktfunk package, or install the display-manager polkit rule from + // the docs)" — and on the box that produced it the action was installed, permissive, + // correctly annotated, and pkexec had already RUN the helper; the helper's refusal ("user + // 'x' is not in the 'punktfunk' group") was thrown away with its stderr. Both suggested + // remedies were dead ends: neither a reinstall nor a polkit rule adds anyone to a group. + let dm_stopped = if let Err(why) = ensure_host_survives_dm_stop() { if !plan.mask { + // The reason goes LAST in both bails: the helper's own refusal ends in a command + // to paste, and burying that mid-sentence is how it stops being read. bail!( "stopping {dm} ends this user's last login session, and without lingering \ logind would stop the user manager — and this host with it — about 10s \ later, leaving the box with no display manager and nothing to restore it; \ - enabling lingering failed, so the managed takeover is unavailable (run \ - `sudo loginctl enable-linger $USER` once, as the setup docs ask, then \ - reconnect)" + lingering could not be enabled, so the managed takeover is unavailable. \ + Either run `sudo loginctl enable-linger $USER` once, as the setup docs ask, \ + and reconnect — or fix the privileged path: {why}" ); } tracing::warn!( %dm, + reason = %why, "cannot stop the display manager for this stream (lingering could not be \ enabled, and without it the DM stop would take this host down ~10s later) — \ leaving it running: its autologin Relogin loop will churn logind sessions for \ @@ -1851,23 +2179,21 @@ fn stop_autologin_sessions() -> Result<()> { `sudo loginctl enable-linger $USER` once, as the setup docs ask" ); false - } else if !try_stop_display_manager(&dm) { + } else if let Err(why) = try_stop_display_manager(&dm) { if !plan.mask { bail!( "the box's gaming session is driven by {dm}, which does not survive a masked \ - session unit, and stopping it needs privilege — the packaged pf-dm-helper \ - polkit action is missing or was denied (reinstall the punktfunk package, or \ - install the display-manager polkit rule from the docs) so the managed \ - takeover is unavailable" + session unit, and stopping it needs privilege, so the managed takeover is \ + unavailable — {why}" ); } tracing::warn!( %dm, - "stopping the display manager for this stream needs privilege — the packaged \ - pf-dm-helper polkit action is missing or was denied — leaving it running: its \ - autologin Relogin loop will churn logind sessions for the whole stream, up to a \ - fork storm that starves the game and encoder (reinstall the punktfunk package, \ - or install the display-manager polkit rule from the docs)" + reason = %why, + "stopping the display manager for this stream needs privilege and the privileged \ + path failed — leaving it running: its autologin Relogin loop will churn logind \ + sessions for the whole stream, up to a fork storm that starves the game and \ + encoder" ); false } else { @@ -2108,6 +2434,10 @@ fn takeover_live() -> bool { /// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host /// that would serve them is exiting. No-op when nothing was taken over. pub fn restore_takeover_now() { + // Unconditionally, and BEFORE the takeover gate: the bind drop-in is armed on the degraded + // attach path too, which takes nothing over and so would leave the file behind on every clean + // host shutdown. It is ours by name and a no-op when absent. + remove_session_bind_dropin(); if !takeover_live() { return; } @@ -2141,6 +2471,12 @@ fn connected_connector_under(base: &std::path::Path) -> bool { /// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a /// cancelled+reconnected window keeps the list for a later real restore. fn do_restore_tv_session() { + // FIRST, and outside every branch below: the gamescope bind drop-in is armed on the box's OWN + // game-mode unit, so it must come back out however this restore ends — including the early + // returns for "nothing was taken over" and "a desktop session is active", which are exactly + // the shapes the degraded attach path produces. It removes only our own file (the operator's + // drop-ins in that directory are untouched) and is a no-op when we never armed it. + remove_session_bind_dropin(); // SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove // the drop-in + restart the target (back to the physical panel) — unless the user switched to a // desktop session meanwhile, in which case drop the override and leave the desktop alone. @@ -2238,22 +2574,26 @@ fn do_restore_tv_session() { // seat, so gamescope never gets DRM master (unit goes `failed`, screen stays black — // live-proven on the Nobara repro VM) — and under SDDM the relogin makes it redundant. if let Some(dm) = dm { - let restart = restore_display_manager(&dm); - if restart { - tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)"); - } else if crate::try_recover_session() { - tracing::warn!( + match restore_display_manager(&dm) { + Ok(()) => { + tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)") + } + Err(why) if crate::try_recover_session() => tracing::warn!( %dm, + reason = %why, "display-manager restart lost its privilege — fired PUNKTFUNK_RECOVER_SESSION_CMD \ to bring the session back" - ); - } else { - tracing::error!( + ), + // The worst state this code can produce: a box with no graphical session at all. The + // helper's own reason rides along, because "run these two commands as root" fixes the + // symptom once and the reason is what stops it happening again. + Err(why) => tracing::error!( %dm, + reason = %why, "could not restart the display manager and no PUNKTFUNK_RECOVER_SESSION_CMD is \ configured — the box has no graphical session until someone runs \ `systemctl reset-failed {dm} && systemctl restart {dm}` as root" - ); + ), } return; } @@ -2413,6 +2753,330 @@ fn write_gamescope_bin_wrapper() -> Result { Ok(path) } +/// systemd's exit status for "I could not set up this unit's mount namespace" (`EXIT_NAMESPACE`). +/// A unit carrying [`session_bind_setting`] exits with it — before its `ExecStart` ever runs — when +/// the box cannot give a **user** unit a mount namespace: no unprivileged user namespaces +/// (`user.max_user_namespaces=0`, a hardened `kernel.unprivileged_userns_clone=0`, a container +/// without the capability), or a source/target that cannot be bound. +/// +/// It is the one outcome that is strictly WORSE than the bug the bind fixes. Without the bind the +/// box streams a working session that merely lacks HDR and a cursor; with a bind that cannot be +/// established the unit does not start at all, and Game Mode is simply gone. So every site that +/// arms the bind also watches for this status and retries without it. +const EXIT_NAMESPACE: i32 = 226; + +/// Set once a unit carrying the bind has actually failed to start with [`EXIT_NAMESPACE`] — the +/// runtime counterpart to the [`bind_mount_usable`] preflight, for the box that passes the probe +/// and then fails the real thing anyway (a probe runs `/bin/true`; the session unit carries the +/// distro's own drop-ins, and one of them may add sandboxing that interacts badly). +/// +/// One-way and process-wide, exactly like [`note_spawn_flags_lost`]: nothing we can observe proves +/// the next launch would fare better, and the cost of trying again is a session that will not +/// start. Once set, [`session_bind_setting`] answers `None` forever and every path is back to +/// today's behaviour. +static BIND_UNUSABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Record that a unit carrying the bind failed with [`EXIT_NAMESPACE`] — see [`BIND_UNUSABLE`]. +fn note_session_bind_unusable() { + BIND_UNUSABLE.store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// The gamescope binary `gamescope-session-plus` HARDCODES, on a box where the `GAMESCOPE_BIN` +/// escape hatch does not exist **at all**. `None` when the script honours `GAMESCOPE_BIN` (Bazzite +/// and the SteamOS-like images — the wrapper works there, and a box that does not need a mount +/// namespace must not be given one), when there is no session-plus, or when the script's shape is +/// not one we recognise. +/// +/// Field evidence, Nobara 44 (`home-nobara-1`, 2026-08-09): +/// `grep GAMESCOPE_BIN /usr/share/gamescope-session-plus/gamescope-session-plus` returns **no +/// matches at all** — the variable is never read — and line 244 reads +/// `GAMESCOPECMD="/usr/bin/gamescope \`. Against that, no environment variable and no `PATH` entry +/// can win: the host writes its wrapper, exports it, and the session execs the distro's stock +/// gamescope with none of our flags. That is not a misconfiguration to warn about, it is a +/// structural property of the script on disk — and it is *readable*, which is what makes it safe +/// to act on rather than guess at. +fn session_plus_hardcoded_gamescope() -> Option { + let script = std::fs::read_to_string(SESSION_PLUS_BIN).ok()?; + hardcoded_gamescope_in(&script).map(std::path::PathBuf::from) +} + +/// [`session_plus_hardcoded_gamescope`]'s parser. Split out pure and unit-tested because both of +/// its answers are load-bearing and one of them arms a bind mount over a distro-owned path. +/// +/// A script that mentions `GAMESCOPE_BIN` *anywhere* is left alone: that is the sanctioned hook, +/// the host already drives it, and the bind exists only for scripts that offer no hook at all. +/// +/// Only the `GAMESCOPECMD=` assignment that OPENS the command line is read, and only when its +/// first word is an absolute path. The same script contains `if [ -z "$GAMESCOPECMD" ]` (a test) +/// and several `GAMESCOPECMD+=" -R $socket"` (appends); neither is an assignment of the binary, +/// and mistaking one for it would bind a mount over `$socket`. +fn hardcoded_gamescope_in(script: &str) -> Option<&str> { + if script.contains("GAMESCOPE_BIN") { + return None; + } + script.lines().find_map(|line| { + let rest = line.trim_start().strip_prefix("GAMESCOPECMD=")?; + let path = rest + .trim_start_matches(['"', '\'']) + .split_whitespace() + .next()?; + path.starts_with('/').then_some(path) + }) +} + +/// The `BindReadOnlyPaths=` value that makes a hardcoded gamescope path resolve to the host's +/// `GAMESCOPE_BIN` wrapper **inside the session unit's own mount namespace** — or `None` when the +/// bind is either unnecessary or unproven, in which case every caller behaves exactly as it did +/// before this mechanism existed. +/// +/// ## Why a mount namespace, when two indirections already exist +/// +/// The wrapper ([`write_gamescope_bin_wrapper`]) and the SteamOS PATH shim +/// ([`write_headless_shim`]) both work by *asking* the session script to run something else. A +/// script that hardcodes `/usr/bin/gamescope` never asks. The only remaining lever that does not +/// require overwriting a distro-owned binary is to change what that path MEANS, for this unit and +/// nothing else — which is what a bind mount in a private mount namespace is. Outside the unit the +/// box is bit-for-bit unchanged; `dnf` still owns `/usr/bin/gamescope`, and a package upgrade, +/// another user's session, and the operator's own desktop all see the stock binary. +/// +/// ## Why the WRAPPER is the source, not the patched binary +/// +/// Binding `punktfunk-gamescope` straight over `/usr/bin/gamescope` would fix the binary and lose +/// the point: the flags this whole path exists to deliver — `--pipewire-composite-cursor`, +/// `--pipewire-composite-external-overlay`, `--hdr-enabled`, `--nested-refresh` — are injected by +/// the wrapper, not baked into the binary. A session bound to the bare binary still runs without +/// them, [`verify_managed_spawn_flags`] still refuses it, and the box still streams SDR with no +/// pointer. Binding the *wrapper* reproduces exactly what `GAMESCOPE_BIN` would have done had the +/// script consulted it, which is the whole objective. +/// +/// ## Where it applies +/// +/// Only where the wrapper is *structurally* lost, decided by reading the script on this box +/// ([`session_plus_hardcoded_gamescope`]) rather than by distro name. Bazzite and SteamOS keep the +/// mechanism that already works there and never take a mount namespace they do not need; a box +/// with no session-plus at all (the bare-spawn path builds its own argv) never reaches here. +fn session_bind_setting() -> Option<&'static str> { + if BIND_UNUSABLE.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + static SETTING: std::sync::OnceLock> = std::sync::OnceLock::new(); + SETTING.get_or_init(resolve_session_bind_setting).as_deref() +} + +/// [`session_bind_setting`]'s one-time resolution. Every `None` here is a deliberate refusal to +/// arm a bind, and each is logged at the level its cause deserves. +fn resolve_session_bind_setting() -> Option { + let dst = session_plus_hardcoded_gamescope()?; + // A bind mount cannot CREATE the target, and a unit whose bind fails does not start — so a + // hardcoded path that is not a file on this box is a refusal, not an attempt. + if !dst.is_file() { + tracing::warn!( + path = %dst.display(), + "gamescope: gamescope-session-plus hardcodes a gamescope that does not exist here — \ + not binding over it" + ); + return None; + } + // FORK-BOMB GUARD, and the reason this is a hard gate rather than an optimization: the source + // is a wrapper whose last act is `exec`ing [`gamescope_bin`]. If that resolved to the very + // path we are about to shadow, the wrapper would exec *itself*, forever, inside the session. + // It does exactly that on a box with no `punktfunk-gamescope` installed — which is also the + // box with nothing to gain here, since the flags the wrapper injects need the patched binary. + let bin = std::path::Path::new(gamescope_bin()); + if !bin.is_absolute() || bin == dst { + tracing::debug!( + bin = %bin.display(), + hardcoded = %dst.display(), + "gamescope: the session script hardcodes the same gamescope we would run — nothing to \ + bind (install punktfunk-gamescope for HDR and the in-node cursor)" + ); + return None; + } + // Written here rather than assumed present: this resolution is cached for the process, so a + // first call that happened to precede the wrapper's own write would latch `None` forever. + // The write is idempotent and its body is constant for the process. + let src = write_gamescope_bin_wrapper() + .map_err(|e| tracing::warn!(error = %format!("{e:#}"), "gamescope: no wrapper to bind")) + .ok()?; + let paths = format!("{}:{}", src.display(), dst.display()); + if !bind_mount_usable(&paths) { + tracing::warn!( + %paths, + "gamescope: this box's gamescope-session-plus hardcodes an absolute gamescope path and \ + reads GAMESCOPE_BIN nowhere, but a systemd --user unit here cannot take a mount \ + namespace — so the patched gamescope cannot be reached without overwriting a \ + distro-owned binary. Sessions stay 8-bit SDR with a host-composited cursor. (Check \ + `sysctl user.max_user_namespaces` / `kernel.unprivileged_userns_clone`.)" + ); + return None; + } + tracing::info!( + %paths, + "gamescope: this box's gamescope-session-plus hardcodes an absolute gamescope path and \ + reads GAMESCOPE_BIN nowhere — binding the punktfunk wrapper over it inside the session \ + unit's own mount namespace (nothing outside the unit changes)" + ); + Some(paths) +} + +/// Can a `systemd --user` unit on this box actually take this bind? Proven — once per host +/// process, before anything depends on it — by starting a throwaway transient unit carrying the +/// exact `BindReadOnlyPaths=` value and running `/bin/true` inside it. +/// +/// This is proven rather than assumed because the failure is worse than the bug (see +/// [`EXIT_NAMESPACE`]). Everything the real bind needs is exercised: the user manager's ability to +/// unshare a mount namespace at all, the source, and the target. Nothing else is: the payload is +/// `/bin/true`, so no gamescope is spawned, no session is touched, and the unit is `--collect`ed +/// the moment it exits. +/// +/// It deliberately does NOT try to prove that the *substitution* took — that the gamescope the +/// session ends up running is ours. That answer already exists downstream and is a better one: +/// [`verify_managed_spawn_flags`] reads the running compositor's `/proc//cmdline` and refuses +/// a session missing our flags, whatever the reason. Proving it here would mean executing the +/// bound path, and a preflight that can accidentally start a compositor on someone's box is not a +/// preflight. +fn bind_mount_usable(paths: &str) -> bool { + let out = Command::new("systemd-run") + .args(["--user", "--wait", "--collect", "--quiet"]) + .arg(format!("--property=BindReadOnlyPaths={paths}")) + // A probe that can hang is a connect that can hang: `/bin/true` returns instantly, so any + // wait at all means something is wrong and the answer we want is "no". + .arg("--property=RuntimeMaxSec=15") + .args(["--", "/bin/true"]) + .output(); + match out { + // `--wait` propagates the payload's own exit status, so success here means systemd built + // the namespace, took the bind, and ran the command in it. + Ok(o) if o.status.success() => true, + Ok(o) => { + tracing::debug!( + status = %o.status, + stderr = %String::from_utf8_lossy(&o.stderr).trim(), + "gamescope: the bind-mount preflight unit failed" + ); + false + } + Err(e) => { + tracing::debug!(error = %e, "gamescope: could not run the bind-mount preflight"); + false + } + } +} + +/// Did `unit` fail because systemd could not set up its mount namespace ([`EXIT_NAMESPACE`])? +/// +/// The distinction this draws is the whole of the graceful degradation: a unit that started +/// without our flags is the OLD bug (loud, recoverable, and [`verify_managed_spawn_flags`] already +/// owns it), while a unit that never started at all is the NEW one, and only the second may retry +/// without the bind. `false` whenever the answer cannot be read — an unreadable status must not +/// disarm a bind that is working. +fn unit_failed_namespace(unit: &str) -> bool { + let Ok(out) = Command::new("systemctl") + .args(["--user", "show", "-p", "ExecMainStatus", "--value", unit]) + .output() + else { + return false; + }; + String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .ok() + == Some(EXIT_NAMESPACE) +} + +/// Path of the drop-in that arms [`session_bind_setting`] on the BOX's own game-mode unit. +/// +/// On the **template** (`gamescope-session-plus@.service.d`), not on an instance: the box chooses +/// its own instance name (`@steam`, `@ogui-steam`, …), the host restarts whichever one is live, +/// and a per-instance file would miss the next one. +/// +/// `zz-` so it sorts LAST. systemd merges drop-ins in filename order and the last assignment of a +/// setting wins, so no distro or operator file can silently take the bind back out from under us. +/// The reference box's own operator drop-in is named `10-headless.conf` for the other half of that +/// contract — a distinct name and an earlier sort position — and [`remove_session_bind_dropin`] +/// removes only the single file named here, so a restore leaves it standing. +fn session_bind_dropin_path() -> Option { + let home = std::env::var("HOME").ok().filter(|h| !h.is_empty())?; + Some( + std::path::Path::new(&home) + .join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf"), + ) +} + +/// Write the bind drop-in for the box's own game-mode unit and make systemd re-read it. Returns +/// whether the bind is now armed; `false` means the caller proceeds exactly as it did before. +/// +/// The `daemon-reload` is not optional and not deferrable: a drop-in systemd has not re-read is +/// **inert**, so the restart that follows would start the unit from the old configuration and the +/// entire mechanism would silently do nothing while looking like it worked. +fn arm_session_bind_dropin(hz: u32) -> bool { + let Some(setting) = session_bind_setting() else { + return false; + }; + let Some(path) = session_bind_dropin_path() else { + return false; + }; + let write = || -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("mkdir {}", parent.display()))?; + } + // `PF_HZ` rides along because the wrapper reads it for `--nested-refresh` — the flag + // gamescope-session-plus does not expose and the one a headless session reports as its + // ONE refresh rate (60 Hz when it never arrives). Binding the wrapper without it would + // hand every game a 60 Hz display on a 120 Hz stream. + let body = format!( + "# Written by punktfunk for the duration of a stream; removed on restore.\n\ + #\n\ + # This box's gamescope-session-plus HARDCODES an absolute gamescope path and reads\n\ + # GAMESCOPE_BIN nowhere, so neither the wrapper nor a PATH shim can reach it. Bind\n\ + # the wrapper over that path inside THIS UNIT's own mount namespace instead: the\n\ + # session gets the patched gamescope and our flags, and nothing outside the unit\n\ + # changes — the distro still owns the binary on disk.\n\ + [Service]\n\ + BindReadOnlyPaths={setting}\n\ + Environment=PF_HZ={hz}\n" + ); + std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display())) + }; + if let Err(e) = write() { + tracing::warn!( + error = %format!("{e:#}"), + "gamescope: could not write the gamescope bind drop-in — the box's session will run \ + the distro's stock gamescope (no HDR, no in-node cursor)" + ); + return false; + } + systemctl_user(&["daemon-reload"]); + tracing::info!( + path = %path.display(), + "gamescope: armed the punktfunk gamescope bind on the box's own game-mode unit" + ); + true +} + +/// Remove the bind drop-in (restore, host shutdown, and a stale one found at startup). Best-effort. +/// +/// It takes **only our own file** — `remove_file` on the one name we wrote, never the directory. +/// The drop-in directory is shared with the operator: the reference box keeps a `10-headless.conf` +/// in there without which its game-mode unit cannot start at all (no connected DRM connector), and +/// an `rm -r` of the directory would take that with it and leave the box unable to reach Game Mode +/// by any route. The `daemon-reload` runs only when a file was actually removed, so a restore on a +/// box that never armed the bind touches nothing. +fn remove_session_bind_dropin() { + let Some(path) = session_bind_dropin_path() else { + return; + }; + if std::fs::remove_file(&path).is_ok() { + systemctl_user(&["daemon-reload"]); + tracing::info!( + path = %path.display(), + "gamescope: removed the punktfunk gamescope bind drop-in (the box's game-mode unit is \ + back to the distro's own gamescope)" + ); + } +} + /// Launch `gamescope-session-plus ` headless at `mode` as a transient `systemd --user` /// unit (clean cgroup teardown of the whole Steam tree on stop). Injects `--nested-refresh` (via /// the wrapper) + `--generate-drm-mode cvt` so games see exactly `mode` (resolution + refresh) and @@ -2451,9 +3115,20 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul r.dedup(); r.iter().map(u32::to_string).collect::>().join(",") }; - let start_unit = || -> Result<()> { - let status = Command::new("systemd-run") - .args(["--user", "--collect", &format!("--unit={unit_name}")]) + // The bind is passed as a transient PROPERTY rather than through a drop-in file, because this + // unit is one we create: `systemd-run` applies it atomically with the start, there is no + // `daemon-reload` window in which it could be inert, and — the part that matters after a host + // crash — there is no file left behind on a box we no longer own. The box's OWN game-mode unit + // (which we only restart) has no such option and takes the drop-in instead; both carry the + // identical `BindReadOnlyPaths=` value from [`session_bind_setting`]. + let mut bind = session_bind_setting(); + let start_unit = |bind: Option<&str>| -> Result<()> { + let mut cmd = Command::new("systemd-run"); + cmd.args(["--user", "--collect", &format!("--unit={unit_name}")]); + if let Some(paths) = bind { + cmd.arg(format!("--property=BindReadOnlyPaths={paths}")); + } + let status = cmd // Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the // user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY // that would abort gamescope at startup. @@ -2490,7 +3165,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul } Ok(()) }; - start_unit()?; + start_unit(bind)?; // Steam Big Picture cold-start is far slower than a bare app — poll the node for up to 45s. let deadline = Instant::now() + Duration::from_secs(45); loop { @@ -2519,11 +3194,29 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul // and the transient unit has no Restart= — without supervision the rest of this poll would // wait on a corpse. Re-run the unit so every readiness attempt inside the deadline is used. if !unit_starting_or_active(unit_name) { - tracing::warn!( - unit = unit_name, - "gamescope session: transient unit died (missed the wrapper's 5 s gamescope \ - readiness window?) — relaunching" - ); + // Before blaming the readiness window: did the unit fail because systemd could not + // build its mount namespace? That is a unit which never reached its ExecStart, so + // relaunching it identically would loop until the deadline and leave the box with no + // Game Mode at all — strictly worse than the missing-flags bug the bind is here to + // fix. Disarm and relaunch plain; the session then comes up exactly as it did before + // this mechanism existed, and `verify_managed_spawn_flags` refuses HDR + the in-node + // cursor as it always has. + if bind.is_some() && unit_failed_namespace(unit_name) { + tracing::warn!( + unit = unit_name, + "gamescope session: the unit could not be given a mount namespace \ + (exit {EXIT_NAMESPACE}) — dropping the gamescope bind and relaunching \ + without it. This session streams SDR with a host-composited cursor" + ); + note_session_bind_unusable(); + bind = None; + } else { + tracing::warn!( + unit = unit_name, + "gamescope session: transient unit died (missed the wrapper's 5 s gamescope \ + readiness window?) — relaunching" + ); + } // Brief cooldown before the relaunch: the wrapper SIGKILLed a gamescope mid-Vulkan-init, // and the NVIDIA driver reclaims that context asynchronously — an instant relaunch pays // the reclaim serialization on top of device init and misses the 5 s window again. @@ -2531,7 +3224,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul let _ = Command::new("systemctl") .args(["--user", "reset-failed", unit_name]) .status(); - start_unit()?; + start_unit(bind)?; } std::thread::sleep(Duration::from_millis(500)); } @@ -2872,9 +3565,9 @@ impl Drop for GamescopeProc { mod tests { use super::{ cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under, - display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args, - is_steam_launch, missing_flags, mode_mismatch, nested_wrapper_script, sentinel_advanced, - shape_dedicated_command, + display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, + hardcoded_gamescope_in, hdr_args, is_steam_launch, missing_flags, mode_mismatch, + nested_wrapper_script, sentinel_advanced, shape_dedicated_command, DmHelperError, }; /// The HDR spawn flags are what make a nested game render HDR at all — and their absence is @@ -2976,6 +3669,52 @@ mod tests { std::fs::remove_dir_all(&base).unwrap(); } + /// The whole point of [`DmHelperError`]: a failure has to say which of the four things went + /// wrong, because they need four different fixes. Pins the two properties that were violated + /// in the field — the helper's own words survive to the operator, and a helper that never RAN + /// never reads as one that ran and refused. + #[test] + fn dm_helper_failures_stay_distinguishable() { + let refusal = "pf-dm-helper: user 'nobara-user' is not in the 'punktfunk' group — \ + refusing. Grant it with: sudo usermod -aG punktfunk nobara-user"; + let ran = DmHelperError::Refused { + helper: "/usr/libexec/punktfunk/pf-dm-helper", + code: Some(1), + stderr: refusal.to_string(), + } + .to_string(); + // Verbatim: the helper already names the user, the group and the exact command. + assert!(ran.contains(refusal), "{ran}"); + assert!(ran.contains("ran and refused"), "{ran}"); + + // …and none of the three "it never got that far" shapes may claim a refusal, or the + // operator goes looking for a group problem that isn't there. + for e in [ + DmHelperError::NotInstalled, + DmHelperError::NotExecutable { + helper: "/usr/libexec/punktfunk/pf-dm-helper", + io: "No such file or directory (os error 2)".into(), + }, + DmHelperError::Denied { + helper: "/usr/libexec/punktfunk/pf-dm-helper", + code: 127, + stderr: "Error executing command as another user: Not authorized".into(), + }, + ] { + let s = e.to_string(); + assert!(!s.contains("ran and refused"), "{s}"); + // …and none of them may send the operator after group membership, which is only ever + // the answer when the helper actually evaluated it. + assert!(!s.contains("group"), "{s}"); + // Every one of them still ends in something the operator can act on. + assert!(s.contains("polkit") || s.contains("install"), "{s}"); + } + + // The remedies the old fixed guess offered — a reinstall and a polkit rule — must appear + // ONLY where they can actually help, never on the path that ran and was refused. + assert!(!ran.contains("reinstall"), "{ran}"); + } + #[test] fn dm_plan_stops_any_dm_that_drove_a_live_session() { // SDDM, live gaming session: mask (belt-and-braces) AND stop the DM — the mask alone @@ -3105,6 +3844,58 @@ mod tests { assert!(!cgroup_is_punktfunk_owned("")); } + /// The gate that decides whether a mount namespace is armed over a distro-owned path at all. + /// Both answers matter: a false `Some` binds a mount on a box that never needed one, and a + /// false `None` is the Nobara bug (a session that silently runs a stock gamescope). + #[test] + fn hardcoded_gamescope_is_read_only_where_there_is_no_escape_hatch() { + // THE field case, Nobara 44 / `home-nobara-1` 2026-08-09: `GAMESCOPE_BIN` appears nowhere + // in the script, and line 244 opens the command line with an absolute path. Verbatim + // shape, tabs and trailing backslash included. + let nobara = "\ +#!/bin/bash\n\ +if [ -z \"$GAMESCOPECMD\" ]; then\n\ +\tGAMESCOPECMD=\"/usr/bin/gamescope \\\n\ +\t\t$CURSOR \\\n\ +\t\t$RESOLUTION\"\n\ +fi\n\ +GAMESCOPECMD+=\" -R $socket -T $stats\"\n\ +$GAMESCOPECMD >\"${HOME}\"/.gamescope-stdout.log 2>&1 &\n"; + assert_eq!(hardcoded_gamescope_in(nobara), Some("/usr/bin/gamescope")); + + // A script that reads GAMESCOPE_BIN anywhere is LEFT ALONE — that is the sanctioned hook, + // the wrapper already drives it (Bazzite / SteamOS-like), and a box where it works must + // not be given a mount namespace it does not need. + let bazzite = "\ +GAMESCOPE_BIN=${GAMESCOPE_BIN:-gamescope}\n\ +GAMESCOPECMD=\"/usr/bin/gamescope $RESOLUTION\"\n"; + assert_eq!(hardcoded_gamescope_in(bazzite), None); + + // An APPEND (`+=`) is not the assignment of the binary. Reading one would bind a mount + // over `$socket`, which is the worst available outcome here. + assert_eq!( + hardcoded_gamescope_in("GAMESCOPECMD+=\" -R /tmp/sock\"\n"), + None + ); + // Nor is the emptiness TEST that guards the assignment. + assert_eq!( + hardcoded_gamescope_in("if [ -z \"$GAMESCOPECMD\" ]; then\nfi\n"), + None + ); + // A relative or variable command is not something we can bind over. + assert_eq!( + hardcoded_gamescope_in("GAMESCOPECMD=\"gamescope -W 1920\"\n"), + None + ); + assert_eq!( + hardcoded_gamescope_in("GAMESCOPECMD=\"$GS -W 1920\"\n"), + None + ); + // Nothing recognisable at all (a script we do not understand) stays hands-off. + assert_eq!(hardcoded_gamescope_in("exec gamescope \"$@\"\n"), None); + assert_eq!(hardcoded_gamescope_in(""), None); + } + /// The silent-60Hz guard. A headless gamescope reports `--nested-refresh` as its ONE refresh /// rate and falls back to 60 Hz when the flag never arrives, so a session that lost the /// `GAMESCOPE_BIN` wrapper streams at the client's rate while telling every game it is 60 — diff --git a/crates/pf-vdisplay/src/vdisplay/routing.rs b/crates/pf-vdisplay/src/vdisplay/routing.rs index abd14860..2fca39be 100644 --- a/crates/pf-vdisplay/src/vdisplay/routing.rs +++ b/crates/pf-vdisplay/src/vdisplay/routing.rs @@ -371,6 +371,19 @@ pub fn restore_takeover_on_startup() { #[cfg(not(target_os = "linux"))] pub fn restore_takeover_on_startup() {} +/// Warn ONCE, at startup, when this box will need the managed gamescope takeover but its user is +/// not in the `punktfunk` group the packaged privilege helper gates on — the one takeover +/// prerequisite that fails silently mid-stream instead of at setup time. Gated so a box that will +/// never attempt a takeover stays quiet; see [`gamescope::preflight_takeover_privilege`] for the +/// exact conditions. Call once at `serve` startup, alongside [`restore_takeover_on_startup`]. +#[cfg(target_os = "linux")] +pub fn preflight_takeover_privilege() { + gamescope::preflight_takeover_privilege(); +} + +#[cfg(not(target_os = "linux"))] +pub fn preflight_takeover_privilege() {} + /// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks /// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown /// path — a takeover that outlives the host leaves the box with no display manager and nobody left diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index be01a1f9..0f0ae045 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -374,6 +374,12 @@ pub(crate) async fn serve( // A3: recover a TV takeover stranded by a crashed previous host instance (persisted to // $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start. crate::vdisplay::restore_takeover_on_startup(); + // …and check the takeover's one un-automatable prerequisite BEFORE a stream needs it: on a box + // that will use the takeover, the host's user must be in the `punktfunk` group the packaged + // privilege helper gates on. Missing membership fails nothing — the takeover degrades to + // mirroring the box's own session — so without this it surfaces only as a black screen on + // every connect. No-op off Linux and on any box the takeover can't apply to. + crate::vdisplay::preflight_takeover_privilege(); // …and the other end of that: give the box its session back when WE are the ones going away. install_shutdown_restore(); // Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com / diff --git a/docs-site/content/docs/arch.md b/docs-site/content/docs/arch.md index 6cef020c..5e276418 100644 --- a/docs-site/content/docs/arch.md +++ b/docs-site/content/docs/arch.md @@ -57,17 +57,21 @@ sudo pacman -Syu punktfunk-scripting # optional: the plugin/script runner (see b sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply) ``` -Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro — it reaches games -as a real USB pad, which is why Steam Input adopts it), also join `punktfunk`: +Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller** +(paddles, trackpads, gyro — it reaches games as a real USB pad, which is why Steam Input adopts +it), or this box autologins into Steam **Gaming Mode** and you want the host to take that session +over at the client's resolution: ```sh -sudo usermod -aG punktfunk "$USER" # usbip/vhci access (re-login to apply) +sudo usermod -aG punktfunk "$USER" # usbip/vhci + display-manager takeover (re-login to apply) ``` That is a second group on purpose. It grants write access to the usbip `attach` file, which materialises an arbitrary emulated USB device — so it stays off the `input` group everyone is -routinely told to join. Join it only on a machine you trust. Without it, everything else still -works and the pad simply arrives as an ordinary Xbox 360 controller. +routinely told to join. Join it only on a machine you trust. On a plain desktop host, everything +else still works without it and the pad simply arrives as an ordinary Xbox 360 controller; on a +Gaming Mode box the takeover silently degrades to mirroring the box's own screen — see +[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers). Each install is a **full** `-Syu`, on purpose: our packages are built against current Arch sonames, and `pacman -Sy ` would drop one onto a system whose other packages are still old — diff --git a/docs-site/content/docs/bazzite.md b/docs-site/content/docs/bazzite.md index 5d89cf8a..1d6497fe 100644 --- a/docs-site/content/docs/bazzite.md +++ b/docs-site/content/docs/bazzite.md @@ -126,17 +126,21 @@ ujust add-user-to-input-group Then **log out and back in**. (A controller that's "detected but does nothing" is almost always this permission, not a client problem.) -Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join -`punktfunk` — `usermod` is fine here, because unlike `input` this group is ours and the sysext -creates it on merge: +Then join `punktfunk` — `usermod` is fine here, because unlike `input` this group is ours and the +sysext creates it on merge: ```sh sudo usermod -aG punktfunk "$USER" # then log out and back in ``` -It is a separate group on purpose: it gates the usbip `attach` file, which can materialise -arbitrary emulated USB hardware, so it is not folded into the group everyone is told to join for -gamepads. Skip it and the pad arrives as an ordinary Xbox 360 controller instead. +This box **is** a Gaming Mode box, so that group is not optional in practice: it authorizes the +helper the host uses to stop the display manager when it takes the Gaming Mode session over at your +client's resolution, and it gates the usbip `attach` file the **virtual Steam Deck controller** +(paddles, trackpads, gyro) attaches through. It is a separate group on purpose — writing that file +can materialise arbitrary emulated USB hardware, so it is not folded into the group everyone is +told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller, and the +takeover degrades to mirroring the box's own screen — see +[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers). ## Configure diff --git a/docs-site/content/docs/fedora.md b/docs-site/content/docs/fedora.md index b80d3c9c..ce16403e 100644 --- a/docs-site/content/docs/fedora.md +++ b/docs-site/content/docs/fedora.md @@ -93,17 +93,21 @@ sudo dnf install punktfunk sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply) ``` -Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro — it reaches games -as a real USB pad, which is why Steam Input adopts it), also join `punktfunk`: +Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller** +(paddles, trackpads, gyro — it reaches games as a real USB pad, which is why Steam Input adopts +it), or this box autologins into Steam **Gaming Mode** (Nobara and friends) and you want the host +to take that session over at the client's resolution: ```sh -sudo usermod -aG punktfunk "$USER" # usbip/vhci access (re-login to apply) +sudo usermod -aG punktfunk "$USER" # usbip/vhci + display-manager takeover (re-login to apply) ``` That is a second group on purpose: it grants write access to the usbip `attach` file, which materialises an arbitrary emulated USB device, so it stays off the `input` group everyone is -routinely told to join. Join it only on a machine you trust. Skip it and the pad simply arrives as -an ordinary Xbox 360 controller. +routinely told to join. Join it only on a machine you trust. Skip it on a plain desktop host and +the pad simply arrives as an ordinary Xbox 360 controller; skip it on a Gaming Mode box and the +takeover silently degrades to mirroring the box's own screen — see +[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers). Updates later are just `sudo dnf upgrade punktfunk`, followed by `systemctl --user restart punktfunk-host` so the running host picks up the new binary. The package diff --git a/docs-site/content/docs/gamescope.md b/docs-site/content/docs/gamescope.md index 2bcd7c9e..ff18816a 100644 --- a/docs-site/content/docs/gamescope.md +++ b/docs-site/content/docs/gamescope.md @@ -40,16 +40,37 @@ the [Bazzite template](/docs/bazzite) ships with **attach** chosen instead. ### Nobara and other autologin display managers -The managed takeover has to stop the box's Gaming Mode session to free Steam. How it does that -depends on the display manager driving the autologin: +The managed takeover has to stop the box's Gaming Mode session to free Steam — and when that +session is a display-manager autologin, it has to stop the **display manager** too, for the length +of the stream. That is a privileged operation, and the privilege is granted to one group. -- **SDDM** (Bazzite, SteamOS): handled automatically — no setup. -- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager - itself for the length of the stream and restart it afterwards, which needs privilege. The - packages ship that privilege: a root helper (`/usr/libexec/punktfunk/pf-dm-helper`, or - `/usr/lib/punktfunk/pf-dm-helper` from the Arch package) behind its own polkit action - (`io.unom.punktfunk.dm-helper`), invoked automatically when the plain - `systemctl` verbs are denied — no setup. The helper only stops/restores the unit the +> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover's root helper +> runs for members of that group and for nobody else, so this one command is what authorizes it: +> +> ```sh +> sudo usermod -aG punktfunk "$USER" # then log out and back in +> ``` +> +> Your package created the group at install time and put **nobody** in it, on purpose: it also +> gates the usbip nodes the virtual Steam Deck pad attaches through, and writing those can present +> arbitrary emulated USB hardware — so joining stays a deliberate act, on a machine you trust. +> Skip it and nothing fails loudly. Every takeover degrades to mirroring the box's own session +> (below), which on a box whose panel is off reads as a black screen on every connect. The host +> checks this at startup on any box that will need the takeover and says so in its log; the +> symptom side is [Game Mode: black screen on +> connect](/docs/troubleshooting#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution). + +How the takeover gets that privilege depends on the display manager driving the autologin: + +- **SDDM** (Bazzite, SteamOS): SDDM survives having the session unit masked, so a box without the + grant still streams — at the cost of SDDM relogin-looping against the takeover for the whole + stream, which churns logind sessions and can starve the game. +- **plasmalogin** (Nobara) and other display managers: masking is fatal there (the autologin + start-limit-kills the display manager), so the host stops the display manager itself and + restarts it afterwards. The packages ship that privilege: a root helper + (`/usr/libexec/punktfunk/pf-dm-helper`, or `/usr/lib/punktfunk/pf-dm-helper` from the Arch + package) behind its own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically + when the plain `systemctl` verbs are denied. The helper only stops/restores the unit the `display-manager.service` symlink points at, the same class of local-seat operation these distros already authorize for their own session switcher (Nobara's `os-session-select`). @@ -71,8 +92,11 @@ depends on the display manager driving the autologin: With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to the client — if your monitor stays on and the stream runs at the desktop's resolution, this is - what happened; check the host log for "managed takeover unavailable"). If the display-manager - restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see + what happened; check the host log for "managed takeover unavailable"). That log line now quotes + the privileged path's own reason for refusing, so read it before changing anything: by far the + most common one is `not in the 'punktfunk' group`, which the group command above fixes and + neither a reinstall nor a polkit rule does. If the display-manager restart ever loses its + privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the fallback. **Lingering is required here**, and the host turns it on for you the first time it takes the box @@ -81,7 +105,9 @@ depends on the display manager driving the autologin: taking the host with it, mid-stream, with the display manager down and nothing left to bring it back. If lingering can't be enabled the host refuses the takeover and degrades to attach instead (above) rather than risk that. Run `sudo loginctl enable-linger "$USER"` once, as the setup guides - ask; `loginctl disable-linger "$USER"` reverts it. + ask; `loginctl disable-linger "$USER"` reverts it. (A host with no login session of its own turns + lingering on through the *same* helper, so a missing group grant surfaces here first — the log + says "enabling lingering failed" and then quotes the same reason.) With the takeover authorized the **in-stream session switch round-trips** in managed mode: Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session @@ -214,6 +240,18 @@ These apply to the **Gaming Mode (gamescope)** path only; the desktop path is un mode and rate, and `PUNKTFUNK_GAMESCOPE_REFRESH_RATES=60,90,120` puts more than one entry in that menu. If the host log says *"the session did not start at the mode we asked for"*, a file in `/etc/gamescope-session-plus/sessions.d/` is overriding `GAMESCOPE_BIN` or setting `GAMESCOPECMD`. +- **Some distros' session script ignores `GAMESCOPE_BIN` entirely — the host works around it with a + bind mount.** Nobara's `gamescope-session-plus` never reads that variable and hardcodes + `/usr/bin/gamescope` outright, so neither the variable nor a `PATH` entry can point the session at + `punktfunk-gamescope`; every session ran the stock binary, which cost HDR *and* the in-stream + cursor. The host now detects that shape by reading the script, and binds its own wrapper over the + hardcoded path **inside the session unit's own mount namespace** — the session gets the patched + gamescope and the flags, and nothing outside that unit changes (the distro still owns + `/usr/bin/gamescope`; you do not need to overwrite it). You will see *"binding the punktfunk + wrapper over it"* in the log when this engages. It needs unprivileged user namespaces; on a box + without them the host logs that it cannot take a mount namespace and streams SDR with a + host-composited cursor, exactly as before. Boxes whose script does honour `GAMESCOPE_BIN` + (Bazzite, SteamOS) are untouched by any of this. - **The performance overlay (fps / frametime / stats) needs the patched build.** It is mangoapp, which gamescope draws as an *external overlay* — a layer upstream's capture composite has never included on any version, so on a stock gamescope you can turn the overlay on and it simply will diff --git a/docs-site/content/docs/install.md b/docs-site/content/docs/install.md index 11c4bad8..d63548fe 100644 --- a/docs-site/content/docs/install.md +++ b/docs-site/content/docs/install.md @@ -156,11 +156,16 @@ you; on NixOS the module does steps 1 and 2, and [NixOS](#nixos) above has the u command differs per distro — see your guide (`usermod -aG input "$USER"`, or `ujust add-user-to-input-group` on Bazzite). - Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join - `punktfunk`: `sudo usermod -aG punktfunk "$USER"`. Your package created that group at install - time; it gates the usbip nodes that pad attaches through, and it is separate from `input` on - purpose, because writing them can present arbitrary emulated USB hardware. Join it only on a - machine you trust — skipping it costs you nothing but that one pad type. + Also join `punktfunk` — `sudo usermod -aG punktfunk "$USER"`, then log out and back in — if + **either** of these is true: you want the **virtual Steam Deck controller** (paddles, + trackpads, gyro), or this box autologins into Steam **Gaming Mode** and you want the host to + take that session over at your client's resolution + ([gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers)). Your package created + that group at install time and left it empty. It gates the usbip nodes the pad attaches through + *and* the helper that stops the display manager for a takeover, and it is separate from `input` + on purpose, because writing those nodes can present arbitrary emulated USB hardware — so join it + only on a machine you trust. On a plain desktop host that streams no Gaming Mode, skipping it + costs you nothing but that one pad type. 2. Put your `host.env` in place, then start the host. Every Linux package ships a systemd **user** unit, so you don't run the host by hand — but that unit reads `~/.config/punktfunk/host.env` and won't start until the file exists. Each package ships a template to copy; your distro and desktop diff --git a/docs-site/content/docs/running-as-a-service.md b/docs-site/content/docs/running-as-a-service.md index e3b9627e..238ac696 100644 --- a/docs-site/content/docs/running-as-a-service.md +++ b/docs-site/content/docs/running-as-a-service.md @@ -133,7 +133,11 @@ disable, and the session unit differ per compositor, so each is documented on it - GNOME: [GNOME → Headless session](/docs/gnome#headless-session). - KDE Plasma: [KDE → Headless session](/docs/kde#headless-session). - Steam / gamescope: [gamescope](/docs/gamescope) — the host launches its own session per client, so - there's no separate session unit. + there's no separate session unit. A headless box that autologins into **Gaming Mode** needs one + more thing: your user in the `punktfunk` group (`sudo usermod -aG punktfunk "$USER"`, then log + out and back in). Without it the host cannot stop the display manager to take that session over, + so every connect quietly mirrors the box's own screen — which, headless, is a black one. See + [gamescope → autologin display managers](/docs/gamescope#nobara-and-other-autologin-display-managers). Once a session comes up at boot, enable the host user service (section A) and reboot. The host comes up on that session. diff --git a/docs-site/content/docs/troubleshooting.md b/docs-site/content/docs/troubleshooting.md index f79bd9ee..03d3a655 100644 --- a/docs-site/content/docs/troubleshooting.md +++ b/docs-site/content/docs/troubleshooting.md @@ -198,6 +198,44 @@ Current hosts detect the display-manager flavor and never mask the session unit [gamescope → autologin display managers](/docs/gamescope) for the polkit rule that enables the full managed takeover on these boxes (without it the host mirrors Game Mode instead). +## Game Mode: black screen on connect, or the stream is stuck at the box's resolution + +You connect to a box that autologins into Steam **Gaming Mode** and get a black picture every time +— or a picture at the box's own resolution instead of the one your client asked for, with the box's +monitor still lit. Nothing errors: the client connects, the host logs no failure, no unit is failed. + +The managed takeover is being refused and the host is falling back to mirroring the box's own +session. On a box whose panel is off (a headless appliance, a TV that's been switched away) there +is nothing to mirror, so the fallback is a black screen. Almost always the cause is **group +membership**: the takeover stops the display manager through a root helper, and that helper serves +members of the `punktfunk` group only. + +```sh +id -nG | tr ' ' '\n' | grep -x punktfunk # are you in it? +journalctl --user -u punktfunk-host | grep -iE "punktfunk. group|takeover unavailable" +``` + +The host also checks at startup on any box that will need the takeover, so a fresh +`systemctl --user restart punktfunk-host` puts the answer at the top of the log. The fix is one +command and a fresh login: + +```sh +sudo usermod -aG punktfunk "$USER" # then log out and back in +``` + +> **Read the reason the log quotes before doing anything else.** The takeover has three other ways +> to be refused — no packaged helper (a tarball or source install), no polkit on the box, and +> polkit denying the action — and the host now prints which one it hit, verbatim from the +> privileged path. Hosts up to 0.27.0 printed a fixed guess instead ("reinstall the punktfunk +> package, or install the display-manager polkit rule from the docs"), and on the group case both +> of those suggestions were dead ends: neither adds anyone to a group. + +Two things this is *not*: it isn't the [pad group problem](#the-pad-works-but-arrives-as-an-xbox-360-controller-instead-of-a-steam-deck) +(same group, different symptom), and it isn't lingering — though a host with no login session of +its own enables lingering through the same helper, so an unjoined user often sees "enabling +lingering failed" first. Both are covered in +[gamescope → autologin display managers](/docs/gamescope#nobara-and-other-autologin-display-managers). + ## Session fails right after editing host.env - Keys are **case-sensitive**: `punktfunk_gamescope_attach=1` sets nothing — use the exact @@ -275,6 +313,10 @@ the reliable way to get one. Joining the group is optional, and there is a real reason it is not automatic: writing that `attach` file materialises an arbitrary emulated USB device. Skip it on a machine you share. +It is not only the pad, though: the same group authorizes the helper that stops the display manager +for a managed **Gaming Mode** takeover, so on a box that autologins into Game Mode, skipping it also +costs you [the takeover](#game-mode-black-screen-on-connect-or-the-stream-is-stuck-at-the-boxs-resolution). + ## Copy and paste between host and client does nothing The shared clipboard needs **two** separate switches on, and turning on only one looks exactly like diff --git a/docs-site/content/docs/ubuntu.md b/docs-site/content/docs/ubuntu.md index 38860187..47f25e0e 100644 --- a/docs-site/content/docs/ubuntu.md +++ b/docs-site/content/docs/ubuntu.md @@ -111,17 +111,20 @@ re-login so the new group membership takes effect: sudo usermod -aG input "$USER" # re-login to apply ``` -Only if you want the **virtual Steam Deck controller** (paddles, trackpads, gyro), also join -`punktfunk`. That pad reaches games as a real USB device over usbip — which is what makes Steam -Input adopt it — and the group gating those nodes is deliberately separate from `input`, because +Also join `punktfunk` if **either** applies — you want the **virtual Steam Deck controller** +(paddles, trackpads, gyro), or this box autologins into Steam **Gaming Mode** and you want the host +to take that session over at the client's resolution. That pad reaches games as a real USB device +over usbip — which is what makes Steam Input adopt it — and the same group authorizes the helper +that stops the display manager for a takeover. It is deliberately separate from `input`, because writing the usbip `attach` file can materialise arbitrary emulated USB hardware: ```sh sudo usermod -aG punktfunk "$USER" # re-login to apply ``` -Join it only on a machine you trust. Skip it and everything else still works; the pad just arrives -as an ordinary Xbox 360 controller. +Join it only on a machine you trust. On a plain desktop host, skipping it costs you nothing but +that one pad type; on a Gaming Mode box the takeover silently degrades to mirroring the box's own +screen — see [gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers). ## 4. Check it installed diff --git a/packaging/arch/punktfunk-host.install b/packaging/arch/punktfunk-host.install index 0890f62c..89a7700b 100644 --- a/packaging/arch/punktfunk-host.install +++ b/packaging/arch/punktfunk-host.install @@ -8,7 +8,10 @@ _ensure_punktfunk_group() { # Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Separate from 'input' on # purpose: writing 'attach' materialises an arbitrary emulated USB device, which is a root-only # kernel primitive and must not ride on the group users are told to join for gamepads - # (security-review 2026-08-05 M-4). + # (security-review 2026-08-05 M-4). It is ALSO the group pf-dm-helper authorizes on (its polkit + # action must stay allow_any, so membership is the real gate), i.e. what a managed gamescope + # takeover needs to stop the display manager. Creating the group is necessary and NOT sufficient + # for either use: membership is. getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true } @@ -62,9 +65,11 @@ post_install() { punktfunk-host installed. 1. Add yourself to the 'input' group for virtual gamepads: sudo usermod -aG input "$USER" # then re-login - Only if you want the virtual Steam Deck pad (usbip), ALSO join 'punktfunk': - sudo usermod -aG punktfunk "$USER" - That group can emulate arbitrary USB devices — join it only on a machine you trust. + ALSO join 'punktfunk' if this box streams Steam Gaming Mode (gamescope), or you want the + virtual Steam Deck pad (usbip): + sudo usermod -aG punktfunk "$USER" # then log out and back in + It authorizes stopping the display manager for a managed gamescope session, and the pad's + usbip nodes. It can emulate arbitrary USB devices — join it only on a machine you trust. 2. Pick a backend config (gamescope is the no-desktop default on SteamOS/Deck): mkdir -p ~/.config/punktfunk cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 5ebf9d10..4a5b2c33 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -292,7 +292,10 @@ if [ "$1" = "configure" ]; then # Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input': # writing 'attach' materialises an arbitrary emulated USB device — a root-only kernel # primitive that must not ride on the group users are told to join for gamepads - # (security-review 2026-08-05 M-4). + # (security-review 2026-08-05 M-4). It is ALSO the group pf-dm-helper authorizes on (its + # polkit action must stay allow_any, so membership is the real gate), i.e. what a managed + # gamescope takeover needs to stop the display manager. Creating the group is necessary and + # NOT sufficient for either use: membership is. getent group punktfunk >/dev/null 2>&1 || addgroup --system punktfunk 2>/dev/null || true # NO capability on the host binary — and an active removal of the one 0.26.0-1 granted here. # @@ -318,8 +321,13 @@ if [ "$1" = "configure" ]; then sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true echo "punktfunk-host installed. Add yourself to the 'input' group for virtual gamepads:" echo " sudo usermod -aG input \"\$USER\" # then re-login" - echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \"\$USER\"" - echo " — that group can emulate arbitrary USB devices; join it only on a machine you trust." + # Naming only the usbip pad here is how a Nobara host shipped broken: its owner had no Deck + # pad, so they correctly skipped this group — and then every managed gamescope takeover + # degraded silently, because pf-dm-helper (which stops the display manager) gates on membership. + echo "ALSO join 'punktfunk' if this box streams Steam Gaming Mode (gamescope) or you want the" + echo "virtual Steam Deck pad: sudo usermod -aG punktfunk \"\$USER\" # then log out and back in" + echo " — it authorizes stopping the display manager for a managed gamescope session, and the" + echo " pad's usbip nodes; it can emulate arbitrary USB devices, so join it only on a box you trust." echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env" echo "Enable: systemctl --user enable --now punktfunk-host" # Debian ships no active firewall and Ubuntu's ufw is inactive by default; hint whichever is present. diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 15eb3370..b65c4a40 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -595,6 +595,9 @@ getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-upd # Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Deliberately NOT 'input': writing # 'attach' materialises an arbitrary emulated USB device — a root-only kernel primitive that must # not ride on the group users are told to join for gamepads (security-review 2026-08-05 M-4). +# It is ALSO the group `pf-dm-helper` authorizes on (the polkit action must stay `allow_any`, so +# membership is the real gate) — so it is what a managed gamescope takeover needs to stop the +# display manager. Creating it is necessary and NOT sufficient for either use: membership is. getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || : # Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort). udevadm control --reload-rules 2>/dev/null || : @@ -603,8 +606,13 @@ udevadm trigger --subsystem-match=misc 2>/dev/null || : # it takes effect on the next boot into the layered deployment). sysctl -p %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || : echo "punktfunk installed. Add yourself to the 'input' group (sudo usermod -aG input \$USER)" -echo "For the virtual Steam Deck pad (usbip) ALSO: sudo usermod -aG punktfunk \$USER" -echo " — that group can emulate arbitrary USB devices; join it only on a machine you trust." +# Naming only the usbip pad here is how a Nobara host shipped broken: its owner had no Deck pad, so +# they correctly skipped this group — and then every managed gamescope takeover degraded silently, +# because pf-dm-helper (which stops the display manager for the stream) gates on THIS membership. +echo "ALSO join 'punktfunk' if this box streams Steam Gaming Mode (gamescope) or you want the" +echo "virtual Steam Deck pad: sudo usermod -aG punktfunk \$USER # then log out and back in" +echo " — it authorizes stopping the display manager for a managed gamescope session, and the" +echo " pad's usbip nodes; it can emulate arbitrary USB devices, so join it only on a box you trust." echo "then enable the host: systemctl --user enable --now punktfunk-host" echo "Config: cp %{_datadir}/%{name}/host.env.bazzite ~/.config/punktfunk/host.env" # Fedora/RHEL run firewalld by default — point the way to the installed service definitions.