docs(nix): bring the Nix docs in line with the module, and fix a stale claim they shared

There are three places Nix is documented — the public docs-site, packaging/nix/
README.md, and packaging/README.md — plus the changelog. All had drifted.

STALE CLAIM, and not only for Nix. install.md said the plugin runner's "user unit
ships **disabled** — enable it once you have" something to run. That is true only
of Arch and source installs: the deb postinst and RPM %post both
`systemctl --global enable punktfunk-scripting.service`, and the Bazzite sysext
bakes in a default.target.wants symlink (build-sysext.sh:113). bazzite.md carried
the same claim about its own image. Both corrected, per channel, with the reason
the default flipped — the library scanners are plugins, so a host without the
runner can come up with an empty library — and the `mask`-not-`disable` opt-out
the sysext's own comment documents.

docs-site:
  * install.md NixOS — `desktopSession` in the example and explained, the runner
    no longer needs enabling, and the host/console line says what autoStart does.
  * running-as-a-service.md — "Restart the host with your desktop" documented the
    drop-in for packaged installs only; NixOS gets its one-liner beside it.
  * bazzite.md — the runner is started for you, not "isn't started".

packaging/nix/README.md:
  * option tables gain `desktopSession`, `gamescopeHdr`, `gamescopePackage`, and
    the `punktfunk` group next to `input` (both are required — the udev rule
    chgrp's the vhci nodes and fails outright if the group was never created).
  * "what the module configures" gains the security.wrappers entry, and a note on
    why the capability sits on the encode worker and never on the host: a wrapper
    raises it into the ambient set, which lands it in the permitted set and fails
    KWin's /proc/<pid>/exe readlink identically to a file capability.
  * the appliance snippet no longer tells you to put pkgs.gamescope on PATH —
    gamescopeHdr does that with the patched build, and desktopSession is called
    out as the thing to leave off there.
  * a caveat recording that `nix flake check` does not check the module, and the
    two rules for editing module-check.nix (assertions stay pure Nix; assert
    list-valued unit fields on the lists, not the rendered text).

packaging/README.md: the flake ships five packages, not "host + client".

CHANGELOG.md v0.27.0: a NixOS section covering the comm/session-detection fix, the
module changes including the scripting default flip as an explicit behaviour
change, and the flake-check gap — plus the documentation bullets above.
This commit is contained in:
2026-08-10 20:24:21 +02:00
parent f8cde0adaf
commit 1befa8a2c4
6 changed files with 164 additions and 19 deletions
+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`.
---
+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
+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
+58 -10
View File
@@ -66,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";
@@ -85,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
@@ -101,10 +103,12 @@ systemctl --user enable --now punktfunk-host
| `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). |
| `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` group (virtual gamepads). |
| `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
@@ -178,19 +182,36 @@ on a *user* unit needs unprivileged user namespaces; drop it with
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)
@@ -213,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
@@ -311,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.