Compare commits

...
3 Commits
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 8d5a9f66c9 fix(gamescope): ship a packaged privilege path for the DM-stop takeover
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 55s
android / android (push) Successful in 12m5s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
ci / bench (push) Successful in 6m29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / deploy-docs (push) Successful in 27s
deb / build-publish (push) Successful in 8m40s
arch / build-publish (push) Successful in 14m51s
deb / build-publish-host (push) Successful in 9m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 15m32s
apple / swift (push) Successful in 23m28s
apple / screenshots (push) In progress
ci / rust (push) Successful in 26m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m37s
Field report (Nobara, 0.19.2): entering Game Mode mid-stream left the monitor
on and the stream mirroring at the desktop's resolution. Root cause: the
DM-flavor takeover needs privilege to stop plasmalogin, the polkit rule is a
docs-only manual step nobody installs, so every managed entry silently
degraded to ATTACH — and with a physical display connected the attach path
mirrors the box's own session at its own mode.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:41:29 +02:00
enricobuehlerandClaude Opus 5 5c7a9407ff fix(windows/web): start the console once its inputs exist, and verify it started
arch / build-publish (push) Successful in 17m1s
ci / web (push) Successful in 53s
ci / docs-site (push) Successful in 58s
apple / swift (push) Successful in 1m19s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
ci / bench (push) Successful in 6m19s
deb / build-publish (push) Successful in 9m35s
docker / deploy-docs (push) Successful in 29s
apple / screenshots (push) Successful in 10m56s
deb / build-publish-host (push) Successful in 13m40s
android / android (push) Successful in 16m19s
windows-host / package (push) Successful in 17m13s
ci / rust (push) Successful in 19m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m18s
A fresh install left PunktfunkWeb registered but not running: `web setup` waited
only for the mgmt token before firing `schtasks /run`, while `web-run.cmd` also
requires the host identity cert — and the host writes the token during argument
parsing but `cert.pem` only after the pure-Rust RSA-2048 keygen inside `serve`.
The launcher lost that race, exited 1, and since the task carried no trigger but
boot (Task Scheduler does not reliably restart on a non-zero exit code) the
console stayed down until the next reboot, with the installer still reporting
"web console set up + started".

- `web setup` gates on cert.pem (written last) as well as the token, 90 s budget.
- After `schtasks /run`, poll for the :47992 listener and retry before giving up;
  warn honestly instead of claiming a start that did not happen.
- `web-run.cmd` (installed + dev) waits in-process for the token + cert (~5 min)
  rather than exiting 1 and hoping restart-on-failure retries.
- Register the task with a logon trigger alongside boot, falling back to the
  boot-only XML if a Task Scheduler build rejects it.
- Linux had the same defect: punktfunk-web.service's Restart=on-failure gave up
  permanently after systemd's default 5-starts-in-10 s limit. StartLimitIntervalSec=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 23:22:09 +02:00
