Merge pull request 'The Game Mode takeover blamed polkit for a group it never named, and prescribed two remedies that cannot work' (#148) from worktree-dm-helper-preflight into main
apple / swift (push) Successful in 1m50s
ci / web (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 3m7s
ci / docs-site (push) Successful in 1m14s
ci / bun-nix (push) Successful in 30s
android / android (push) Successful in 6m53s
arch / build-publish (push) Successful in 8m35s
apple / screenshots (push) Successful in 6m15s
deb / build-publish (push) Successful in 3m43s
deb / build-publish-client-arm64 (push) Successful in 3m34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 43s
windows-host / package (push) Successful in 12m7s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 11m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Failing after 41s
windows-host / canary-manifest (push) Successful in 25s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 9m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m29s
apple / swift (push) Successful in 1m50s
ci / web (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 3m7s
ci / docs-site (push) Successful in 1m14s
ci / bun-nix (push) Successful in 30s
android / android (push) Successful in 6m53s
arch / build-publish (push) Successful in 8m35s
apple / screenshots (push) Successful in 6m15s
deb / build-publish (push) Successful in 3m43s
deb / build-publish-client-arm64 (push) Successful in 3m34s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 43s
windows-host / package (push) Successful in 12m7s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 11m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Failing after 41s
windows-host / canary-manifest (push) Successful in 25s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 9m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m29s
Reviewed-on: #148
This commit was merged in pull request #148.
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::{
|
||||
|
||||
@@ -1571,21 +1571,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
|
||||
@@ -1618,12 +1858,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
|
||||
@@ -1632,16 +1877,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
|
||||
@@ -1679,20 +1936,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
|
||||
@@ -1778,9 +2044,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"
|
||||
);
|
||||
@@ -1913,19 +2180,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 \
|
||||
@@ -1933,23 +2211,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 {
|
||||
@@ -2320,22 +2596,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;
|
||||
}
|
||||
@@ -3045,7 +3325,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
|
||||
@@ -3147,6 +3427,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