Files
punktfunk/packaging/nix/README.md
T
enricobuehler 1befa8a2c4 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.
2026-08-10 20:24:21 +02:00

369 lines
22 KiB
Markdown

# punktfunk on NixOS / Nix
First-class Nix support via the repo's `flake.nix`: reproducible builds of the streaming **host**
and the native Linux **client**, a **NixOS module** that wires up everything the RPM/deb do
(systemd user service, udev rules, kernel modules, sysctl tuning, firewall, `input` group), and a
**dev shell** with the pinned toolchain and every system library.
> **Platform:** `x86_64-linux` only (the host encodes with desktop NVENC; matches the RPM's
> `ExclusiveArch: x86_64`). NixOS **24.11 or newer** for the `hardware.graphics` option.
---
## What the flake provides
| Output | Contents |
| --- | --- |
| `packages.x86_64-linux.punktfunk-host` | `punktfunk-host` + `punktfunk-tray` (built with `nvenc` + `vulkan-encode`, like CI) |
| `packages.x86_64-linux.punktfunk-client` | `punktfunk-client` (GTK4 shell) + `punktfunk-session` (Vulkan streamer, without the Skia OSD — see caveats) |
| `packages.x86_64-linux.punktfunk-web` | the management web console (bun-built Nitro SSR bundle; SPAKE2 pairing + host status) |
| `packages.x86_64-linux.punktfunk-scripting` | the plugin/script runner (bun-bundled Effect SDK; supervises host automation) |
| `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
NVIDIA (zero-copy dmabuf → CUDA → NVENC), AMD/Intel (raw Vulkan-Video HEVC / VAAPI), or software.
---
## Quick start (no NixOS required)
```sh
# Build
nix build git+https://git.unom.io/unom/punktfunk#punktfunk-host
nix build git+https://git.unom.io/unom/punktfunk#punktfunk-client
# Run
nix run git+https://git.unom.io/unom/punktfunk#punktfunk-host -- serve --gamestream
nix run git+https://git.unom.io/unom/punktfunk#punktfunk-client
```
GPU drivers are resolved at runtime from `/run/opengl-driver/lib`. On non-NixOS distros use
[nixGL](https://github.com/nix-community/nixGL) so that path is populated (`nixGL nix run …`); on
NixOS the module (below) sets `hardware.graphics.enable = true` for you.
---
## NixOS module
Add the flake and enable the host and/or client:
```nix
{
inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
# (optional) share your nixpkgs: inputs.punktfunk.inputs.nixpkgs.follows = "nixpkgs";
outputs = { self, nixpkgs, punktfunk, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
punktfunk.nixosModules.default
({ ... }: {
services.punktfunk.host = {
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";
# PUNKTFUNK_444 = true; # booleans render as 1/0
};
};
# …and/or the client on the same or another box:
services.punktfunk.client = {
enable = true;
openFirewall = true; # UDP 5353 for mDNS discovery
};
})
];
};
};
}
```
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 punktfunk-web
```
### Options
`services.punktfunk.host`:
| Option | Default | Meaning |
| --- | --- | --- |
| `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). |
| `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**,
mirroring the RPM's `Recommends: punktfunk-web`):
| Option | Default | Meaning |
| --- | --- | --- |
| `enable` | `host.enable` | Run the console as a `systemd --user` service on **TCP 47992 (HTTPS)**. Set `false` for a console-less host. |
| `openFirewall` | `host.openFirewall` | Open TCP 47992 so other devices on the LAN can reach it. |
| `autoStart` | `host.autoStart` | Add the console user service to `default.target` (appliance mode). |
| `package` | flake's | Override the package. |
The console is **auto-wired** to the host on the same box: it reads the host's per-user
`~/.config/punktfunk/{mgmt-token,cert.pem,key.pem}` (written by `serve`), serves HTTPS with the
host's own identity cert, and proxies the loopback mgmt API with the bearer token injected
server-side (never sent to the browser). A login password is generated on first start — read it
with `journalctl --user -u punktfunk-web-init` (or `~/.config/punktfunk/web-password`). Then open
`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 **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` | `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 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`, 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` 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; 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)
- **NVIDIA:** `hardware.nvidia` + `hardware.graphics.enable = true`. NVENC/CUDA come from the
driver at runtime (nothing pinned in the closure).
- **AMD/Intel:** `hardware.graphics.enable = true` with `extraPackages = [ vaapiVdpau … ]` /
`intel-media-driver` for VAAPI encode; the host's raw Vulkan-Video HEVC path needs only Mesa.
### Headless / appliance
Set `autoStart = true`, enable lingering, and — for a **dedicated single-session appliance**
pin a backend in `settings` (pinning `PUNKTFUNK_COMPOSITOR` disables live-session auto-detection,
so leave it out on any box that switches between a desktop and Game Mode):
```nix
services.punktfunk.host = {
enable = true;
autoStart = true;
users = [ "streamer" ];
settings = { PUNKTFUNK_COMPOSITOR = "gamescope"; }; # appliance-only; omit to auto-detect
};
users.users.streamer.linger = true;
```
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
`host.env` files, the OpenAPI doc) are installed for reference.
---
## Development
```sh
nix develop # pinned toolchain (rust-toolchain.toml) + all system libs
cargo build --release -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
# The tray gets its OWN invocation — co-building it with the host unifies the host's
# ashpd -> zbus/tokio onto the tray's zbus (which runs ksni's async-io executor, no tokio runtime),
# and the resulting binary panics at launch: "there is no reactor running, must be called from the
# context of a Tokio 1.x runtime". Same split the .deb / RPM / Arch packaging does.
cargo build --release -p punktfunk-tray
```
The shell exports an
`LD_LIBRARY_PATH` that includes `/run/opengl-driver/lib` so `cargo run` finds the GPU driver.
`nix fmt` formats the `.nix` files.
---
## Notes & caveats
- **Build tool:** [crane](https://github.com/ipetkov/crane). The lockfile carries
`windows 0.62.2` from both crates.io *and* a pinned `microsoft/windows-rs` git rev (the Windows
client), which `rustPlatform.importCargoLock` can't vendor (colliding `name-version`); crane
vendors per-source and fetches the git rev via `builtins.fetchGit` (no output hash to maintain).
Those crates are `cfg(windows)`-gated — vendored, never compiled on Linux.
- **First build compiles from scratch** (no split dep cache — pyrowave-sys builds a CMake tree in
its build.rs that a crane "dummy" source would drop) and has no public binary cache, so expect a
long initial build. `nix develop` gives incremental rebuilds.
- **The status tray is built in its own derivation, on purpose.** `punktfunk-tray` uses `ksni`'s
`async-io` zbus executor with no tokio runtime (by design — see `crates/punktfunk-tray/Cargo.toml`).
Cargo unifies features across everything in one `cargo build`, so co-building the tray with the
host would pull the host's `ashpd → zbus/tokio` onto the tray's shared `zbus`, and the tray then
panics at startup (`there is no reactor running, must be called from the context of a Tokio 1.x
runtime`). Building it as a separate `-p punktfunk-tray` invocation keeps its `zbus` on async-io;
the host package copies the resulting binary into its `$out`. (The rpm/arch builds split it the same
way. The **.deb did not**, despite its sibling comments claiming otherwise: `deb.yml` co-built
`-p punktfunk-host -p punktfunk-tray`, and `build-deb.sh`'s own standalone build was skipped because
the poisoned artifact already existed — so this shipped as a real crash-at-launch on Debian/Ubuntu,
not a latent one. Fixed 2026-07-27: the workflow no longer co-builds it and `build-deb.sh` now
rebuilds it unconditionally.)
- **The bun packages (`punktfunk-web`, `punktfunk-scripting`) use [bun2nix](https://github.com/nix-community/bun2nix).**
Their `node_modules` is fetched **one `fetchurl` per package**, straight from the integrity hashes
already in the lockfile, via a generated-and-committed `bun.nix` (`web/bun.nix`, `sdk/bun.nix`).
There is **no aggregate deps hash to bump** — the previous design put `bun install` in a
fixed-output derivation whose single `outputHash` silently went stale on every lockfile change and
broke the build. `bun2nix` is a devDependency of both packages and regenerates `bun.nix` on every
`bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the *published*
`@punktfunk/host` package and a `postinstall` would then fire on consumers' installs).
The `@unom` scope needs no special handling: `web/bun.lock` records those tarballs' full
`https://git.unom.io/api/packages/unom/npm/…` URLs and the registry is read-public (the same
anonymous pull CI's rpm/deb builds do).
> ⚠⚠ **That devDependency hook is a convenience, NOT the guarantee — `bun.nix` still drifts.**
> It fires only on a local `bun install` that runs lifecycle scripts. It does *not* fire under
> `bun install --ignore-scripts`, which is what every bun install in CI uses; and it cannot fire
> on a **merge or rebase**, where git carries someone else's `bun.lock` change past a `bun.nix`
> generated before it and reports no conflict. That is how `web/bun.nix` shipped on main holding
> `brace-expansion@5.0.7` while `web/bun.lock` said `5.0.8` — for **553 commits** (2026-07-27 →
> 2026-08-05), with `nix build .#punktfunk-web` broken the whole time, until an unrelated
> advisory bump happened to rerun a real `bun install` and closed it by accident.
>
> The enforcement point is **`scripts/ci/check-bun-nix.sh`** (the `bun-nix` job in `ci.yml`,
> unfiltered so it sees the innocuous-looking commits drift arrives through). It regenerates each
> `bun.nix` from its committed `bun.lock` and diffs. Fix any report with:
>
> scripts/ci/check-bun-nix.sh --fix
>
> Never regenerate with a bare `bunx bun2nix`: **`bun.nix` has no schema stability across bun2nix
> versions**, and an unpinned `bunx` uses whatever is newest. The flake input
> (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency in `web/package.json` +
> `sdk/package.json` must name the *same exact version* — the script checks that too, and always
> generates with the pinned one. Move all three together, then rerun it with `--fix`.
Everything past the deps fetch is offline (the console's codegen + vite build; the runner's
`bun build --target=bun` bundle). Both launchers exec `pkgs.bun` from the store — unlike the
deb/rpm, which vendor a bun binary because apt/dnf have none.
- **Commit `flake.lock`:** it pins the input revisions (nixpkgs / crane / rust-overlay / bun2nix).
It is generated on first eval and checked in.
- **Session Skia OSD is off under Nix.** `punktfunk-session`'s default `ui` feature draws its
on-screen stats/console overlay with `skia-safe`, whose build *downloads* a prebuilt Skia from
the rust-skia releases — which Nix's network-less build sandbox forbids, and a from-source Skia
build pulls the whole gn/ninja/python toolchain plus network-fetched third-party. The feature is
explicitly droppable ("same streaming, stats on stdout only"), so the Nix build compiles the
session with `--no-default-features --features pyrowave`. **Everything streams**; only the
session binary's *optional* on-glass stats overlay is absent, and the **GTK shell
(`punktfunk-client`) is skia-free and fully featured.** Re-adding it means teaching skia-bindings
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
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.