enricobuehler 7781d09e26 feat(linux): log if unsupported / too low gamescope version discovered
apple / swift (push) Successful in 2m29s
android / android (push) Successful in 12m52s
arch / build-publish (push) Successful in 12m59s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m6s
ci / bench (push) Successful in 5m30s
ci / rust (push) Successful in 19m51s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
deb / build-publish (push) Successful in 8m43s
apple / screenshots (push) Successful in 20m59s
deb / build-publish-host (push) Successful in 9m25s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m22s
2026-07-24 20:57:44 +02:00
14 changed files with 367 additions and 85 deletions
+12
View File
@@ -2652,6 +2652,18 @@ mod pipewire {
build_default_format_obj(preferred) build_default_format_obj(preferred)
}; };
// gamescope trap — the Steam overlay's presence in the stream is decided HERE by omission:
// gamescope's `paint_pipewire()` composites the overlay (Shift+Tab / Quick Access Menu) into
// the node it hands us ONLY when the consumer-negotiated `gamescope_focus_appid` is 0 — the
// default, and the "mirror the focused window + overlay" branch (gamescope ≥ 3.16.23; see
// `MIN_GAMESCOPE_OVERLAY`). None of the EnumFormat pods below advertise the
// `SPA_FORMAT_VIDEO_gamescope_focus_appid` property, so gamescope reads 0 and paints the
// overlay for us for free. DO NOT add a non-zero focus-appid (e.g. to "isolate the game" in a
// dedicated session) — that flips gamescope into the Remote-Play branch that deliberately
// drops the overlay (and all host chrome) back out of the capture. The cursor, external
// overlay (MangoHUD), and notifications are excluded from the node on EVERY gamescope
// version and are composited host-side instead (see `xfixes_cursor.rs`).
//
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers // When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
// (offering shm too makes the compositor pick shm). The modifier list is advertised with // (offering shm too makes the compositor pick shm). The modifier list is advertised with
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in // DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
@@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool {
dm == "sddm.service" dm == "sddm.service"
} }
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on /// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see /// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the /// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
/// caller degrades to attach. /// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
fn try_stop_display_manager(dm: &str) -> bool { /// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
Command::new("systemctl") /// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
.args(["stop", dm]) /// rpm/deb `libexec` path (what the shipped policy annotates) and Arch's `/usr/lib/<pkg>` (its
/// PKGBUILD rewrites the annotation to match).
const DM_HELPER_PATHS: &[&str] = &[
"/usr/libexec/punktfunk/pf-dm-helper",
"/usr/lib/punktfunk/pf-dm-helper",
];
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
fn dm_helper(verb: &str) -> bool {
let Some(helper) = DM_HELPER_PATHS
.iter()
.find(|p| std::path::Path::new(p).exists())
else {
return false;
};
Command::new("pkexec")
.arg(helper)
.arg(verb)
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
} }
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
/// attach.
fn try_stop_display_manager(dm: &str) -> bool {
let direct = Command::new("systemctl")
.args(["stop", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("stop")
}
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
/// two steps as root.
fn restore_display_manager(dm: &str) -> bool {
let _ = Command::new("systemctl")
.args(["reset-failed", dm])
.status();
let direct = Command::new("systemctl")
.args(["restart", dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
direct || dm_helper("restore")
}
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the /// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so /// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but /// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
@@ -1219,10 +1268,13 @@ fn honor_session_select_switch(dm: String) {
clear_takeover(); clear_takeover();
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
let _ = Command::new("systemctl") if !restore_display_manager(&dm) {
.args(["reset-failed", &dm]) tracing::warn!(
.status(); %dm,
let _ = Command::new("systemctl").args(["start", &dm]).status(); "gamescope: display-manager start was denied — the desktop switch may need a manual \
`systemctl restart` of the DM"
);
}
let deadline = Instant::now() + Duration::from_secs(10); let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline { while Instant::now() < deadline {
let active = Command::new("systemctl") let active = Command::new("systemctl")
@@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> {
if !try_stop_display_manager(&dm) { if !try_stop_display_manager(&dm) {
bail!( bail!(
"the box's gaming session is driven by {dm}, which does not survive a masked \ "the box's gaming session is driven by {dm}, which does not survive a masked \
session unit, and stopping it needs privilege install the punktfunk \ session unit, and stopping it needs privilege the packaged pf-dm-helper \
display-manager polkit rule (see docs) to enable the managed takeover" polkit action is missing or was denied (reinstall the punktfunk package, or \
install the display-manager polkit rule from the docs) so the managed takeover \
is unavailable"
); );
} }
tracing::info!( tracing::info!(
@@ -1668,14 +1722,7 @@ fn do_restore_tv_session() {
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin // (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
// session Exec starts the gamescope unit itself. // session Exec starts the gamescope unit itself.
if let Some(dm) = dm { if let Some(dm) = dm {
let _ = Command::new("systemctl") let restart = restore_display_manager(&dm);
.args(["reset-failed", &dm])
.status();
let restart = Command::new("systemctl")
.args(["restart", &dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
if restart { if restart {
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)"); tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
} else if crate::try_recover_session() { } else if crate::try_recover_session() {
@@ -340,9 +340,19 @@ pub(crate) fn is_available() -> bool {
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon. /// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22); const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing /// First gamescope that paints the Steam overlay (Shift+Tab / Quick Access Menu) into its built-in
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a /// PipeWire node. `paint_pipewire()` is a *separate, reduced* composite from the display scanout;
/// gate. Returns the parsed version when it could read one. /// the overlay-window paint (gated on the consumer negotiating `gamescope_focus_appid == 0`, which
/// we do by never advertising that property — see the capturer's EnumFormat builders) first ships
/// in 3.16.23 (gamescope commits `ccd62074` + `f8b33d38`). Below this the overlay is *never* in the
/// node, so it cannot appear in the stream no matter what the host does. The cursor and
/// external-overlay / notification layers are excluded on *every* version (handled host-side).
const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
/// Best-effort: warn if the installed gamescope is older than [`MIN_GAMESCOPE`] (capture is
/// unreliable) or than [`MIN_GAMESCOPE_OVERLAY`] (capture works but the Steam overlay can't reach
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
/// diagnostic, not a gate. Returns the parsed version when it could read one.
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> { pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
let out = Command::new("gamescope").arg("--version").output().ok()?; let out = Command::new("gamescope").arg("--version").output().ok()?;
// gamescope prints the version banner to stderr on some builds, stdout on others. // gamescope prints the version banner to stderr on some builds, stdout on others.
@@ -360,6 +370,18 @@ pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
capture deadlock against PipeWire 1.6 (a wedged link head-blocks the daemon); \ capture deadlock against PipeWire 1.6 (a wedged link head-blocks the daemon); \
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter" upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
); );
} else if ver < MIN_GAMESCOPE_OVERLAY {
// Capture is fine; the Steam overlay just won't be in the frame gamescope hands us.
tracing::warn!(
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
min = %format!(
"{}.{}.{}",
MIN_GAMESCOPE_OVERLAY.0, MIN_GAMESCOPE_OVERLAY.1, MIN_GAMESCOPE_OVERLAY.2
),
"gamescope is older than the first version that paints the Steam overlay (Shift+Tab / \
Quick Access Menu) into its PipeWire node the overlay will be absent from the \
stream until you upgrade gamescope (the cursor is composited host-side regardless)"
);
} }
Some(ver) Some(ver)
} }
@@ -379,7 +401,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE}; use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY};
#[test] #[test]
fn parses_steam_appid_from_launch() { fn parses_steam_appid_from_launch() {
@@ -425,4 +447,15 @@ mod tests {
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE); assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE); assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
} }
#[test]
fn overlay_threshold_brackets_the_fix() {
// 3.16.22 captures fine but predates the overlay-in-pipewire paint — it sits in the
// "capture works, overlay absent" window `[MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY)`, which is
// exactly the `else if` warn arm; 3.16.23 is the first to include the overlay.
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
assert!(parse_version("gamescope version 3.16.22").unwrap() < MIN_GAMESCOPE_OVERLAY);
assert!(parse_version("gamescope version 3.16.23").unwrap() >= MIN_GAMESCOPE_OVERLAY);
assert!(parse_version("gamescope version 3.16.25").unwrap() >= MIN_GAMESCOPE_OVERLAY);
}
} }
+108 -32
View File
@@ -431,16 +431,69 @@ fn web_setup(args: &[String]) -> Result<()> {
) { ) {
eprintln!("warning: could not add the firewall rule for TCP 47992"); eprintln!("warning: could not add the firewall rule for TCP 47992");
} }
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise) // 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
for _ in 0..30 { //
if token_path.exists() { // `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
break; // host writes them in that order: the token during argument parsing (`main::parse_serve`,
// milliseconds after `serve` starts), the cert only once `serve` reaches
// `gamestream::cert::ServerIdentity::load_or_create` - behind a pure-Rust RSA-2048 keygen
// whose prime search has a multi-second tail. Gating on the token alone therefore fired
// `schtasks /run` while that keygen was still running: the launcher exited 1, and since the
// task carries no trigger other than boot (and Task Scheduler's restart-on-failure does not
// reliably fire for a plain non-zero exit code), a freshly installed console stayed down
// until the next reboot. Gate on `cert.pem` - written LAST, after `key.pem` - so all three
// files are on disk before the first start.
let cert_path = data_dir.join("cert.pem");
if !wait_for_files(&[token_path.as_path(), cert_path.as_path()], 90) {
eprintln!(
"warning: the host service has not written {} + {} yet - not starting the console now; \
it will start at the next boot (or run: schtasks /run /tn {WEB_TASK})",
token_path.display(),
cert_path.display()
);
println!("web console set up (https://<host-ip>:47992)");
return Ok(());
}
if start_web_task() {
println!("web console set up + started (https://<host-ip>:47992)");
} else {
// Never claim "started" when it isn't: the launcher's own diagnostics go to a task with no
// console, so this warning is the only trace an operator gets at install time.
eprintln!(
"warning: the {WEB_TASK} task did not bring up a listener on :47992 - check its Last Run \
Result in Task Scheduler; the console will be retried at the next boot"
);
println!("web console set up (https://<host-ip>:47992)");
}
Ok(())
}
/// Poll until every path exists, or `secs` elapse. Returns whether they all showed up.
fn wait_for_files(paths: &[&Path], secs: u64) -> bool {
for _ in 0..secs {
if paths.iter().all(|p| p.exists()) {
return true;
} }
std::thread::sleep(std::time::Duration::from_secs(1)); std::thread::sleep(std::time::Duration::from_secs(1));
} }
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]); paths.iter().all(|p| p.exists())
println!("web console set up + started (https://<host-ip>:47992)"); }
Ok(())
/// `schtasks /run` + verify: the command reports only that a start was *attempted*, so poll for the
/// console's own listener and retry a couple of times before giving up. Returns whether :47992 ended
/// up listening.
fn start_web_task() -> bool {
for _ in 0..3 {
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
// Bun binds the port a second or two after launch on a warm box; allow for a cold one.
for _ in 0..10 {
std::thread::sleep(std::time::Duration::from_secs(1));
if !web_listener_pids().is_empty() {
return true;
}
}
}
false
} }
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback. /// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
@@ -505,30 +558,57 @@ fn random_password() -> String {
/// ("LISTENING"/"ABHOEREN"/...) is never parsed. /// ("LISTENING"/"ABHOEREN"/...) is never parsed.
fn stop_web_console() { fn stop_web_console() {
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]); run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() { for pid in web_listener_pids() {
let toks: Vec<&str> = line.split_whitespace().collect(); run_quiet("taskkill", &["/PID", &pid, "/F"]);
if toks.len() >= 5
&& toks[0].eq_ignore_ascii_case("tcp")
&& toks[1].ends_with(":47992")
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0")
{
let pid = toks[toks.len() - 1];
if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) {
run_quiet("taskkill", &["/PID", pid, "/F"]);
}
}
} }
std::thread::sleep(std::time::Duration::from_secs(1)); std::thread::sleep(std::time::Duration::from_secs(1));
} }
/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`, /// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM. fn web_listener_pids() -> Vec<String> {
run_capture("netstat", &["-ano", "-p", "tcp"])
.lines()
.filter_map(|line| {
let toks: Vec<&str> = line.split_whitespace().collect();
let listening = toks.len() >= 5
&& toks[0].eq_ignore_ascii_case("tcp")
&& toks[1].ends_with(":47992")
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0");
let pid = *toks.last()?;
(listening && !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
.then(|| pid.to_string())
})
.collect()
}
/// Register the boot+logon/SYSTEM/restart-on-failure task via a generated Task Scheduler XML
/// (`schtasks /xml`, no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
///
/// Two triggers, because boot alone left too many holes: a console that lost the install-time start
/// (or a box where the host service was started only later) had to wait for a full reboot. Logon is
/// free insurance - `MultipleInstancesPolicy=IgnoreNew` makes a redundant start a no-op. If a
/// Task Scheduler build rejects the two-trigger XML we fall back to the boot-only form rather than
/// fail registration outright (no task at all is strictly worse than a boot-only task).
fn register_web_task(cmd: &Path) -> Result<()> { fn register_web_task(cmd: &Path) -> Result<()> {
let cmd_xml = xml_escape(&cmd.to_string_lossy());
let boot = "<BootTrigger><Enabled>true</Enabled></BootTrigger>";
let boot_and_logon = "<BootTrigger><Enabled>true</Enabled></BootTrigger>\
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>";
if try_register_web_task(&cmd_xml, boot_and_logon) || try_register_web_task(&cmd_xml, boot) {
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
Ok(())
} else {
bail!("schtasks /create {WEB_TASK} failed")
}
}
/// One `schtasks /create /xml` attempt with the given `<Triggers>` body. Returns whether it took.
fn try_register_web_task(cmd_xml: &str, triggers: &str) -> bool {
let xml = format!( let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\ "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\ <Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\ <RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\
<Triggers><BootTrigger><Enabled>true</Enabled></BootTrigger></Triggers>\n\ <Triggers>{triggers}</Triggers>\n\
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\ <Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
<Settings>\n\ <Settings>\n\
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\ <RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
</Settings>\n\ </Settings>\n\
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\ <Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
</Task>", </Task>"
xml_escape(&cmd.to_string_lossy())
); );
let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml"); let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml");
write_utf16le_bom(&xml_path, &xml)?; if write_utf16le_bom(&xml_path, &xml).is_err() {
return false;
}
let ok = run_quiet( let ok = run_quiet(
"schtasks", "schtasks",
&[ &[
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
], ],
); );
let _ = std::fs::remove_file(&xml_path); let _ = std::fs::remove_file(&xml_path);
if ok { ok
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
Ok(())
} else {
bail!("schtasks /create {WEB_TASK} failed")
}
} }
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> { fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
+19 -10
View File
@@ -40,8 +40,16 @@ depends on the display manager driving the autologin:
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup. - **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager - **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
itself for the length of the stream and restart it afterwards, which needs privilege. Allow it itself for the length of the stream and restart it afterwards, which needs privilege. The
with a polkit rule (adjust the unit and user names to your box): packages ship that privilege: a root helper (`/usr/libexec/punktfunk/pf-dm-helper`) behind its
own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically when the plain
`systemctl` verbs are denied — no setup. The helper only stops/restores the unit the
`display-manager.service` symlink points at, the same class of local-seat operation these
distros already authorize for their own session switcher (Nobara's `os-session-select`).
Installed from a tarball, or prefer not to ship the `allow_any` action? Remove the `.policy`
file and use a polkit rule scoped to your user instead (adjust the unit and user names to your
box) — the host tries the plain verbs first, so the rule takes precedence:
```js ```js
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules // /etc/polkit-1/rules.d/49-punktfunk-dm.rules
@@ -54,15 +62,16 @@ depends on the display manager driving the autologin:
}); });
``` ```
Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
display manager. If the display-manager restart ever loses its privilege mid-restore, the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the what happened; check the host log for "managed takeover unavailable"). If the display-manager
fallback. restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
[Configuration](/docs/configuration)) is fired as the fallback.
With the rule in place the **in-stream session switch round-trips** in managed mode: Steam's With the takeover authorized the **in-stream session switch round-trips** in managed mode:
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again. and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
## Session following ## Session following
+1 -1
View File
@@ -66,7 +66,7 @@ the Windows specifics follow.
The installer also sets up the **web management console** (status, paired devices, the PIN pairing The installer also sets up the **web management console** (status, paired devices, the PIN pairing
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
**`https://<this-PC>:47992`**, starting at boot. **`https://<this-PC>:47992`**, starting at boot and at sign-in.
#### Console login password #### Console login password
+8
View File
@@ -129,6 +129,14 @@ package_punktfunk-host() {
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host" install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID) # /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules" install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
# stop/restore the display manager for the stream. Arch has no /usr/libexec — install under
# /usr/lib/punktfunk and rewrite the policy's exec.path annotation to match (the host probes both).
install -Dm0755 "$R/scripts/pf-dm-helper" "$pkgdir/usr/lib/punktfunk/pf-dm-helper"
install -Dm0644 "$R/scripts/io.unom.punktfunk.dm-helper.policy" \
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads) # vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf" install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
# 32 MB UDP socket buffers (send-side headroom at high bitrate) # 32 MB UDP socket buffers (send-side headroom at high bitrate)
+5
View File
@@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG"
# --- file layout (matches the RPM %install) ---------------------------------- # --- file layout (matches the RPM %install) ----------------------------------
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG" install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules" install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
# stop/restore the display manager for the stream (the helper derives the DM unit itself).
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \
"$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads). # vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf" install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB # UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
+9
View File
@@ -249,6 +249,12 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
# udev rule — /dev/uinput access for virtual gamepads (input group). # udev rule — /dev/uinput access for virtual gamepads (input group).
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
# Managed gamescope takeover on DM-autologin boxes (Nobara's plasmalogin): a root helper + polkit
# action let the host stop/restore the display manager for the stream without a hand-installed
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a # vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam). # real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf
@@ -383,6 +389,9 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
%{_bindir}/punktfunk-host %{_bindir}/punktfunk-host
%{_bindir}/punktfunk-tray %{_bindir}/punktfunk-tray
%{_udevrulesdir}/60-punktfunk.rules %{_udevrulesdir}/60-punktfunk.rules
%dir %{_libexecdir}/punktfunk
%{_libexecdir}/punktfunk/pf-dm-helper
%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
%{_prefix}/lib/modules-load.d/punktfunk.conf %{_prefix}/lib/modules-load.d/punktfunk.conf
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml %{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<vendor>Punktfunk</vendor>
<vendor_url>https://punktfunk.unom.io</vendor_url>
<!-- Stop/restore the box's display manager for a managed gamescope takeover. The helper derives
the unit from the display-manager.service symlink itself (no caller-chosen units), so this
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
these distros already authorize for their session switcher (e.g. Nobara's
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
<action id="io.unom.punktfunk.dm-helper">
<description>Stop or restore the display manager for a Punktfunk stream</description>
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
<defaults>
<allow_any>yes</allow_any>
<allow_inactive>yes</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/libexec/punktfunk/pf-dm-helper</annotate>
</action>
</policyconfig>
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Privileged display-manager verbs for the punktfunk managed gamescope takeover.
#
# On DM-autologin boxes whose display manager does not survive a masked session unit (Nobara's
# plasmalogin, unknown DMs), taking the Gaming Mode session over means stopping the display
# manager for the length of the stream and restarting it afterwards — root-only operations. The
# host invokes this helper via pkexec under its own polkit action
# (io.unom.punktfunk.dm-helper.policy, installed by the packages), the same mechanism these
# distros use for their own session switching (Nobara's os-session-select).
#
# The unit is NEVER caller-controlled: it is derived here from the display-manager.service alias
# symlink, so the polkit grant's blast radius is exactly "the box's own display manager" — a
# local-seat operation, not arbitrary unit management.
set -eu
dm_unit() {
target=$(readlink /etc/systemd/system/display-manager.service) || {
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
exit 1
}
basename "$target"
}
case "${1-}" in
stop)
exec systemctl stop "$(dm_unit)"
;;
restore)
dm=$(dm_unit)
# reset-failed first: a relogin loop may have tripped the unit's start limit, and a plain
# restart would be refused until the accounting is cleared.
systemctl reset-failed "$dm" 2>/dev/null || :
exec systemctl restart "$dm"
;;
*)
echo "usage: pf-dm-helper stop|restore" >&2
exit 2
;;
esac
+5
View File
@@ -12,6 +12,11 @@ Description=punktfunk management web console
# web-init generates the login password; the host writes the mgmt token. Order after both. # web-init generates the login password; the host writes the mgmt token. Order after both.
After=punktfunk-web-init.service punktfunk-host.service After=punktfunk-web-init.service punktfunk-host.service
Wants=punktfunk-web-init.service Wants=punktfunk-web-init.service
# Retry indefinitely while the host is still writing the mgmt token + identity cert. Without this,
# systemd's default rate limit (5 starts / 10 s) plus RestartSec=2 gives up permanently after ~10 s
# - so a console enabled before the host's first run stayed dead until someone restarted it by hand
# (the same defect the Windows PunktfunkWeb task had).
StartLimitIntervalSec=0
[Service] [Service]
Type=simple Type=simple
+18 -9
View File
@@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.pem"
set "KEYFILE=%PFDATA%\key.pem" set "KEYFILE=%PFDATA%\key.pem"
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP.
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than rem
rem silently downgrading to plain HTTP. rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task
if not exist "%TOKENFILE%" ( rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. rem before the host finished writing those files (the token lands at argument parse, the cert only
exit /b 1 rem after the RSA-2048 keygen) used to stay down until the next reboot. ~5 min at 2 s, then give up
) rem so a genuinely broken install still surfaces as a failed task rather than one that runs forever.
if not exist "%CERTFILE%" ( rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep.
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service. set /a PFWAITS=0
:pfwait
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
if %PFWAITS% GEQ 150 (
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host service running?
exit /b 1 exit /b 1
) )
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host service to write the mgmt token + identity cert...
set /a PFWAITS+=1
ping -n 3 127.0.0.1 >nul 2>&1
goto pfwait
:pfready
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment. rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
+13 -8
View File
@@ -18,16 +18,21 @@ set "CERTFILE=%PFDATA%\cert.pem"
set "KEYFILE=%PFDATA%\key.pem" set "KEYFILE=%PFDATA%\key.pem"
rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather
rem installed launcher / Linux unit) rather than silently serving plain HTTP. rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats
if not exist "%TOKENFILE%" ( rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up.
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service. set /a PFWAITS=0
exit /b 1 :pfwait
) if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
if not exist "%CERTFILE%" ( if %PFWAITS% GEQ 150 (
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service. echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host running?
exit /b 1 exit /b 1
) )
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host to write the mgmt token + identity cert...
set /a PFWAITS+=1
ping -n 3 127.0.0.1 >nul 2>&1
goto pfwait
:pfready
rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... . rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... .
rem Split on the first '=' and import each into the environment. rem Split on the first '=' and import each into the environment.