fix(pf-vdisplay): the takeover blamed polkit for a group it never named, and offered two remedies that cannot work
Field triage on Nobara, 2026-08-09. Every connect degraded to ATTACH — which on that box mirrors a
game-mode session the host never configured, and looked like a black screen on every connect. The
host said:
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)
Every clause of that was wrong. The action was installed, `allow_any`, and its exec.path annotation
matched the installed helper; pkexec authorized it and RAN the helper. The helper refused, and said
exactly why:
pf-dm-helper: user 'nobara-user' is not in the 'punktfunk' group — refusing.
Grant it with: sudo usermod -aG punktfunk nobara-user (then re-login)
That text never reached the log, because `dm_helper` ran the helper with `.status()` — which
discards stderr and collapses the exit code to a bool. The one thing that would have ended the
investigation in seconds was thrown away at the call site, and the caller then guessed. Neither
suggested remedy adds anyone to a group, so a reader who followed both stayed broken and learned the
docs were useless. It fails soft, with no error and no failed unit, so nobody finds it on purpose.
Now: `.output()`, and four failure modes that stay distinguishable because they need different
fixes — helper not installed, pkexec could not run it, polkit denied it (pkexec's own 126/127), and
the helper ran and refused, whose stderr rides through VERBATIM rather than being re-described. Null
stdin too, so a pkexec that decides to prompt gets EOF instead of parking a stream thread on a tty
read.
The same gate gates the `linger` verb, so on a sessionless host an unjoined user fails there first —
carrying the reason there as well, or the misdiagnosis just moves one message earlier.
A new startup preflight says it before a stream is being built rather than during one, gated so it
cannot nag a box that would never attempt a takeover: not root, a display-manager alias exists, a
managed session launcher exists, a packaged helper exists, and the user is not in the group. It reads
membership from the user database rather than this process's groups, deliberately: that is what the
helper reads (it runs as root and resolves the caller from the database), so `usermod -aG` satisfies
the DM gate immediately and the warning stops. Using `getgroups()` would keep warning on a box where
the takeover already works.
Packaging said the group was for "the virtual Steam Deck pad (usbip)" — so anyone without a Deck pad
correctly skipped it and landed here by following instructions properly. All three scriptlets now
lead with Game Mode, name both grants, and record that creating the group is necessary and NOT
sufficient. Docs get the same treatment: the group is an admonition above the DM-flavor list in
gamescope.md, a black-screen entry in troubleshooting.md that tells the reader to read the quoted
reason FIRST, and the per-distro install pages no longer frame it as pad-only.
This commit is contained in:
@@ -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::{
|
||||
|
||||
@@ -1489,21 +1489,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<i32>,
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `": <text>"`, 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::<Vec<_>>()
|
||||
.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 <user>`. 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 <dm>` 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 <uid>`
|
||||
/// 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<String> {
|
||||
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 +1776,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 +1795,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 +1854,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 +1962,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 +2098,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 +2129,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 {
|
||||
@@ -2238,22 +2514,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;
|
||||
}
|
||||
@@ -2874,7 +3154,7 @@ mod tests {
|
||||
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,
|
||||
shape_dedicated_command, DmHelperError,
|
||||
};
|
||||
|
||||
/// The HDR spawn flags are what make a nested game render HDR at all — and their absence is
|
||||
@@ -2976,6 +3256,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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 <pkg>` would drop one onto a system whose other packages are still old —
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user