Merge pull request 'NixOS could not detect a graphical session at all — and the module was never checked by CI' (#158) from worktree-nixos-wrapped-comm-detection into main
apple / swift (push) Successful in 1m43s
ci / docs-site (push) Successful in 1m19s
ci / web (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 2m47s
deb / build-publish-client-arm64 (push) Successful in 1m28s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
apple / screenshots (push) Successful in 3m1s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m26s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
ci / bun-nix (push) Successful in 35s
deb / build-publish (push) Successful in 8m7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
deb / build-publish-host (push) Successful in 8m47s
arch / build-publish (push) Successful in 9m25s
docker / deploy-docs (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 13s
android / android (push) Successful in 6m57s
ci / rust (push) Successful in 6m38s
windows-host / package (push) Successful in 16m38s
windows-host / winget-source (push) Skipped
nix / flake (push) Successful in 14m42s
windows-host / canary-manifest (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m12s

Reviewed-on: #158
This commit was merged in pull request #158.
This commit is contained in:
2026-08-10 18:37:32 +00:00
13 changed files with 911 additions and 49 deletions
+38 -4
View File
@@ -7,10 +7,24 @@
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
# without building them. Catches the failures that actually happen to this flake — a
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
# attribute dropped from packages.nix.
#
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs'
# input, use it to check the module.`). MEASURED: a module setting a nonexistent
# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib`
# function passes clean, printing `checking NixOS module ... all checks passed!`. This
# header used to claim the module was covered here; it was not, for the module's whole
# life. It is covered NOW because `checks.<system>.nixos-module`
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
# the derivation body would only run under a full `nix flake check`, which builds the
# hour-long Rust packages.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
@@ -22,6 +36,12 @@
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
@@ -66,6 +86,10 @@ on:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
build-gamescope:
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
type: boolean
default: false
jobs:
flake:
@@ -165,3 +189,13 @@ jobs:
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
# The patched compositor. Separate from build-rust because its failure mode is different: it
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
# something actually builds it.
- name: Build the patched gamescope (dispatch opt-in)
if: ${{ github.event.inputs.build-gamescope == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-gamescope
+68
View File
@@ -65,6 +65,64 @@ code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded **
handshake rather than read from the worker's environment, which is sanitized at spawn; one env var
still means one thing on both platforms.
### NixOS — session detection, module defaults, and a CI gate that was never running
🛑 **The host could not detect any graphical session on NixOS, at all.** The live-session probe
matched `/proc/<pid>/comm` exactly against `kwin_wayland` / `gamescope` / `gnome-shell` /
`Hyprland`. `comm` is the kernel's name for the **executed file**, truncated to 15 bytes — not
`argv[0]` — and nixpkgs wraps essentially every graphical binary: `wrapProgram` moves the real ELF
aside to `.<name>-wrapped` and installs a wrapper that `exec -a "$0"`s it. So the kernel reports
`.kwin_wayland-w` while `ps` and `pgrep -a` show a perfectly ordinary `kwin_wayland`, because they
read argv. Every probe answered `ActiveKind::None` on a running desktop, and nothing downstream
could recover: `wayland` logged as `-`, a correct `WAYLAND_DISPLAY` changed nothing, `Auto` returned
the *detected* backend so a live KWin already in `available()` was never chosen, and a
`PUNKTFUNK_COMPOSITOR` pin turned the miss into a hard error through `pinned_at_a_dead_session`.
sway and river survived by accident — nixpkgs' wrapper execs a binary still called `sway`.
Names are now resolved through `/proc/<pid>/exe`, whose file name is untruncated, with the nixpkgs
decoration stripped. Stripping requires **both** the leading `.` and a trailing `-wrapped`, so
KWin's own real `kwin_wayland_wrapper` binary keeps its name instead of collapsing into
`kwin_wayland` and handing the probe the parent's PID. The `comm` fast path is unchanged for every
ordinary distro — one read, no readlink — and no name that matched before can stop matching. Also
applied to the foreign-gamescope probe, which had the same defect.
**Module changes** (`services.punktfunk`):
- **`host.desktopSession`** *(new, default `false`)* — binds the host to `graphical-session.target`,
the declarative form of the `punktfunk-host-desktop-session.conf` drop-in. Without it a
Plasma/GNOME restart leaves the host holding a Wayland socket and portal D-Bus connection that
died with the old compositor: it still listens, still answers, and every session after that fails
at capture. Off by default because an appliance may never reach that target and would be left
permanently stopped.
-**`scripting.autoStart` now defaults ON** *(behaviour change)*, matching the deb `postinst` and
RPM `%post`, which both `systemctl --global enable` the runner, and the sysext's baked-in
`default.target.wants` symlink. It was opt-in here on the reasoning that the runner is inert until
you add automation — untrue since the game-library scanners became plugins, so a NixOS host came
up with an empty library and no obvious cause. Opt out with `scripting.autoStart = false` or
`systemctl --user mask punktfunk-scripting`.
- **Three divergences from the shipped units, ported.** `punktfunk-web` gains
`StartLimitIntervalSec=0` (without it, 5 starts / 10 s against `RestartSec=2` gives up permanently
after ~10 s — exactly the window before the host's first `serve` writes the mgmt token, so a
console enabled before the host's first run stayed dead) and `Restart=always` rather than
`on-failure`. `punktfunk-scripting` gains the sandbox the deb/rpm unit has all along
(`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=%h /tmp`, restricted address families,
`PrivateTmp=no`) — it is the one unit that runs arbitrary operator TypeScript by design, and it
had been running strictly less confined on NixOS than anywhere else.
- A **warning** when the host is enabled and `xdg.portal.enable` is not.
🛑 **`nix flake check` does not check `nixosModules`** — worth knowing for anyone maintaining a
flake. It forces the value and asserts it is a lambda taking an open attribute set, and stops;
nix's source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check the module.`
Measured: a module with a nonexistent option, a nonexistent `pkgs` attribute **and** a nonexistent
`lib` function passes, printing `checking NixOS module ... all checks passed!`. `nix.yml`'s header
claimed that leg covered the module; it never had. `checks.<system>.nixos-module`
(`packaging/nix/module-check.nix`) now evaluates it against real nixpkgs across four scenarios and
asserts on the rendered units, including a guard that the host's `ExecStart` stays on the plain
store path while the encode worker points at the wrapper. Its assertions are pure Nix, so
instantiation runs them and the existing `--no-build` leg is enough. `punktfunk-gamescope` gains a
`build-gamescope` dispatch input — it is on the critical path of every host build yet nothing
compiled it, and it tracks nixpkgs' gamescope, so a `flake.lock` bump is what breaks it.
### Host and client environment variables
- **`PUNKTFUNK_ENCODE_WORKER`** *(new, host, Linux)* — where to find the encode worker. Resolution
@@ -87,6 +145,16 @@ still means one thing on both platforms.
- The 0.26.0 user-facing notes describe a privilege that is deliberately not granted. That is the
record of what 0.26.0 shipped and is **not** rewritten; the new phrasing — granted to the worker,
never to the host — lives in `docs/releases/v0.27.0.md`.
- `install.md` **NixOS** documents `desktopSession`, and its `punktfunk-scripting` bullet no longer
claims the runner "ships disabled": that was true only of Arch and source installs — apt, dnf, the
Bazzite sysext and now the NixOS module all start it, because the library scanners are plugins.
`bazzite.md` carried the same stale claim and is corrected. **Running as a service → Restart the
host with your desktop** gains the NixOS one-liner beside the drop-in.
- `packaging/nix/README.md`: `desktopSession`, `gamescopeHdr`/`gamescopePackage` and the
`punktfunk` group added to the option tables; the "what the module configures" list gains the
`security.wrappers` entry, with the KWin-identification reasoning for why the capability is on the
worker and not the host; and a caveat recording that `nix flake check` does not check the module,
plus the two rules for editing `module-check.nix`.
---
@@ -658,10 +658,12 @@ pub fn foreign_gamescope_running() -> bool {
if md.uid() != uid {
continue;
}
let Ok(comm) = std::fs::read_to_string(e.path().join("comm")) else {
// Resolved, not a raw `comm` read: nixpkgs wraps gamescope too, so on NixOS the kernel
// reports `.gamescope-wrap` and this probe saw no foreign session at all.
let Some(comm) = crate::proc::match_name(&e.path()) else {
continue;
};
if !matches!(comm.trim(), "gamescope" | "gamescope-wl") {
if !matches!(comm.as_str(), "gamescope" | "gamescope-wl") {
continue;
}
if !descends_from(pid, our_pid) {
+258
View File
@@ -111,6 +111,74 @@ pub(crate) fn current_uid() -> u32 {
unsafe { libc::getuid() }
}
/// The longest `/proc/<pid>/comm` the kernel will report: `TASK_COMM_LEN` is 16 *including* the
/// NUL, so a name of exactly this many bytes may be a truncation of a longer one.
#[cfg(target_os = "linux")]
const COMM_MAX: usize = 15;
/// The executable name to identify a process by, with nixpkgs wrapper decoration undone.
///
/// `comm` is the kernel's name for the **executed file**, truncated to [`COMM_MAX`] bytes — it is
/// not `argv[0]` and not the command line. nixpkgs wraps essentially every graphical binary:
/// `wrapProgram` moves the real ELF aside to `.<name>-wrapped` and installs a shell wrapper under
/// the original name, and that wrapper `exec -a "$0"`s the hidden file. So `ps`/`pgrep -a` show a
/// perfectly ordinary `kwin_wayland` (they read argv) while the kernel reports `.kwin_wayland-w`
/// — 15 bytes of `.kwin_wayland-wrapped`, which can never equal `kwin_wayland`.
///
/// That is not a KDE-only detail. On NixOS `kwin_wayland`, `gamescope`, `gnome-shell` and
/// `Hyprland` are all wrapped, so an exact `comm` comparison made [`super::session`]'s probe
/// answer [`crate::ActiveKind::None`] on a visibly running desktop — and because the probe is the
/// *only* input to that decision, no environment variable could reach it: `WAYLAND_DISPLAY` was
/// correct, capture worked the moment detection was satisfied, and a `PUNKTFUNK_COMPOSITOR` pin
/// turned the miss into a hard error via `pinned_at_a_dead_session`. (sway survives by accident —
/// nixpkgs' wrapper execs a real binary that is itself still called `sway`.)
///
/// The `comm` fast path is kept for every ordinary distro: one read, no readlink. Only a name that
/// *could* be decorated or truncated — it starts with `.`, or it is exactly [`COMM_MAX`] bytes —
/// is re-resolved through `/proc/<pid>/exe`, which carries the full, untruncated file name.
///
/// `pid_path` is a `/proc/<pid>` directory. `None` when the process vanished mid-scan.
#[cfg(target_os = "linux")]
pub(crate) fn match_name(pid_path: &std::path::Path) -> Option<String> {
let comm = std::fs::read_to_string(pid_path.join("comm")).ok()?;
let comm = comm.trim();
// An undecorated name short enough to be complete is already the answer.
if !comm.starts_with('.') && comm.len() < COMM_MAX {
return Some(comm.to_string());
}
// Reading our OWN uid's `/proc/<pid>/exe` needs no privilege (every caller filters on uid
// first), but it is still absent for a kernel thread and for a process exiting under us —
// in which case the truncated `comm` is the best that exists.
match std::fs::read_link(pid_path.join("exe"))
.ok()
.as_deref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
{
Some(full) => Some(undecorate(full).to_string()),
None => Some(comm.to_string()),
}
}
/// Strip nixpkgs `wrapProgram` decoration: `.<name>-wrapped`, plus the `_` suffixes make-wrapper
/// appends when that hidden name is already taken (a doubly-wrapped app — Qt *and* GApps).
///
/// **Both** halves are required, and that is the load-bearing part rather than pedantry: KWin
/// ships its own real binary called `kwin_wayland_wrapper` (the session's parent process), so a
/// rule that merely stripped a `wrapper`-ish suffix would rewrite it into `kwin_wayland` and hand
/// the session probe the wrong PID. Demanding the leading `.` as well keeps it — and any genuine
/// `foo-wrapped` — under its real name.
#[cfg(target_os = "linux")]
fn undecorate(name: &str) -> &str {
let Some(rest) = name.strip_prefix('.') else {
return name;
};
match rest.trim_end_matches('_').strip_suffix("-wrapped") {
Some(real) if !real.is_empty() => real,
_ => name,
}
}
/// Ending the *tree* the helper started, not just the process we spawned.
///
/// [`std::process::Child::kill`] is one `TerminateProcess` / one `SIGKILL`: it ends exactly the
@@ -280,6 +348,196 @@ mod tests {
}
}
/// The `comm`-vs-real-name resolution ([`match_name`]). Linux-only, because the trap it exists for
/// is a Linux kernel detail (`comm` names the executed FILE, truncated to 15 bytes) crossed with a
/// nixpkgs packaging convention.
///
/// Driven against **fixture** `/proc/<pid>` directories rather than spawned processes, for the same
/// reason the `/proc` matcher in `punktfunk-host` learned the hard way: a stand-in has to be a real
/// ELF that tolerates being *renamed*, and `/bin/sleep` is not one. Modern coreutils (uutils on
/// Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary — copied to `.kwin_wayland-wrapped` it
/// prints "unknown program" and exits before `/proc` can be read, and restoring `argv[0]` does not
/// save it. That reads exactly like this resolver being broken. The truncation the fixtures encode
/// is not guessed: the strings below were measured from a live kernel (`.kwin_wayland-w`,
/// `.kwin_wayland_w`, `.gamescope-wrap` — all 15 bytes) against binaries installed and exec'd the
/// way nixpkgs does it.
#[cfg(all(test, target_os = "linux"))]
mod name_tests {
use super::*;
use std::path::{Path, PathBuf};
/// A fake `/proc/<pid>` directory: a `comm` file and, optionally, the `exe` symlink. Removed on
/// drop.
struct FakePid {
dir: PathBuf,
}
impl FakePid {
/// `comm` is written exactly as the kernel would report it — i.e. already truncated.
fn new(tag: &str, comm: &str, exe: Option<&str>) -> FakePid {
let dir = std::env::temp_dir().join(format!("pf-vd-name-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("fixture dir");
std::fs::write(dir.join("comm"), format!("{comm}\n")).expect("comm");
if let Some(exe) = exe {
// The target need not exist: `read_link` reports the link's contents, and a real
// `/proc/<pid>/exe` routinely points at a path that has since been replaced.
std::os::unix::fs::symlink(
format!("/nix/store/eeee-kwin-6.5.0/bin/{exe}"),
dir.join("exe"),
)
.expect("exe symlink");
}
FakePid { dir }
}
fn path(&self) -> &Path {
&self.dir
}
}
impl Drop for FakePid {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// The decoration table. The `kwin_wayland_wrapper` rows are the ones that earn their keep: it
/// is a REAL KWin binary (the session's parent process), so the rule must leave it under its own
/// name in both its plain and its wrapped form rather than collapsing either into
/// `kwin_wayland` and handing the session probe the wrong PID.
#[test]
fn undecorate_strips_only_a_real_nixpkgs_wrapper() {
for (raw, want) in [
(".kwin_wayland-wrapped", "kwin_wayland"),
(".gamescope-wrapped", "gamescope"),
(".gnome-shell-wrapped", "gnome-shell"),
(".Hyprland-wrapped", "Hyprland"),
// make-wrapper appends `_`s when the hidden name is already taken (a Qt + GApps
// double-wrap), so the underscores come off before the suffix does.
(".kwin_wayland-wrapped_", "kwin_wayland"),
(".kwin_wayland-wrapped__", "kwin_wayland"),
// Not decoration — every one of these keeps its exact name.
("kwin_wayland", "kwin_wayland"),
("kwin_wayland_wrapper", "kwin_wayland_wrapper"),
(".kwin_wayland_wrapper-wrapped", "kwin_wayland_wrapper"),
("foo-wrapped", "foo-wrapped"),
(".hidden", ".hidden"),
(".-wrapped", ".-wrapped"),
] {
assert_eq!(undecorate(raw), want, "undecorate({raw:?})");
}
}
/// The whole bug. Every compositor the session probe matches on is wrapped by nixpkgs, so the
/// kernel reports a truncated, decorated `comm` that can never equal the name being compared —
/// which is why `detect_active_session` answered `ActiveKind::None` on a *running* KDE desktop
/// and every connect died "no usable compositor".
#[test]
fn a_nixpkgs_wrapped_compositor_resolves_to_its_real_name() {
for (tag, comm, exe, want) in [
(
"kwin",
".kwin_wayland-w",
".kwin_wayland-wrapped",
"kwin_wayland",
),
(
"gamescope",
".gamescope-wrap",
".gamescope-wrapped",
"gamescope",
),
(
"gnome",
".gnome-shell-wr",
".gnome-shell-wrapped",
"gnome-shell",
),
("hypr", ".Hyprland-wrapp", ".Hyprland-wrapped", "Hyprland"),
] {
let p = FakePid::new(tag, comm, Some(exe));
assert_eq!(
match_name(p.path()).as_deref(),
Some(want),
"a nixpkgs-wrapped {want} must resolve to the name the session probe matches"
);
}
}
/// KWin's own `kwin_wayland_wrapper` is a real binary that runs *alongside* `kwin_wayland`, and
/// its wrapped `comm` (`.kwin_wayland_w`) differs from the compositor's by a single byte. It
/// must NOT resolve to `kwin_wayland`: the probe would then match the parent process and carry
/// its PID as the compositor identity, which drives restart detection.
#[test]
fn kwins_own_wrapper_binary_does_not_masquerade_as_the_compositor() {
let p = FakePid::new(
"kwrap",
".kwin_wayland_w",
Some(".kwin_wayland_wrapper-wrapped"),
);
assert_eq!(
match_name(p.path()).as_deref(),
Some("kwin_wayland_wrapper")
);
}
/// The other half of the 15-byte limit, with no nix involved: a long name is truncated too, and
/// has to be recovered from `exe` rather than matched short.
#[test]
fn a_long_name_is_recovered_untruncated() {
let p = FakePid::new(
"long",
"a-very-long-com",
Some("a-very-long-compositor-name"),
);
assert_eq!(
match_name(p.path()).as_deref(),
Some("a-very-long-compositor-name")
);
}
/// The fast path answers without consulting `exe` at all — which is what keeps this probe at one
/// read per process on every ordinary distro, and what lets it answer for a process whose `exe`
/// is unreadable in the first place.
#[test]
fn an_ordinary_short_name_never_needs_the_exe_link() {
let p = FakePid::new("plain", "kwin_wayland", None);
assert_eq!(match_name(p.path()).as_deref(), Some("kwin_wayland"));
}
/// A decorated-or-truncated name whose `exe` cannot be read (a kernel thread, or a process
/// exiting under the scan) degrades to the truncated `comm` instead of failing the whole entry.
#[test]
fn an_unreadable_exe_falls_back_to_comm() {
let p = FakePid::new("noexe", ".kwin_wayland-w", None);
assert_eq!(match_name(p.path()).as_deref(), Some(".kwin_wayland-w"));
}
/// A pid directory that does not exist yields `None`, not a bogus name — the scans `continue`.
#[test]
fn a_vanished_process_yields_none() {
assert_eq!(match_name(Path::new("/proc/0")), None);
}
/// The one thing a fixture cannot establish: that reading `/proc/<pid>/exe` is actually
/// *permitted* for a process of our own uid, which the whole resolver depends on. Checked
/// against the only such process guaranteed to be running — this one.
#[test]
fn our_own_exe_link_is_readable() {
let me = Path::new("/proc/self");
let exe = std::fs::read_link(me.join("exe"))
.expect("/proc/self/exe must be readable for our own uid");
let name = exe.file_name().and_then(|n| n.to_str()).expect("exe name");
let got = match_name(me).expect("our own name");
// Whichever rung answered, it must agree with the real binary: the fast path returns the
// (short, undecorated) comm, which is a prefix of it; the exe path returns it outright.
assert!(
name.starts_with(got.as_str()) || got == name,
"resolved {got:?} disagrees with our real binary {name:?}"
);
}
}
/// The same two cases through `cmd /c`, so the budget logic is covered on the platform whose
/// process model differs most (job objects, no `SIGKILL`). `ping -n` is the standard Windows
/// no-extra-tooling sleep.
+7 -4
View File
@@ -311,8 +311,11 @@ pub fn detect_active_session() -> ActiveSession {
let dbus = default_bus(&env, &xdg_runtime_dir);
// Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the
// `pkill -x` discipline (exact, ≤15 chars so untruncated).
// a real desktop (kwin/gnome/sway) win over a leftover gamescope child. Names are matched
// exactly, `pkill -x` style — but resolved through [`crate::proc::match_name`], NOT a raw
// `comm` read: on NixOS every one of these binaries is a nixpkgs wrapper whose real ELF is
// `.<name>-wrapped`, so a raw `comm` says `.kwin_wayland-w` and this whole probe answered
// `None` on a running KDE desktop.
let mut kind = ActiveKind::None;
let mut best = 0u8;
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
@@ -332,10 +335,10 @@ pub fn detect_active_session() -> ActiveSession {
if md.uid() != uid {
continue;
}
let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else {
let Some(comm) = crate::proc::match_name(&pid_path) else {
continue;
};
let (k, prio) = match comm.trim() {
let (k, prio) = match comm.as_str() {
"gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1),
"kwin_wayland" => (ActiveKind::DesktopKde, 4),
"gnome-shell" => (ActiveKind::DesktopGnome, 4),
+4 -2
View File
@@ -44,8 +44,10 @@ manifest is OpenPGP-signed by packages@unom.io (key `AF245C506F4E4763`, the same
RPMs), and `punktfunk-sysext` checks that signature against a key baked into the script before it
trusts a single checksum — so it needs `gpg` on the box, and it refuses a feed it can't verify.
The plugin runner rides along in the image but isn't started: run
`systemctl --user enable --now punktfunk-scripting` when you want [plugins](/docs/plugins).
The plugin runner rides along in the image and is **started for you** — the image bakes in its
`default.target.wants` symlink, because the game-library scanners ship as
[plugins](/docs/plugins). To turn it off: `systemctl --user mask punktfunk-scripting` (`mask`, not
`disable` — a plain disable cannot remove a symlink that lives in `/usr`).
From then on:
+26 -5
View File
@@ -101,6 +101,7 @@ services.punktfunk.host = {
enable = true;
users = [ "alice" ]; # added to the `input` group, for virtual gamepads
openFirewall = true;
desktopSession = true; # on a machine you log into — see below
settings = { RUST_LOG = "info"; }; # these become host.env
};
```
@@ -108,13 +109,27 @@ services.punktfunk.host = {
The module does declaratively what the deb/RPM scriptlets do — the systemd user service, udev rules,
kernel modules, sysctl tuning, the firewall ports and `input` group membership — and brings in the
web console alongside the host. Because `settings` writes the environment file for you, skip the
`host.env` step in [After installing](#after-installing). The user services are defined but not
started, so from your graphical session enable the host and the console:
`host.env` step in [After installing](#after-installing).
**Set `desktopSession = true` on any machine somebody logs into.** It ties the host to
`graphical-session.target`, so restarting Plasma or GNOME restarts the host with it. Without it the
host keeps running against a compositor that no longer exists — still listening, still answering,
and failing at capture on every session after that. Leave it off for the headless appliance route
(a pinned compositor or a gamescope box), which may never reach that target. Same reasoning, and
the same caveats for Sway and Hyprland, as [Restart the host with your
desktop](/docs/running-as-a-service#restart-the-host-with-your-desktop).
The host and console user services are defined but not started (set `autoStart = true` for an
appliance), so from your graphical session enable them:
```sh
systemctl --user enable --now punktfunk-host punktfunk-web
```
The plugin runner needs no such step — like the deb and RPM, the module starts it for you, because
the game-library scanners ship as plugins. Opt out with
`services.punktfunk.scripting.autoStart = false;`.
The full option reference (client, console and scripting options, GPU driver notes, headless
appliance setup) is in
[packaging/nix](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/nix/README.md). To
@@ -138,14 +153,20 @@ update, run `nix flake update punktfunk` in your flake directory, then `sudo nix
For Gaming Mode, add the [Decky plugin](/docs/steam-deck) on top of it. Full client instructions
for every device: [Install a Client](/docs/install-client).
- **`punktfunk-scripting`** — the plugin/script runner. Install it if you want
[plugins](/docs/plugins) or [automation](/docs/automation). It's inert until you add something to
run, so its user unit ships **disabled** — enable it once you have:
- **`punktfunk-scripting`** — the plugin/script runner, behind [plugins](/docs/plugins) and
[automation](/docs/automation). The game-library scanners ship as plugins, so a host without the
runner can come up with an empty library — which is why **apt, dnf, the Bazzite sysext and the
NixOS module all start it for you**. On **Arch** and source installs it is not started, so enable
it yourself:
```sh
systemctl --user enable --now punktfunk-scripting
```
To opt out where it *is* on: `systemctl --user mask punktfunk-scripting` (`mask`, not `disable`
a plain disable cannot remove a symlink that lives in `/etc` or `/usr`), or on NixOS
`services.punktfunk.scripting.autoStart = false;`.
## After installing
These three steps are for the **Linux packages**. On Windows the installer does the equivalent for
@@ -108,6 +108,12 @@ the host running against a compositor that no longer exists. It keeps listening
every session after that fails at capture, which is a confusing way to find out. The drop-in makes a
compositor restart a host restart.
On **NixOS** don't copy anything — the module has the option:
```nix
services.punktfunk.host.desktopSession = true;
```
Skip it on the headless/appliance route below (which has its own session unit), and on **Sway or
Hyprland**, which don't hand their session to systemd: they never reach `graphical-session.target`, so
the drop-in is harmless there but does nothing. To make the host come and go with the session on
+12
View File
@@ -126,6 +126,7 @@
system:
let
pf = packagesFor system;
pkgs = pkgsFor system;
in
{
inherit (pf)
@@ -134,6 +135,17 @@
punktfunk-web
punktfunk-scripting
;
# The NixOS module, actually evaluated. `nix flake check` does NOT do this for
# `nixosModules` — it only forces the value and asserts it is a lambda taking an open
# attribute set, so a module with a nonexistent option, a nonexistent `pkgs` attribute
# and a nonexistent `lib` function passes clean (measured). Routing the module through a
# `checks` entry instead means the eval-only CI leg has to instantiate it, and every
# assertion in module-check.nix is pure Nix so instantiation is enough to run them.
nixos-module = pkgs.callPackage ./packaging/nix/module-check.nix {
inherit nixpkgs system;
module = import ./packaging/nix/nixos-module.nix;
};
}
);
+2 -2
View File
@@ -26,8 +26,8 @@ The other packaging targets have their own READMEs: [`debian/`](debian/README.md
[`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
[`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
drivers), plus `kde/` and `linux/` helpers. **NixOS / Nix** users get a flake (`flake.nix` at the
repo root) with reproducible host + client packages and a `services.punktfunk` NixOS module —
see [`nix/README.md`](nix/README.md).
repo root) with reproducible `punktfunk-host`, `-client`, `-web`, `-scripting` and `-gamescope`
packages plus a `services.punktfunk` NixOS module — see [`nix/README.md`](nix/README.md).
## What's needed beyond base Fedora
+96 -15
View File
@@ -21,6 +21,7 @@ and the native Linux **client**, a **NixOS module** that wires up everything the
| `packages.x86_64-linux.default` | = `punktfunk-host` |
| `nixosModules.default` | `services.punktfunk.host` / `.client` / `.web` / `.scripting` |
| `devShells.x86_64-linux.default` | pinned Rust (from `rust-toolchain.toml`) + all build deps |
| `checks.x86_64-linux.nixos-module` | evaluates the NixOS module against real nixpkgs and asserts on the rendered systemd units |
| `apps` / `checks` / `formatter` | `nix run`, `nix flake check`, `nix fmt` |
One binary per GPU vendor: NVENC/CUDA entry points are `dlopen`'d at runtime, so the host runs on
@@ -65,6 +66,7 @@ Add the flake and enable the host and/or client:
enable = true;
users = [ "alice" ]; # → added to the `input` group for virtual gamepads
openFirewall = true; # native + GameStream ports
desktopSession = true; # a machine you log into — restart the host with the desktop
settings = {
PUNKTFUNK_VIDEO_SOURCE = "virtual";
RUST_LOG = "info";
@@ -84,10 +86,11 @@ Add the flake and enable the host and/or client:
}
```
Then, in your graphical session:
Then, in your graphical session (the console follows with `punktfunk-web`; the plugin runner is
already started for you — see `scripting.autoStart` below):
```sh
systemctl --user enable --now punktfunk-host
systemctl --user enable --now punktfunk-host punktfunk-web
```
### Options
@@ -99,12 +102,35 @@ systemctl --user enable --now punktfunk-host
| `enable` | `false` | Install the host + wire udev/sysctl/kernel-modules/firewall and the user service. |
| `gamestream` | `true` | `serve --gamestream` (Moonlight-compatible). `false` = native-only, more secure. |
| `autoStart` | `false` | Add the user service to `default.target` (appliance mode — pair with lingering). |
| `users` | `[ ]` | Users added to the `input` group (virtual gamepads). |
| `desktopSession` | `false` | Bind the host to `graphical-session.target`**turn this on for a machine somebody logs into** (see below). |
| `users` | `[ ]` | Users added to the `input` **and `punktfunk`** groups (virtual gamepads; the second covers the usbip/vhci nodes the virtual Steam Deck pad attaches through — it can emulate arbitrary USB hardware, so list only users you'd trust with that). |
| `settings` | `{ }` | `host.env` key/values (see `${package}/share/punktfunk-host/host.env.example`). |
| `environmentFile` | `null` | Extra `EnvironmentFile` for secrets (e.g. `PUNKTFUNK_MGMT_TOKEN`); loaded optionally. |
| `openFirewall` | `false` | Open the inbound ports (see below). |
| `gamescopeHdr` | `true` | Put `punktfunk-gamescope` (gamescope + our `pipewire-hdr` patches) on the service PATH, so a 10-bit client can stream true HDR10 off a gamescope output. Costs a gamescope build from source — set `false` to skip it and stay SDR on that backend. |
| `gamescopePackage` | flake's | The patched gamescope used when `gamescopeHdr = true`. |
| `package` | flake's | Override the package. |
**`desktopSession` — set it on a desktop, leave it off on an appliance.** On a machine somebody logs
into, a compositor restart (a crash, a logout/login, "restart the shell") otherwise leaves the host
running while it holds a Wayland socket and a portal D-Bus connection that both died with the old
compositor. It cannot recover either in-process, and the failure is *silent*: the host still
listens, still answers, and every session it then serves fails at capture. `desktopSession = true`
adds `PartOf=`/`WantedBy=graphical-session.target` (in addition to `default.target`), so the host
restarts with the session. Leave it `false` for an appliance — a pinned `PUNKTFUNK_COMPOSITOR`, a
headless KWin or a gamescope box — which may never reach that target and would be left permanently
stopped. sway/Hyprland and anything else not under systemd session management never reach it
either; there, start the host from the compositor's config after `systemctl --user
import-environment`.
**Portals.** The host reaches the desktop through `xdg-desktop-portal` on several backends (Mutter's
ScreenCast/RemoteDesktop, and the libei input path), so a hand-assembled machine wants
`xdg.portal.enable = true` plus the backend for its compositor
(`xdg-desktop-portal-kde` / `-gnome` / `-hyprland` / `-wlr`). The KDE and GNOME desktop-manager
modules already do this. The module emits a warning if the host is enabled and portals are not —
the KWin backend's own virtual output uses the privileged `zkde_screencast` protocol and needs no
portal, so KDE-only setups are unaffected in practice.
`services.punktfunk.client`: `enable`, `openFirewall` (UDP 5353), `package`.
`services.punktfunk.web` (the management console — **on by default whenever the host is enabled**,
@@ -125,39 +151,67 @@ with `journalctl --user -u punktfunk-web-init` (or `~/.config/punktfunk/web-pass
`https://<host-ip>:47992` and trust the self-signed host cert once. Enable it (with the host) via
`systemctl --user enable --now punktfunk-web`.
`services.punktfunk.scripting` (the plugin/script runner — installed with the host, but **opt-in to
run**):
`services.punktfunk.scripting` (the plugin/script runner — installed **and started** with the host,
matching the deb/rpm, which `systemctl --global enable` it):
| Option | Default | Meaning |
| --- | --- | --- |
| `enable` | `host.enable` | Install the runner + define its `systemd --user` unit `punktfunk-scripting`. |
| `autoStart` | `false` | Add the unit to `default.target`. Off even on an auto-start host — running operator scripts/plugins is a deliberate opt-in. |
| `autoStart` | `scripting.enable` | Add the unit to `default.target`. **On by default** — the game-library scanners are plugins, so a host without the runner has an empty library. |
| `package` | flake's | Override the package. |
The runner discovers loose scripts under `~/.config/punktfunk/scripts` and installed
`punktfunk-plugin-*` packages under `~/.config/punktfunk/plugins`, and supervises each as an Effect
fiber (SIGTERM shuts the tree down structurally so plugin finalizers run). A plugin auto-wires to
the host's mgmt token + identity cert. It's inert until you add automation, so the unit ships
un-started; turn it on with `systemctl --user enable --now punktfunk-scripting`.
the host's mgmt token + identity cert.
It used to ship un-started here, on the reasoning that the runner is inert until you add
automation. That stopped being true when the library scanners became plugins — a host with the
runner off comes up with an empty library and no obvious reason why — so it now runs by default,
as it already did on every other channel. Opt out with `scripting.autoStart = false`, or per user
`systemctl --user mask punktfunk-scripting` (`mask`, not `disable`).
The runner is sandboxed exactly as the deb/rpm unit is (`NoNewPrivileges`, `ProtectSystem=strict`,
`ReadWritePaths=%h /tmp`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`) — with `PrivateTmp`
deliberately **off**, because plugins integrate with things that talk over `/tmp`. `ProtectSystem`
on a *user* unit needs unprivileged user namespaces; drop it with
`systemctl --user edit punktfunk-scripting` on a kernel that restricts them.
### What the host module configures for you
Everything the RPM's `%install` + `%post` do, declaratively:
- **systemd `--user` service** `punktfunk-host``serve [--gamestream]`, `EnvironmentFile` from
`settings` (+ optional secret file), `Restart=on-failure`.
`settings` (+ optional secret file), `Restart=on-failure`, and — with `desktopSession`
`PartOf=graphical-session.target`.
- **udev rules** (`60-punktfunk.rules`): `/dev/uinput` + `/dev/uhid` group access and the vhci
sysfs perms for the virtual Steam Deck.
- **kernel modules**: `uinput`, `uhid`, `vhci-hcd` (usbip transport so Steam Input adopts the
virtual Deck).
- **sysctl**: `net.core.{r,w}mem_max = 32 MB` (high-bitrate UDP headroom; `mkDefault`).
- **`input` group** membership for `users`.
- **`input` and `punktfunk` groups**, declared and joined for `users`. Both are required: the udev
rule `chgrp punktfunk`s the vhci nodes and fails outright if nothing ever created that group.
- **A `security.wrappers` entry for `punktfunk-encode-worker`** carrying `cap_sys_nice=ep`, with
`PUNKTFUNK_ENCODE_WORKER` pointed at it. A file capability cannot live on a read-only store path,
so a wrapper is the only mechanism NixOS has. The capability is deliberately **not** on the host
itself — see the caveat below.
- **`hardware.graphics.enable = true`** (`mkDefault`) so `/run/opengl-driver/lib` has the driver
libs the binaries `dlopen`.
- **firewall** (when `openFirewall`): native UDP 9777/5353 + TCP 47990; with `gamestream` also TCP
47984/47989/48010 + UDP 47998/47999/48000. The media data plane is an ephemeral, hole-punched
UDP port — nothing fixed to open.
47984/47989/48010 + UDP 47998/47999/48000; with the console, TCP 47992 + 47993. The media data
plane is an ephemeral, hole-punched UDP port — nothing fixed to open.
- **tray autostart** entry (`--autostart`; self-gates to users who actually run a host).
- **A warning** if `xdg.portal.enable` is off (see the portal note above).
> **Why the capability is on the worker and not the host.** KWin only advertises its restricted
> protocols (`zkde_screencast_unstable_v1` for the virtual output, `org_kde_kwin_fake_input` for
> input) to a client it can *identify*, by resolving that client's `/proc/<pid>/exe` and matching an
> installed `.desktop`'s `Exec=`. The kernel refuses that readlink to any reader whose effective set
> is not a superset of the target's permitted set, and KWin holds no capabilities. A NixOS wrapper
> does not dodge this — it raises the capability into the ambient set before exec'ing, which lands
> it in the permitted set and fails the readlink identically. Giving the host `cap_sys_nice` broke
> desktop streaming on every KDE box in 0.26.0-1. The encode worker is a separate binary that
> nothing ever has to identify, so the grant is safe there.
### GPU drivers (out of scope of the module — set these yourself)
@@ -180,8 +234,15 @@ services.punktfunk.host = {
settings = { PUNKTFUNK_COMPOSITOR = "gamescope"; }; # appliance-only; omit to auto-detect
};
users.users.streamer.linger = true;
# For the gamescope/KWin backends extend the service PATH, e.g.:
# systemd.user.services.punktfunk-host.path = [ pkgs.gamescope ];
```
Leave `desktopSession` off here — an appliance starts its own compositor and may never reach
`graphical-session.target`, which would leave the host permanently stopped. `gamescopeHdr` (on by
default) already puts the patched `punktfunk-gamescope` on the service PATH, so the gamescope
backend needs no PATH surgery; extend it only for a helper the module doesn't know about:
```nix
# systemd.user.services.punktfunk-host.path = [ pkgs.some-helper ];
```
The `${package}/share/punktfunk-host/headless/` helpers (KDE/Sway session scripts, example
@@ -278,10 +339,30 @@ The shell exports an
to consume a prebuilt Skia offline (a fixed-output derivation of the rust-skia tarball) or a
vendored from-source Skia build — a tracked follow-up.
- **`nix flake check` does NOT check the NixOS module — that is why `module-check.nix` exists.**
For `nixosModules`, nix forces the value and asserts it is a lambda taking an open attribute set,
and stops there (its source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check
the module.`). Measured: a module setting a nonexistent *option*, referencing a nonexistent
`pkgs` attribute **and** calling a nonexistent `lib` function passes clean, printing
`checking NixOS module 'nixosModules.default'... all checks passed!`. So the reassuring line means
nothing. `checks.<system>.nixos-module` (`packaging/nix/module-check.nix`) closes it: it evaluates
the module against real nixpkgs in four scenarios and asserts on the rendered systemd units.
Two rules if you edit it — **keep every assertion pure Nix** (instantiating the derivation is what
runs them, which is what lets the cheap `--no-build` CI leg cover it; a shell script in the
`runCommand` body would only run under a full `nix flake check`, i.e. an hour of Rust), and
**assert list-valued unit fields on the evaluated lists**, not the rendered text — systemd renders
`After=` as one space-separated line, so an `hasInfix` on it silently depends on ordering.
## Verified
Both packages build, install, and run on real Nix hardware (NixOS-equivalent: CachyOS + Nix,
The packages build, install, and run on real Nix hardware (NixOS-equivalent: CachyOS + Nix,
RTX 5070 Ti, driver 610). `punktfunk-host --version` and `punktfunk-session` run; the driver
RUNPATH (`/run/opengl-driver/lib`) and the GTK GApps wrapper (GSettings schemas + pixbuf loaders)
are present. Fixes discovered during that bring-up: `CMAKE_POLICY_VERSION_MINIMUM=3.5` (CMake ≥ 4),
system `libopus` (audiopus_sys), and the session Skia note above.
In CI (`.gitea/workflows/nix.yml`): `nix flake check --no-build` evaluates every output *including*
the module check above, and `punktfunk-web` + `punktfunk-scripting` are built for real. The Rust
packages and `punktfunk-gamescope` are `workflow_dispatch` opt-ins (`build-rust`,
`build-gamescope`) — run the latter after a `flake.lock` bump, since it patches whatever gamescope
the pinned nixpkgs carries.
+263
View File
@@ -0,0 +1,263 @@
# Does the NixOS module actually evaluate, and does it still render the units we decided on?
#
# WHY THIS FILE EXISTS. `nix flake check` does NOT check `nixosModules`. It forces the value and
# asserts it is a lambda taking an open attribute set — nothing more; nix's own source carries
# `// FIXME: if we have a 'nixpkgs' input, use it to check the module.` MEASURED: a flake whose
# `nixosModules.default` sets a nonexistent OPTION, references a nonexistent `pkgs` attribute AND
# calls a nonexistent `lib` function passes clean, printing the thoroughly reassuring
#
# checking NixOS module 'nixosModules.default'... all checks passed!
#
# So every option name, type, `pkgs.*` reference and systemd directive in nixos-module.nix was
# unverified by CI while reading as covered — on a flake whose own history is Nix regressions
# reaching main invisibly (`nix build .#punktfunk-web` was broken for 553 commits).
#
# HOW IT CLOSES THAT. Exposed as a flake `checks` output, so `nix flake check --no-build` — the leg
# CI already runs — must INSTANTIATE it, and instantiating forces the `assert` below. Every
# assertion is therefore pure Nix, evaluated at instantiation: a shell script inside the derivation
# would only run under a full `nix flake check`, which builds the hour-long Rust packages and is
# exactly what CI cannot afford. Keep it that way — if you add a check, add it to `results`, not to
# a `runCommand` body.
#
# STUB PACKAGES, on purpose. The real derivations would drag punktfunk-host, punktfunk-client and
# (via `gamescopeHdr`) a from-source gamescope into this check's closure, making the cheap leg
# expensive and coupling a module regression to a Rust build. What is under test here is the MODULE.
# ⚠ The stubs must be fake DERIVATIONS, not store-path strings: `types.package` accepts anything
# `isDerivation` as-is, but runs a store-path string through `builtins.storePath`, which demands the
# path actually exist and fails eval with "no substituter can build it".
{
lib,
runCommand,
# The nixpkgs SOURCE. Interpolated rather than `nixpkgs + "/..."` because this is called with the
# flake INPUT, which is an attribute set (coerced through its `outPath`) and not a path — the
# difference only shows up as "expected a set but found a string" from whichever side is wrong.
nixpkgs,
system,
module,
}:
let
fakeDrv = name: {
type = "derivation";
inherit name;
outPath = "/pf-stub/${name}";
outputs = [ "out" ];
};
stubSelf = {
packages.${system} = lib.genAttrs [
"punktfunk-host"
"punktfunk-client"
"punktfunk-web"
"punktfunk-scripting"
"punktfunk-gamescope"
] fakeDrv;
};
# A machine just complete enough for eval-config, plus the scenario under test.
evalWith =
scenario:
(import "${nixpkgs}/nixos/lib/eval-config.nix" {
inherit system;
modules = [
(module stubSelf)
{
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "/dev/sda1";
fsType = "ext4";
};
system.stateVersion = "24.11";
nixpkgs.hostPlatform = system;
# `host.users` adds group membership to an existing user; declare one so NixOS's own
# "isNormalUser or isSystemUser" assertion is not what this check trips over.
users.users.alice.isNormalUser = true;
}
scenario
];
}).config;
# Every scenario keeps `gamescopeHdr = false`: it is the one option whose default would pull a
# real (stub, here) gamescope onto the unit PATH, and nothing below is about that.
desktop = evalWith {
services.punktfunk.host = {
enable = true;
users = [ "alice" ];
gamescopeHdr = false;
desktopSession = true;
};
};
appliance = evalWith {
services.punktfunk.host = {
enable = true;
autoStart = true;
openFirewall = true;
gamescopeHdr = false;
};
};
nativeOnly = evalWith {
services.punktfunk.host = {
enable = true;
openFirewall = true;
gamestream = false;
gamescopeHdr = false;
};
};
clientOnly = evalWith { services.punktfunk.client.enable = true; };
unit = cfg: name: cfg.systemd.user.units."${name}.service".text;
has =
cfg: name: infix:
lib.hasInfix infix (unit cfg name);
# A module's own failed assertions, as messages.
failedAssertions = cfg: map (a: a.message) (lib.filter (a: !a.assertion) cfg.assertions);
results = [
# --- the module evaluates at all, in every shape an operator can ask for -------------------
{
name = "desktop scenario has no failing assertions";
ok = failedAssertions desktop == [ ];
}
{
name = "appliance scenario has no failing assertions";
ok = failedAssertions appliance == [ ];
}
{
name = "client-only scenario has no failing assertions";
ok = failedAssertions clientOnly == [ ];
}
# --- the KWin identification trap (packaging/arch/punktfunk-host.install) -------------------
# The host MUST exec the plain store path. A capability wrapper here would put CAP_SYS_NICE in
# the process's permitted set, and the kernel then refuses KWin the /proc/<pid>/exe readlink it
# identifies the client by — which cost every KDE box its desktop streaming in 0.26.0-1.
{
name = "host ExecStart is the store binary, never a capability wrapper";
ok =
has desktop "punktfunk-host" "ExecStart=/pf-stub/punktfunk-host/bin/punktfunk-host serve"
&& !(has desktop "punktfunk-host" "ExecStart=/run/wrappers");
}
# ...while the ENCODE WORKER, which nothing ever has to identify, is pointed at the wrapper.
{
name = "host points PUNKTFUNK_ENCODE_WORKER at the capability wrapper";
ok =
has desktop "punktfunk-host"
"PUNKTFUNK_ENCODE_WORKER=/run/wrappers/bin/punktfunk-encode-worker";
}
{
name = "the encode-worker wrapper carries exactly cap_sys_nice=ep";
ok = desktop.security.wrappers.punktfunk-encode-worker.capabilities == "cap_sys_nice=ep";
}
# --- the desktop-login route (scripts/punktfunk-host-desktop-session.conf) -----------------
# Asserted on the evaluated LISTS, not the rendered text: systemd renders `After=` as one
# space-separated line, so `hasInfix "After=graphical-session.target"` silently depends on
# ordering — it failed against a correct module the first time this check ran.
{
name = "desktopSession binds the host to graphical-session.target";
ok =
let
u = desktop.systemd.user.services.punktfunk-host;
in
lib.elem "graphical-session.target" u.after
&& lib.elem "graphical-session.target" u.partOf
# IN ADDITION to default.target, never instead of it.
&& lib.elem "graphical-session.target" u.wantedBy;
}
{
name = "desktopSession is NOT applied to the appliance route (it would stay stopped there)";
ok =
let
u = appliance.systemd.user.services.punktfunk-host;
in
!(lib.elem "graphical-session.target" u.partOf) && lib.elem "default.target" u.wantedBy;
}
# --- GameStream opt-out reaches both the argv and the firewall -----------------------------
{
name = "gamestream=true passes --gamestream";
ok = has desktop "punktfunk-host" "serve --gamestream";
}
{
name = "gamestream=false drops --gamestream and its firewall ports";
ok =
!(has nativeOnly "punktfunk-host" "--gamestream")
&& !(lib.elem 47984 nativeOnly.networking.firewall.allowedTCPPorts)
&& lib.elem 47990 nativeOnly.networking.firewall.allowedTCPPorts;
}
{
name = "openFirewall opens the console AND its plugin origin";
ok =
lib.elem 47992 appliance.networking.firewall.allowedTCPPorts
&& lib.elem 47993 appliance.networking.firewall.allowedTCPPorts;
}
# --- the three divergences from the shipped units, as regression guards --------------------
# Each of these was ONCE wrong here while right in scripts/*.service. Assert the decision, so a
# future edit cannot quietly drift back.
{
# Without this, systemd's default 5-starts-per-10s against RestartSec=2 gives up permanently
# after ~10 s — the exact window before the host's first `serve` writes the mgmt token.
name = "web console retries indefinitely while the host writes its mgmt token";
ok = has appliance "punktfunk-web" "StartLimitIntervalSec=0";
}
{
# A console that exits 0 has still stopped serving.
name = "web console restarts on ANY exit, not just failure";
ok =
has appliance "punktfunk-web" "Restart=always"
&& !(has appliance "punktfunk-web" "Restart=on-failure");
}
{
# The one unit here that runs arbitrary operator TypeScript by design.
name = "the plugin runner is sandboxed like the deb/rpm unit";
ok =
has appliance "punktfunk-scripting" "NoNewPrivileges=true"
&& has appliance "punktfunk-scripting" "ProtectSystem=strict"
&& has appliance "punktfunk-scripting" "ReadWritePaths=%h"
&& has appliance "punktfunk-scripting" "ReadWritePaths=/tmp"
&& has appliance "punktfunk-scripting" "RestrictAddressFamilies=AF_UNIX";
}
{
# PrivateTmp is OFF on purpose (the VirtualHere field report: a private /tmp hides
# /tmp/vhclient and /tmp/.X11-unix, so a plugin cannot reach the daemon it integrates with).
name = "the plugin runner keeps the real /tmp";
ok = has appliance "punktfunk-scripting" "PrivateTmp=false";
}
{
# Since the library scanners became plugins, a runner that is off means an EMPTY LIBRARY and
# no obvious reason why — which is why deb+rpm `systemctl --global enable` it.
name = "the plugin runner is started by default, like every other channel";
ok = has appliance "punktfunk-scripting" "WantedBy=default.target";
}
# --- the client half must not drag the host's system wiring in -----------------------------
{
name = "a client-only machine defines no host/web/scripting units";
ok =
!(clientOnly.systemd.user.services ? punktfunk-host)
&& !(clientOnly.systemd.user.services ? punktfunk-web)
&& !(clientOnly.systemd.user.services ? punktfunk-scripting);
}
];
failures = map (r: r.name) (lib.filter (r: !r.ok) results);
in
# The `assert` is what makes `--no-build` sufficient: instantiating this derivation forces it.
assert
failures == [ ]
|| throw ''
The punktfunk NixOS module no longer renders what packaging/nix/module-check.nix requires.
Failing checks (${toString (lib.length failures)} of ${toString (lib.length results)}):
- ${lib.concatStringsSep "\n - " failures}
'';
runCommand "punktfunk-nixos-module-check"
{
# Recorded in the output so a green run says what it actually covered.
passed = toString (lib.length results);
}
''
echo "punktfunk NixOS module: $passed checks passed at eval time" > "$out"
''
+127 -15
View File
@@ -3,8 +3,8 @@
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
# UDP socket-buffer sysctls, the firewall openers, the `input`- and `punktfunk`-group membership
# for virtual gamepads, the management web console (`services.punktfunk.web`, on by default with
# the host — the RPM/deb Recommends), and the opt-in plugin/script runner
# (`services.punktfunk.scripting`).
# the host — the RPM/deb Recommends), and the plugin/script runner
# (`services.punktfunk.scripting`, likewise on by default — the game-library scanners are plugins).
#
# Usage (flake):
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
@@ -107,6 +107,34 @@ in
'';
};
desktopSession = mkOption {
type = types.bool;
default = false;
description = ''
Bind the host to the DESKTOP LOGIN session's lifetime
(`PartOf=`/`WantedBy=graphical-session.target`), so a Plasma/GNOME restart restarts the
host with it.
Turn this on for a machine somebody logs into. Without it, when the compositor restarts
(a crash, a logout/login, "restart the shell") the host keeps running while holding a
Wayland socket and a portal D-Bus connection that both died with the old compositor. It
cannot recover either in-process, and the failure is silent: the host still listens,
still answers, and every session it then serves fails at capture. A host that idles for
days between sessions is exactly the shape that gets discovered at the worst moment.
Leave it OFF for an appliance a pinned `PUNKTFUNK_COMPOSITOR`, a headless KWin or a
gamescope box. Those start their own compositor and may never reach
`graphical-session.target` at all, and this would leave the host permanently stopped.
No effect under sway/Hyprland or any session not managed by systemd (they never reach
that target either): there, start the host from the compositor's own config, after
`systemctl --user import-environment`, so it dies and comes back with the session.
This is the declarative equivalent of the `scripts/punktfunk-host-desktop-session.conf`
drop-in the deb/rpm document for the same route.
'';
};
users = mkOption {
type = types.listOf types.str;
default = [ ];
@@ -257,9 +285,9 @@ in
};
# The plugin/script runner — host automation on bun. Ships with the host (the RPM/deb Recommends
# it), but running it is OPT-IN: the `systemd --user` unit is defined yet NOT added to
# `default.target`, because the runner is inert until you add scripts/plugins. Turn it on with
# `systemctl --user enable --now punktfunk-scripting`.
# it) and, like them, runs by default: the game-library scanners are plugins, so a host with the
# runner off has an empty library. Opt out with `scripting.autoStart = false` or, per user,
# `systemctl --user mask punktfunk-scripting`.
scripting = {
enable = mkOption {
type = types.bool;
@@ -267,9 +295,10 @@ in
defaultText = literalExpression "config.services.punktfunk.host.enable";
description = ''
Install the plugin/script runner and define its `systemd --user` unit
(`punktfunk-scripting`). Enabled by default whenever the host is but the unit is not
auto-started (see `autoStart`), since the runner does nothing until you add scripts to
`~/.config/punktfunk/scripts` or install `punktfunk-plugin-*` packages under
(`punktfunk-scripting`). Enabled by default whenever the host is, and started by default
too (see `autoStart`) the game-library scanners are plugins, so a host without the
runner has an empty library. It also runs whatever you put in
`~/.config/punktfunk/scripts` or install as `punktfunk-plugin-*` under
`~/.config/punktfunk/plugins`. A plugin auto-wires to the host's mgmt token + identity cert.
'';
};
@@ -283,11 +312,21 @@ in
autoStart = mkOption {
type = types.bool;
default = false;
default = cfg.scripting.enable;
defaultText = literalExpression "config.services.punktfunk.scripting.enable";
description = ''
Start the runner automatically in every user's graphical session (adds it to the user
`default.target`). Off by default even when the host auto-starts running arbitrary
operator scripts/plugins is a deliberate opt-in; enable it once you have automation to run.
`default.target`).
ON by default, matching every other channel: the deb postinst and the RPM `%post` both
run `systemctl --global enable punktfunk-scripting.service`, and the sysext image bakes
in the `default.target.wants` symlink. It used to be opt-in here, on the reasoning that
the runner does nothing until you add scripts or plugins that stopped being true when
the game-library scanners became plugins. A host whose runner is off now comes up with an
empty library and no obvious reason why (design/library-scanner-plugins.md D9).
It remains opt-OUT: set this to `false`, or per user
`systemctl --user mask punktfunk-scripting`.
'';
};
};
@@ -305,6 +344,23 @@ in
# The GPU driver libs the binaries dlopen at runtime (libcuda / libnvidia-encode / libEGL /
# the Vulkan ICD) live under /run/opengl-driver/lib — provided by hardware.graphics.
hardware.graphics.enable = mkDefault true;
# A WARNING, not `xdg.portal.enable = mkDefault true`: enabling the portal service without an
# `extraPortals` backend is its own broken state, and only the operator knows which backend
# their compositor needs. The desktop-manager modules (plasma6, gnome) already wire theirs, so
# this fires exactly where it should — a headless/appliance or sway/Hyprland box assembled by
# hand. It matters because the host reaches the desktop through portals on several backends:
# Mutter's virtual output is ashpd ScreenCast/RemoteDesktop, and the libei input path's own
# error message is "is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?".
warnings = optional (cfg.host.enable && !config.xdg.portal.enable) ''
services.punktfunk.host is enabled but xdg.portal.enable is false. The host drives the
compositor through xdg-desktop-portal on several backends (Mutter's ScreenCast/RemoteDesktop
and the libei input path), so capture or input will fail there with a portal error. Set
xdg.portal.enable = true and add the backend for your compositor, e.g.
xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-kde ] # or -gnome / -hyprland / -wlr
(the KDE and GNOME desktop-manager modules already do this for you). The KWin backend's own
virtual output uses the privileged zkde_screencast protocol and needs no portal.
'';
# 32 MB UDP socket buffers — without this the kernel clamps the host's SO_SNDBUF / client's
# SO_RCVBUF and high-bitrate frames overflow (measured: 4 MB cap = 31.6 % loss at 2 Gbps).
boot.kernel.sysctl = {
@@ -409,9 +465,17 @@ in
description = "punktfunk GameStream + punktfunk/1 streaming host";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
# Soft ordering: the host listens immediately and only touches the compositor per session.
after = [ "pipewire.service" ];
after = [ "pipewire.service" ] ++ optional cfg.host.desktopSession "graphical-session.target";
wants = [ "pipewire.service" ];
wantedBy = optional cfg.host.autoStart "default.target";
# `graphical-session.target` is IN ADDITION to `default.target`, never instead of it: the
# host still comes up at login before the graphical session is ready — it listens without
# touching the compositor and only opens one per client connect, so an early start costs
# nothing. `partOf` is the half that matters, taking the host down with the session so the
# next one gets a fresh compositor connection (see `desktopSession`).
partOf = optional cfg.host.desktopSession "graphical-session.target";
wantedBy =
optional cfg.host.autoStart "default.target"
++ optional cfg.host.desktopSession "graphical-session.target";
# The host may exec external helpers (pw-dump, sh, and — for the gamescope/kwin backends —
# the compositor). Extend this in your config for a headless gamescope/KWin appliance.
path = [
@@ -477,7 +541,10 @@ in
# policy keeps a plugin from acting as the logged-in operator. Leaving it closed does not
# degrade gracefully — every plugin interface is simply an empty panel from any other device.
# Keep in step with packaging/linux/punktfunk-web.xml and punktfunk.ufw.
allowedTCPPorts = [ 47992 47993 ];
allowedTCPPorts = [
47992
47993
];
};
# First-run setup: generate the console login password once, in the user's config dir, and
@@ -508,6 +575,15 @@ in
];
wants = [ "punktfunk-web-init.service" ];
wantedBy = optional cfg.web.autoStart "default.target";
# Retry INDEFINITELY while the host is still writing the mgmt token + identity cert. The
# EnvironmentFile below is mandatory on purpose, so the unit genuinely fails until those
# exist — and systemd's default rate limit (5 starts / 10 s) against `RestartSec = 2` gives
# up permanently after ~10 s, which on an appliance is exactly the window before the host's
# first `serve` completes. A console enabled before the host's first run then stayed dead
# until someone restarted it by hand. The shipped unit (scripts/punktfunk-web.service) has
# carried this since that defect was found; it was missed in the port, while the comment
# below went on promising the behaviour it removes.
unitConfig.StartLimitIntervalSec = 0;
environment = {
PUNKTFUNK_MGMT_URL = "https://127.0.0.1:47990";
PORT = "47992";
@@ -525,7 +601,11 @@ in
"-%h/.config/punktfunk/web-password"
];
ExecStart = "${cfg.web.package}/bin/punktfunk-web-server";
Restart = "on-failure";
# `always`, not `on-failure`: a console that exits 0 has still stopped serving, and
# `on-failure` would leave it down. An explicit `systemctl --user stop` is still honoured
# (Restart= never fights that). Matches scripts/punktfunk-web.service and the Windows
# web-run.cmd, both of which relaunch bun on ANY exit.
Restart = "always";
RestartSec = 2;
};
};
@@ -554,6 +634,38 @@ in
KillMode = "mixed";
KillSignal = "SIGTERM";
TimeoutStopSec = 30;
# Sandbox — the same confinement scripts/punktfunk-scripting.service gives the deb/rpm
# installs. The runner `import()`s the operator's own `.ts` files, so this is the one unit
# here that executes arbitrary code by design; without these it ran strictly LESS confined
# on NixOS than on every other channel. Read-only outside $HOME, no setuid re-escalation,
# and only the address families automation actually uses (loopback mgmt API, LAN/IPv6
# webhooks, unix sockets).
NoNewPrivileges = true;
# PrivateTmp deliberately OFF (field report 2026-08-03, the VirtualHere plugin). A
# plugin's whole job is integrating with things already running on this box, and on Linux
# those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient +
# /tmp/vhclient_response, X11 is /tmp/.X11-unix. A private /tmp hides all of it — the
# plugin launches the vendor binary fine and then cannot reach the daemon behind it,
# which presents as an error no amount of config fixes.
PrivateTmp = false;
ProtectSystem = "strict";
# ReadWritePaths puts back the write bit ProtectSystem=strict takes away: plugin state and
# ~/.config/punktfunk under $HOME, plus the /tmp above. A plugin that must write OUTSIDE
# $HOME (a game library on another mount) gets it with
# systemctl --user edit punktfunk-scripting → [Service] ReadWritePaths=/mnt/games
# ⚠ ProtectSystem is a MOUNT-NAMESPACE option, and for a *user* unit that needs
# unprivileged user namespaces. On a kernel/config that restricts those it fails the unit
# rather than degrading — drop it via the same drop-in if this box is one of them.
ReadWritePaths = [
"%h"
"/tmp"
];
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
};
};
})