fix(gamescope): ship a packaged privilege path for the DM-stop takeover
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 55s
android / android (push) Successful in 12m5s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / bench (push) Successful in 6m29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 8m40s
arch / build-publish (push) Successful in 14m51s
deb / build-publish-host (push) Successful in 9m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 15m32s
apple / swift (push) Successful in 23m28s
ci / rust (push) Successful in 26m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m37s
apple / screenshots (push) Successful in 2h32m51s

Field report (Nobara, 0.19.2): entering Game Mode mid-stream left the monitor
on and the stream mirroring at the desktop's resolution. Root cause: the
DM-flavor takeover needs privilege to stop plasmalogin, the polkit rule is a
docs-only manual step nobody installs, so every managed entry silently
degraded to ATTACH — and with a physical display connected the attach path
mirrors the box's own session at its own mode.

Ship the privilege with the packages instead: a root helper
(libexec/punktfunk/pf-dm-helper, verbs stop|restore) behind its own polkit
action (io.unom.punktfunk.dm-helper, allow_any — the same mechanism Nobara's
os-session-select uses, and the helper derives the DM unit from the
display-manager.service symlink itself so callers never name a unit across
the privilege boundary). The host tries the plain system-bus verbs first
(root / operator rule still take precedence), then pkexec's the helper; the
restore paths (idle restore + in-stream desktop-switch honor) use the same
ladder so a takeover that needed the helper can always be undone by it.

Packaged in rpm/deb/arch (Arch under /usr/lib/punktfunk with the policy's
exec.path annotation rewritten; the host probes both layouts). Nix is left
out deliberately: store paths can't match the probe, NixOS keeps the manual
rule. Docs updated — the rule snippet stays as the scoped alternative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 23:41:29 +02:00
co-authored by Claude Fable 5
parent 5c7a9407ff
commit 8d5a9f66c9
7 changed files with 173 additions and 31 deletions
@@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool {
dm == "sddm.service" dm == "sddm.service"
} }
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on /// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see /// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the /// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
/// caller degrades to attach. /// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
fn try_stop_display_manager(dm: &str) -> bool { /// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
Command::new("systemctl") /// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
.args(["stop", dm]) /// rpm/deb `libexec` path (what the shipped policy annotates) and Arch's `/usr/lib/<pkg>` (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() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .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 /// 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 /// 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 /// 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(); clear_takeover();
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; *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 stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
let _ = Command::new("systemctl") if !restore_display_manager(&dm) {
.args(["reset-failed", &dm]) tracing::warn!(
.status(); %dm,
let _ = Command::new("systemctl").args(["start", &dm]).status(); "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); let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline { while Instant::now() < deadline {
let active = Command::new("systemctl") let active = Command::new("systemctl")
@@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> {
if !try_stop_display_manager(&dm) { if !try_stop_display_manager(&dm) {
bail!( bail!(
"the box's gaming session is driven by {dm}, which does not survive a masked \ "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 \ session unit, and stopping it needs privilege — the packaged pf-dm-helper \
display-manager polkit rule (see docs) to enable the managed takeover" 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!( tracing::info!(
@@ -1668,14 +1722,7 @@ fn do_restore_tv_session() {
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin // (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
// session Exec starts the gamescope unit itself. // session Exec starts the gamescope unit itself.
if let Some(dm) = dm { if let Some(dm) = dm {
let _ = Command::new("systemctl") let restart = restore_display_manager(&dm);
.args(["reset-failed", &dm])
.status();
let restart = Command::new("systemctl")
.args(["restart", &dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
if restart { if restart {
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)"); tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
} else if crate::try_recover_session() { } else if crate::try_recover_session() {
+19 -10
View File
@@ -40,8 +40,16 @@ depends on the display manager driving the autologin:
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup. - **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager - **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 itself for the length of the stream and restart it afterwards, which needs privilege. The
with a polkit rule (adjust the unit and user names to your box): 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 ```js
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules // /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 With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
display manager. If the display-manager restart ever loses its privilege mid-restore, the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the what happened; check the host log for "managed takeover unavailable"). If the display-manager
fallback. 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 With the takeover authorized the **in-stream session switch round-trips** in managed mode:
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again. and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
## Session following ## Session following
+8
View File
@@ -129,6 +129,14 @@ package_punktfunk-host() {
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host" install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID) # /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" 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) # 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" 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) # 32 MB UDP socket buffers (send-side headroom at high bitrate)
+5
View File
@@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG"
# --- file layout (matches the RPM %install) ---------------------------------- # --- file layout (matches the RPM %install) ----------------------------------
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG" install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules" 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). # 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" 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 # UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
+9
View File
@@ -249,6 +249,12 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
# udev rule — /dev/uinput access for virtual gamepads (input group). # udev rule — /dev/uinput access for virtual gamepads (input group).
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules 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 # 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). # 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 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-host
%{_bindir}/punktfunk-tray %{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules %{_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/modules-load.d/punktfunk.conf
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml %{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<vendor>Punktfunk</vendor>
<vendor_url>https://punktfunk.unom.io</vendor_url>
<!-- Stop/restore the box's display manager for a managed gamescope takeover. The helper derives
the unit from the display-manager.service symlink itself (no caller-chosen units), so this
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
these distros already authorize for their session switcher (e.g. Nobara's
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
<action id="io.unom.punktfunk.dm-helper">
<description>Stop or restore the display manager for a Punktfunk stream</description>
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
<defaults>
<allow_any>yes</allow_any>
<allow_inactive>yes</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/libexec/punktfunk/pf-dm-helper</annotate>
</action>
</policyconfig>
+39
View File
@@ -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