fix(gamescope): a stopped display manager left the box unable to switch sessions — idle its autologin instead

The managed takeover freed Steam by stopping the box's gaming session and,
because a display-manager autologin puts that session straight back, by stopping
the display manager too. That worked until the user asked for a desktop session:
with no DM there is nothing on the box able to start one, so Steam's own "Switch
to Desktop" sat on its modal until a reboot (field report 2026-08-18, .41).

It also could not be detected and worked around. On a steamos-manager box the
switch is a D-Bus call whose every trace is written by the display manager we had
just stopped — measured on .41: the `~/.config/steamos-session-select` sentinel is
never written (that is the ChimeraOS/Nobara layout), `/var/lib/sddm/state.conf`
only advances when sddm actually STARTS a session, `get-default-login-mode` stays
`game` for a non-persistent switch, and `graphical-session.target` going inactive
fires at takeover time too. There is nothing to watch, because the component that
would produce the signal is the one we disabled.

So stop disabling it. The takeover now idles the box's autologin session for the
stream's duration — a drop-in over the `gamescope-session-plus@` template that
replaces `ExecStart` with a process that sleeps — and leaves the DM running. The
autologin still SUCCEEDS, so there is no failed unit to relogin against (a masked
one fails in milliseconds, which is the storm's engine); the session runs nothing,
so Steam is free; and the DM is alive, so the box can service the switch itself.
No privilege, no DM-flavor matrix, and no detection.

Measured on .41, both directions: takeover leaves `steam` down, `sddm` active and
the unit `active (running)` with NRestarts=0; the switch that used to hang brings
Plasma up in ~10 s; the restore puts Steam back within 5 s.

The drop-in lives under $XDG_RUNTIME_DIR for the same reason the bind one does —
it replaces the box's game-mode ExecStart, so a copy outliving the host would be a
box whose Game Mode silently does nothing — and is swept unconditionally at
startup. Its removal sits above every early return in the restore, next to the
bind drop-in's, because the desktop-active return is exactly the path that would
leak it. The restore restarts rather than starts: the unit is active-but-idle, and
`start` on an active unit is a no-op that would log success over it.

Docs and the shipped Bazzite host.env said the takeover has to stop the display
manager and needs the `punktfunk` group; both are now false. The group still gates
the usbip nodes the virtual Steam Deck pad attaches through, which is what the
advice narrows to.
This commit is contained in:
2026-08-19 18:58:18 +02:00
parent b6ca692c13
commit c2f5e91b3d
4 changed files with 187 additions and 115 deletions
@@ -128,6 +128,11 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
/// [`restore_takeover_on_startup`] sets it for a stranded takeover it adopts: unmasking a unit we
/// never masked is a no-op, while missing one that IS masked leaves the box unable to enter its
/// own Game Mode until reboot.
///
/// ⚠ The takeover itself no longer masks anything — it idles the autologin session instead
/// ([`install_idle_dropin`]), because a masked unit FAILS and a failing unit is what the display
/// manager relogin-loops against. So this is now only ever true for a takeover adopted from a
/// host old enough to have laid one, and the lift paths stay for exactly that box.
static AUTOLOGIN_MASKED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
@@ -158,6 +163,10 @@ static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::
/// giving the DM's desktop session time to come up so re-detection follows it instead.
const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
/// Whether [`install_idle_dropin`] has one outstanding. Process memory only — the sweep in
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
static IDLE_DROPIN_ARMED: std::sync::Mutex<bool> = std::sync::Mutex::new(false);
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
@@ -373,6 +382,15 @@ pub fn restore_takeover_on_startup() {
);
systemctl_user(&["daemon-reload"]);
}
// Same shape, same reason: a host that died mid-stream leaves the box's Game Mode replaced by
// a session that does nothing at all, which looks exactly like broken hardware. Runtime-dir
// state, so a reboot clears it too — this covers the restart that does not.
if remove_idle_dropin() {
tracing::warn!(
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
own Game Mode session would have started and then done nothing"
);
}
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
return; // no takeover file — clean start
};
@@ -1287,6 +1305,74 @@ fn legacy_session_plus_dropin_path() -> std::path::PathBuf {
.join(".config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf")
}
/// Where the takeover's IDLE drop-in lives. Same runtime-dir argument as
/// [`session_plus_dropin_path`], and here it is the safety property the mechanism rests on rather
/// than a tidiness one: this drop-in replaces the box's game-mode `ExecStart`, so a copy that
/// outlived the host would leave the box unable to enter Game Mode at all. Under
/// `$XDG_RUNTIME_DIR` it dies with the login session, and a reboot restores game mode by itself —
/// on top of the unconditional sweep [`restore_takeover_on_startup`] does.
fn idle_dropin_path() -> std::path::PathBuf {
let base = crate::session::runtime_dir();
std::path::Path::new(&base)
.join("systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-idle.conf")
}
/// `sleep`'s path on this box. The idle `ExecStart` must not be a command that can fail to
/// EXECUTE: a unit that dies on start is precisely the relogin storm this drop-in exists to avoid
/// ([`mask_unit`] has that chain), so resolve it instead of hardcoding one distro's layout.
fn sleep_binary() -> &'static str {
["/usr/bin/sleep", "/bin/sleep"]
.into_iter()
.find(|p| std::path::Path::new(p).exists())
.unwrap_or("/usr/bin/sleep")
}
/// Idle the box's autologin game session for the stream's duration: a drop-in over the
/// `gamescope-session-plus@` TEMPLATE (so it reaches whichever instance this box autologs into)
/// that replaces `ExecStart` with a process which merely sleeps.
///
/// This is what the takeover uses INSTEAD of stopping the display manager, and it satisfies all
/// three things that path has to get right at once. Steam is freed (the session runs nothing).
/// The DM does not storm: its autologin still SUCCEEDS, so there is no failed session to relogin
/// against — unlike a masked unit, which fails in milliseconds and is the storm's engine. And the
/// box keeps a live display manager, so a session switch the user asks for can still be serviced;
/// that is the one a stopped DM could not, and it stranded `.41` on Steam's "Switch to Desktop"
/// modal until a reboot.
///
/// Measured on that box: with this installed, `steam` is down, `sddm` stays active, the unit sits
/// `active (running)` with `NRestarts=0`, and a subsequent `switch-to-desktop-mode` brings Plasma
/// up in ~10 s.
fn install_idle_dropin() -> Result<()> {
let path = idle_dropin_path();
let dir = path
.parent()
.context("the idle drop-in path has no parent directory")?;
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
std::fs::write(
&path,
format!(
"[Service]\nExecStart=\nExecStart={} infinity\n",
sleep_binary()
),
)
.with_context(|| format!("write {}", path.display()))?;
systemctl_user(&["daemon-reload"]);
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = true;
Ok(())
}
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
/// the drop-in outliving a host that died is exactly the case that has to be swept.
fn remove_idle_dropin() -> bool {
let removed = std::fs::remove_file(idle_dropin_path()).is_ok();
*IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) = false;
if removed {
systemctl_user(&["daemon-reload"]);
}
removed
}
/// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and
/// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS`
/// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60).
@@ -2230,16 +2316,24 @@ struct DmPlan {
/// no Steam, masking them while a DM is up is the relogin storm ([`mask_unit`]), and stopping
/// the DM would kill the user's live desktop for it.
skip: bool,
/// Stop the DM for the stream's duration (only a live instance justifies it). Masking is not
/// a plan input: it is laid only once this stop has LANDED, so it can never substitute for it.
stop_dm: bool,
/// A display manager drives this LIVE gaming session, so its autologin brings the session
/// straight back the moment we free Steam. That is what the idle drop-in answers
/// ([`install_idle_dropin`]) — not, any longer, stopping the DM.
///
/// Stopping it satisfied the same requirement and broke a different one: a box with no DM has
/// nothing that can start a desktop session, so the user's own "Switch to Desktop" hung on
/// Steam's modal until a reboot (field report 2026-08-18). It hung UNDETECTABLY, which is why
/// no amount of watching fixes it: on a `steamos-manager` box the switch is a D-Bus call whose
/// every trace — the sddm state file, the session units, the login mode — is written by the
/// display manager we had just stopped. Leave the DM up and there is nothing to detect.
dm_relogins: bool,
}
/// See [`DmPlan`].
fn dm_plan(dm: Option<&str>, any_live: bool) -> DmPlan {
DmPlan {
skip: !any_live,
stop_dm: dm.is_some() && any_live,
dm_relogins: dm.is_some() && any_live,
}
}
@@ -2974,88 +3068,37 @@ fn stop_autologin_sessions() -> Result<()> {
if plan.skip {
return Ok(());
}
if plan.stop_dm {
let dm = dm.expect("stop_dm ⇒ Some");
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
// box's display manager down and nobody left to bring it back.
//
// BOTH arms below now BAIL, on every DM flavor. They did not always: SDDM used to degrade
// to mask-only here, on the reasoning that the mask still protects Steam and the cost is
// just relogin churn. It is not just churn — the mask is IN sddm's relogin path, so a
// mask without the stop is a 45 logins/s fork storm that drops the pad from 250 Hz to
// 1.4 Hz ([`mask_unit`]). Degrading to attach costs the client's mode; degrading to
// mask-only costs the user their input plane. Attach wins.
//
// Both bails 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.
if let Err(why) = ensure_host_survives_dm_stop() {
// 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; \
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}"
);
}
if let Err(why) = try_stop_display_manager(&dm) {
// ERROR, not WARN, and it names the SHAPE: this is the branch whose silence cost an
// evening on .41 — the takeover degraded, nothing failed loudly, and the storm that
// followed read as a pad bug. The `bail!` below reaches the caller's own warn line;
// this one exists so the shape survives into the journal even if the caller's does
// not, because the four shapes need four different fixes.
tracing::error!(
%dm,
shape = why.shape(),
reason = %why,
"the managed takeover planned to stop the display manager and could not — \
degrading to ATTACH rather than fighting its autologin: a killed session under \
a running DM relogin-loops at 4-5/s and starves the box's input plane"
);
bail!(
"the box's gaming session is driven by {dm}, and stopping it for the stream needs \
privilege this host does not have; taking over without stopping it would leave \
its autologin relogin-looping against us for the whole stream, so the managed \
takeover is unavailable {why}"
);
}
tracing::info!(
%dm,
"freed Steam: stopped the display manager for this stream (its autologin \
Relogin loop would otherwise churn against the takeover)"
);
// Baseline the switch sentinel HERE, not just at a successful launch: setting
// STOPPED_DM is what arms the honor gate, so from this instant an unbaselined
// sentinel would read as an in-stream "Switch to Desktop" — including the write from
// the switch that just brought the box INTO game mode. A successful launch
// re-baselines (tighter still).
record_session_select_baseline();
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
// Already idled by an earlier connect in this stream's life? Then the session listed as "live"
// above is our own idled one — it holds no Steam and there is nothing left to free. Without
// this, every reconnect and every in-place rebuild would kill and restart the box's session
// again to accomplish exactly nothing.
if *IDLE_DROPIN_ARMED.lock().unwrap_or_else(|e| e.into_inner()) {
return Ok(());
}
// The display manager STAYS UP. Freeing Steam means ending the session its autologin owns, and
// the two ways to stop that autologin fighting us are not equivalent: stopping the DM works
// until the user asks for a desktop session, at which point nothing on the box can give them
// one ([`DmPlan::dm_relogins`]). Idling the session instead keeps the autologin succeeding
// no failed unit to relogin against, no storm — while leaving the DM able to service that
// switch.
if plan.dm_relogins {
install_idle_dropin().context("idling the box's autologin game session for the stream")?;
}
// Reaching here means no display manager can relogin against us: either there is none
// (`!plan.stop_dm` with `dm == None`), or the stop above LANDED — both failure arms bail. That
// is the precondition the mask needs, and the only one under which it is a defense rather than
// the storm's accelerator ([`mask_unit`]).
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
let mut stopped = Vec::new();
// Record that a mask is outstanding BEFORE laying it: every hand-back path lifts it off this
// flag, and one that ran between the mask and an unrecorded flag would leave it on forever.
*AUTOLOGIN_MASKED.lock().unwrap_or_else(|e| e.into_inner()) = true;
for unit in units {
mask_unit(&unit); // belt-and-braces: no DM is up to relogin through it
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
if plan.dm_relogins {
// Bring it back ourselves rather than waiting for the DM to notice: deterministic, and
// it closes the window in which the DM sees a dead session and starts churning. The
// drop-in above is already loaded, so what comes back runs nothing.
systemctl_user(&["restart", &unit]);
}
tracing::info!(
%unit,
dm_stopped = plan.stop_dm,
"freed Steam: masked and stopped the autologin gaming session for this stream"
idled = plan.dm_relogins,
"freed Steam: the box's autologin gaming session is idled for this stream (its \
display manager stays up, so the box can still switch sessions)"
);
stopped.push(unit);
}
@@ -3591,6 +3634,17 @@ fn do_restore_tv_session() {
// rests on. It used to sit after the desktop-active and DM returns, so those two paths leaked
// it.
disarm_session_plus_dropin();
// The idle drop-in belongs to the same rule and leaks the same way — worse, in fact: the bind
// one leaves the box's Game Mode running OUR gamescope, this one leaves it running NOTHING.
// The desktop-active return below is the live case (the user switched away, so we never
// restart the units), and a drop-in left there is a box whose Game Mode silently does nothing
// for the rest of the login.
if remove_idle_dropin() {
tracing::info!(
"gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
real again"
);
}
unset_forced_session_screen_env();
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
@@ -3648,12 +3702,16 @@ fn do_restore_tv_session() {
clear_takeover();
return;
}
// (The idle drop-in is already gone — removed above every early return, so the restarts
// below bring the box's real session back rather than another idle one.)
for unit in units {
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
// that logged an unconditional success over a thrown-away exit status. A `--user start`
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
// and the DM branch thirty lines up already shows the shape — say what happened.
match issue_restore_verb(&["start", &unit]) {
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
// active unit is a no-op that would report success over a session still running nothing.
match issue_restore_verb(&["restart", &unit]) {
RestoreVerb::Done => tracing::info!(
unit,
"restored the TV's autologin gaming session (debounce elapsed, no client)"
@@ -5583,27 +5641,27 @@ mod tests {
}
#[test]
fn dm_plan_stops_any_dm_that_drove_a_live_session() {
// A live gaming session behind a DM: stop the DM, whatever the flavor. The mask alone does
// NOT stop the relogin loop — on .41 it is what makes the loop fast, because the session
// script's last act is `systemctl --user --wait start gamescope-session-plus@…` and a
// masked unit fails that in milliseconds (4-5 logins/s, pad at 1.4 Hz, 2026-08-18).
fn dm_plan_idles_any_dm_that_drove_a_live_session() {
// A live gaming session behind a DM: idle it, whatever the flavor. Neither of the two
// things that do NOT work is flavor-dependent — a mask fails the unit in milliseconds and
// makes the relogin loop fast (4-5 logins/s, pad at 1.4 Hz, 2026-08-18), and stopping the
// DM leaves nothing able to start a desktop session when the user asks for one.
let p = dm_plan(Some("sddm.service"), true);
assert!(!p.skip && p.stop_dm);
assert!(!p.skip && p.dm_relogins);
// Flavor is no longer an input: plasmalogin gets the same plan as sddm. It used to differ
// only to pick a DEGRADED mode (mask-only for sddm), and that degrade is now gone —
// `stop_autologin_sessions` bails to ATTACH instead.
let q = dm_plan(Some("plasmalogin.service"), true);
assert!(q.skip == p.skip && q.stop_dm == p.stop_dm);
assert!(q.skip == p.skip && q.dm_relogins == p.dm_relogins);
// Nothing live, DM present: hands off entirely, on EVERY flavor. Killing loaded-but-
// inactive leftovers frees no Steam; masking them while the DM is up is the storm; and
// stopping the DM would kill the user's live desktop for it.
assert!(dm_plan(Some("sddm.service"), false).skip);
assert!(dm_plan(Some("plasmalogin.service"), false).skip);
// No DM at all (getty autologin), live: mask+kill, nothing to stop — masking is sound
// here precisely because no relogin loop exists to run into it.
// No DM at all (getty autologin), live: kill and leave it stopped. Nothing relogins, so
// there is no autologin to idle — and no reason to leave a drop-in on the box.
let p = dm_plan(None, true);
assert!(!p.skip && !p.stop_dm);
assert!(!p.skip && !p.dm_relogins);
assert!(dm_plan(None, false).skip);
}
+8 -8
View File
@@ -135,13 +135,12 @@ sysext creates it on merge:
sudo usermod -aG punktfunk "$USER" # then log out and back in
```
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
This box **is** a Gaming Mode box, so that group is worth having: it gates the usbip `attach` file
the **virtual Steam Deck controller** (paddles, trackpads, gyro) attaches through. (The Gaming Mode
takeover itself no longer needs it — it idles the box's session with a user-level drop-in rather
than stopping the display manager.) 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
told to join for gamepads. Without it the pad arrives as an ordinary Xbox 360 controller — see
[gamescope](/docs/gamescope#nobara-and-other-autologin-display-managers).
## Configure
@@ -172,8 +171,9 @@ and on Bazzite (which ships `gamescope-session-plus`) that is **managed**:
relaunches it **headless** at the *client's* exact resolution and refresh — Game Mode on the
virtual screen — restoring the box on idle. This is the model that gives the client a display of
its **own**, and the only one under which a game launched from a client's library gets a
dedicated session. It needs the [`punktfunk` group](#allow-controller-input): the takeover stops
the display manager for the length of the stream, and without that grant it cannot.
dedicated session. The takeover idles the box's own session for the length of the
stream (a user-level drop-in — no privilege needed, and the display manager stays up, so Steam's
"Switch to Desktop" still works mid-stream).
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session on its own
display, and the host attaches to whatever's live without ever tearing it down (on a headless
box, a box-owned autologin session is restarted at the client's resolution on a mismatch; with a
+21 -12
View File
@@ -40,12 +40,23 @@ set** — which is what every shipped template does — a box that has gamescope
### Nobara and other autologin display managers
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.
The managed takeover has to stop the box's Gaming Mode session to free Steam — and when a display
manager autologs into that session, stopping it alone accomplishes nothing: the autologin puts it
straight back. So the host **idles** that session for the length of the stream instead, with a
systemd drop-in that replaces its `ExecStart` with a process that just sleeps. The autologin still
succeeds (nothing relogin-loops), the session it logs into does nothing (Steam is free), and the
**display manager keeps running** — which is what lets you still switch the box to Desktop Mode
from Steam while a stream is up.
> **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:
That needs no privilege at all: the drop-in is a user-level unit override, written under
`$XDG_RUNTIME_DIR` so it cannot outlive the login session, and a reboot clears it regardless.
Versions before this one stopped the display manager for the stream's duration — which needed a
root helper, the `punktfunk` group, and lingering, and left the box with nothing able to start a
desktop session, so Steam's own "Switch to Desktop" hung until a reboot.
> **Join the `punktfunk` group on any box you stream Game Mode from.** The takeover itself no
> longer needs it — the group now gates the usbip nodes the virtual Steam Deck pad attaches
> through, so without it the pad arrives as an ordinary Xbox 360 controller:
>
> ```sh
> sudo usermod -aG punktfunk "$USER" # then log out and back in
@@ -60,14 +71,12 @@ of the stream. That is a privileged operation, and the privilege is granted to o
> 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:
The display-manager flavor is no longer an input — SDDM, plasmalogin and the rest all get the
idled session above, and none of them is stopped. The root helper described below is therefore no
longer part of a normal takeover; it is kept for the restore path, and for a box where an older
host left a display manager stopped:
- **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
- The packages ship it: 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
+10 -5
View File
@@ -27,11 +27,16 @@ PUNKTFUNK_VIDEO_SOURCE=virtual
# instead of a copy of the TV, and it is also what lets a game launched from a client's library get
# a dedicated session to itself.
#
# The managed takeover has to stop the display manager for the length of the stream, and that is
# privileged: it works for members of the `punktfunk` group and nobody else. Join it once —
# `sudo usermod -aG punktfunk "$USER"`, then log out and back in. Skip it and the takeover cannot
# stop SDDM, which relogin-loops against it for the whole stream and can starve the game. The host
# checks at startup and says so in its log.
# The takeover needs no privilege and leaves the display manager RUNNING: it idles the box's own
# gaming session for the stream's duration (a user-level systemd drop-in that replaces the
# session's ExecStart), so the autologin still succeeds — nothing relogin-loops — while Steam is
# free for the stream. Because SDDM stays up, Steam's own "Switch to Desktop" still works while you
# are streaming. Earlier versions stopped the display manager instead, which needed the `punktfunk`
# group and left that switch hanging until a reboot.
#
# The `punktfunk` group is still worth joining for the virtual Steam Deck pad (it gates the usbip
# nodes; without it the pad arrives as an ordinary Xbox 360 controller):
# sudo usermod -aG punktfunk "$USER" # then log out and back in
#
# Opt IN to the ATTACH model if you would rather the BOX keep ownership: the host captures whatever
# gamescope is live and never tears it down, so Desktop<->Game switching is rock-solid and the box