From 1befa8a2c4a5d75acf667939e788d70fec3acbd8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 20:24:21 +0200 Subject: [PATCH] docs(nix): bring the Nix docs in line with the module, and fix a stale claim they shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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. --- CHANGELOG.md | 68 +++++++++++++++++++ docs-site/content/docs/bazzite.md | 6 +- docs-site/content/docs/install.md | 31 +++++++-- .../content/docs/running-as-a-service.md | 6 ++ packaging/README.md | 4 +- packaging/nix/README.md | 68 ++++++++++++++++--- 6 files changed, 164 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ee3801..cca76c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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 `.-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//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..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`. --- diff --git a/docs-site/content/docs/bazzite.md b/docs-site/content/docs/bazzite.md index 1d6497fe..e31ea247 100644 --- a/docs-site/content/docs/bazzite.md +++ b/docs-site/content/docs/bazzite.md @@ -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: diff --git a/docs-site/content/docs/install.md b/docs-site/content/docs/install.md index d63548fe..8dcb467e 100644 --- a/docs-site/content/docs/install.md +++ b/docs-site/content/docs/install.md @@ -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 diff --git a/docs-site/content/docs/running-as-a-service.md b/docs-site/content/docs/running-as-a-service.md index 7773c21f..cd52daa5 100644 --- a/docs-site/content/docs/running-as-a-service.md +++ b/docs-site/content/docs/running-as-a-service.md @@ -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 diff --git a/packaging/README.md b/packaging/README.md index 8055b958..ddb3c7de 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -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 diff --git a/packaging/nix/README.md b/packaging/nix/README.md index 786f90af..a0d07f81 100644 --- a/packaging/nix/README.md +++ b/packaging/nix/README.md @@ -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//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..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.