diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 98a6ab46..fbd46791 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool { dm == "sddm.service" } -/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on -/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see -/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the -/// caller degrades to attach. -fn try_stop_display_manager(dm: &str) -> bool { - Command::new("systemctl") - .args(["stop", dm]) +/// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind +/// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these +/// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed +/// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule. +/// The helper derives the DM unit from the `display-manager.service` symlink itself, so this +/// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the +/// rpm/deb `libexec` path (what the shipped policy annotates) and Arch's `/usr/lib/` (its +/// PKGBUILD rewrites the annotation to match). +const DM_HELPER_PATHS: &[&str] = &[ + "/usr/libexec/punktfunk/pf-dm-helper", + "/usr/lib/punktfunk/pf-dm-helper", +]; + +/// Run the packaged DM helper (`stop` | `restore`) 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 + .iter() + .find(|p| std::path::Path::new(p).exists()) + else { + return false; + }; + Command::new("pkexec") + .arg(helper) + .arg(verb) .status() .map(|s| s.success()) .unwrap_or(false) } +/// 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 { + let direct = Command::new("systemctl") + .args(["stop", dm]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + direct || 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 { + let _ = Command::new("systemctl") + .args(["reset-failed", dm]) + .status(); + let direct = Command::new("systemctl") + .args(["restart", dm]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + direct || dm_helper("restore") +} + /// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the /// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so /// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but @@ -1219,10 +1268,13 @@ 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 - let _ = Command::new("systemctl") - .args(["reset-failed", &dm]) - .status(); - let _ = Command::new("systemctl").args(["start", &dm]).status(); + if !restore_display_manager(&dm) { + tracing::warn!( + %dm, + "gamescope: display-manager start was denied — the desktop switch may need a manual \ + `systemctl restart` of the DM" + ); + } let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { let active = Command::new("systemctl") @@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> { if !try_stop_display_manager(&dm) { bail!( "the box's gaming session is driven by {dm}, which does not survive a masked \ - session unit, and stopping it needs privilege — install the punktfunk \ - display-manager polkit rule (see docs) to enable the managed takeover" + 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" ); } tracing::info!( @@ -1668,14 +1722,7 @@ fn do_restore_tv_session() { // (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin // session Exec starts the gamescope unit itself. if let Some(dm) = dm { - let _ = Command::new("systemctl") - .args(["reset-failed", &dm]) - .status(); - let restart = Command::new("systemctl") - .args(["restart", &dm]) - .status() - .map(|s| s.success()) - .unwrap_or(false); + 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() { diff --git a/docs-site/content/docs/gamescope.md b/docs-site/content/docs/gamescope.md index b2b56460..6c9adace 100644 --- a/docs-site/content/docs/gamescope.md +++ b/docs-site/content/docs/gamescope.md @@ -40,8 +40,16 @@ depends on the display manager driving the autologin: - **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. Allow it - with a polkit rule (adjust the unit and user names to your box): + 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`) 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 + `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`). + + Installed from a tarball, or prefer not to ship the `allow_any` action? Remove the `.policy` + file and use a polkit rule scoped to your user instead (adjust the unit and user names to your + box) — the host tries the plain verbs first, so the rule takes precedence: ```js // /etc/polkit-1/rules.d/49-punktfunk-dm.rules @@ -54,15 +62,16 @@ depends on the display manager driving the autologin: }); ``` - Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session - instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the - display manager. If the display-manager restart ever loses its privilege mid-restore, - `PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the - fallback. + 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 + [Configuration](/docs/configuration)) is fired as the fallback. - With the rule in place 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 and the - stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again. + 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 + and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again. ## Session following diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index e281d015..00d01278 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -129,6 +129,14 @@ package_punktfunk-host() { install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host" # /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID) install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules" + # Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can + # stop/restore the display manager for the stream. Arch has no /usr/libexec — install under + # /usr/lib/punktfunk and rewrite the policy's exec.path annotation to match (the host probes both). + install -Dm0755 "$R/scripts/pf-dm-helper" "$pkgdir/usr/lib/punktfunk/pf-dm-helper" + install -Dm0644 "$R/scripts/io.unom.punktfunk.dm-helper.policy" \ + "$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy" + sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \ + "$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy" # vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads) install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf" # 32 MB UDP socket buffers (send-side headroom at high bitrate) diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 413b4a57..7d66222f 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG" # --- file layout (matches the RPM %install) ---------------------------------- install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG" install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules" +# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can +# stop/restore the display manager for the stream (the helper derives the DM unit itself). +install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper" +install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \ + "$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy" # vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads). install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf" # UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index b4be9583..a8719e3f 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -249,6 +249,12 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h # udev rule — /dev/uinput access for virtual gamepads (input group). install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules +# Managed gamescope takeover on DM-autologin boxes (Nobara's plasmalogin): a root helper + polkit +# action let the host stop/restore the display manager for the stream without a hand-installed +# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units. +install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper +install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy + # vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a # real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam). install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf @@ -383,6 +389,9 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/ %{_bindir}/punktfunk-host %{_bindir}/punktfunk-tray %{_udevrulesdir}/60-punktfunk.rules +%dir %{_libexecdir}/punktfunk +%{_libexecdir}/punktfunk/pf-dm-helper +%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy %{_prefix}/lib/modules-load.d/punktfunk.conf %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf %{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml diff --git a/scripts/io.unom.punktfunk.dm-helper.policy b/scripts/io.unom.punktfunk.dm-helper.policy new file mode 100644 index 00000000..a63598c3 --- /dev/null +++ b/scripts/io.unom.punktfunk.dm-helper.policy @@ -0,0 +1,25 @@ + + + + Punktfunk + https://punktfunk.unom.io + + + + Stop or restore the display manager for a Punktfunk stream + Authentication is required to switch the display manager for a Punktfunk stream + + yes + yes + yes + + /usr/libexec/punktfunk/pf-dm-helper + + diff --git a/scripts/pf-dm-helper b/scripts/pf-dm-helper new file mode 100644 index 00000000..399e95ad --- /dev/null +++ b/scripts/pf-dm-helper @@ -0,0 +1,39 @@ +#!/bin/sh +# Privileged display-manager verbs for the punktfunk managed gamescope takeover. +# +# On DM-autologin boxes whose display manager does not survive a masked session unit (Nobara's +# plasmalogin, unknown DMs), taking the Gaming Mode session over means stopping the display +# manager for the length of the stream and restarting it afterwards — root-only operations. The +# host invokes this helper via pkexec under its own polkit action +# (io.unom.punktfunk.dm-helper.policy, installed by the packages), the same mechanism these +# distros use for their own session switching (Nobara's os-session-select). +# +# The unit is NEVER caller-controlled: it is derived here from the display-manager.service alias +# symlink, so the polkit grant's blast radius is exactly "the box's own display manager" — a +# local-seat operation, not arbitrary unit management. +set -eu + +dm_unit() { + target=$(readlink /etc/systemd/system/display-manager.service) || { + echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2 + exit 1 + } + basename "$target" +} + +case "${1-}" in + stop) + exec systemctl stop "$(dm_unit)" + ;; + restore) + dm=$(dm_unit) + # reset-failed first: a relogin loop may have tripped the unit's start limit, and a plain + # restart would be refused until the accounting is cleared. + systemctl reset-failed "$dm" 2>/dev/null || : + exec systemctl restart "$dm" + ;; + *) + echo "usage: pf-dm-helper stop|restore" >&2 + exit 2 + ;; +esac