Ship the management web console and the plugin/script runner across the packaging
matrix, and fix a Linux tray startup crash.
- nix: add punktfunk-web (Nitro SSR on bun) and punktfunk-scripting (Effect runner),
with services.punktfunk.{web,scripting} modules. Both bun node_modules come from
fixed-output derivations pinned to real deps hashes; patchShebangs the web
node_modules so orval/paraglide/vite run in the sandbox. Validated on x86_64-linux
(nix build .#punktfunk-{web,scripting,tray,host} all green).
- punktfunk-scripting now ships for deb/rpm/arch/nix/windows, mirroring the web-console
mechanics (vendored bun on deb/rpm/arch, pkgs.bun on nix; one self-contained
`bun build --target=bun` bundle). Opt-in: host Recommends it, but the systemd --user
unit ships un-started (Windows registers its scheduled task disabled).
- fix(tray): build punktfunk-tray in its OWN cargo invocation on nix/rpm/arch. Cargo
unifies features across one `cargo build`, so co-building the tray with the host pulls
the host's ashpd->zbus/tokio onto the tray's shared zbus and it panics at startup
("there is no reactor running, must be called from the context of a Tokio 1.x
runtime"). Isolated, the tray's zbus stays on async-io. The .deb already did this;
runtime-verified on nix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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-linuxonly (the host encodes with desktop NVENC; matches the RPM'sExclusiveArch: x86_64). NixOS 24.11 or newer for thehardware.graphicsoption.
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 |
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)
# 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 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:
{
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
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:
systemctl --user enable --now punktfunk-host
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). |
users |
[ ] |
Users added to the input group (virtual gamepads). |
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). |
package |
flake's | Override the package. |
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 with the host, but opt-in to
run):
| 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. |
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.
What the host module configures for you
Everything the RPM's %install + %post do, declaratively:
- systemd
--userservicepunktfunk-host→serve [--gamestream],EnvironmentFilefromsettings(+ optional secret file),Restart=on-failure. - udev rules (
60-punktfunk.rules):/dev/uinput+/dev/uhidgroup 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). inputgroup membership forusers.hardware.graphics.enable = true(mkDefault) so/run/opengl-driver/libhas the driver libs the binariesdlopen.- firewall (when
openFirewall): native UDP 9777/5353 + TCP 47990; withgamestreamalso TCP 47984/47989/48010 + UDP 47998/47999/48000. 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).
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 = truewithextraPackages = [ vaapiVdpau … ]/intel-media-driverfor VAAPI encode; the host's raw Vulkan-Video HEVC path needs only Mesa.
Headless / appliance
Set autoStart = true, enable lingering, and pick a backend in settings:
services.punktfunk.host = {
enable = true;
autoStart = true;
users = [ "streamer" ];
settings = { PUNKTFUNK_COMPOSITOR = "gamescope"; }; # or kwin/mutter/wlroots
};
users.users.streamer.linger = true;
# For the gamescope/KWin backends extend the service PATH, e.g.:
# systemd.user.services.punktfunk-host.path = [ pkgs.gamescope ];
The ${package}/share/punktfunk-host/headless/ helpers (KDE/Sway session scripts, example
host.env files, the OpenAPI doc) are installed for reference.
Development
nix develop # pinned toolchain (rust-toolchain.toml) + all system libs
cargo build --release -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-tray
The shell exports PF_FFVK_VULKAN_INCLUDE (Vulkan headers for pf-ffvk bindgen) and 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. The lockfile carries
windows 0.62.2from both crates.io and a pinnedmicrosoft/windows-rsgit rev (the Windows client), whichrustPlatform.importCargoLockcan't vendor (collidingname-version); crane vendors per-source and fetches the git rev viabuiltins.fetchGit(no output hash to maintain). Those crates arecfg(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 developgives incremental rebuilds. - The status tray is built in its own derivation, on purpose.
punktfunk-trayusesksni'sasync-iozbus executor with no tokio runtime (by design — seecrates/punktfunk-tray/Cargo.toml). Cargo unifies features across everything in onecargo build, so co-building the tray with the host would pull the host'sashpd → zbus/tokioonto the tray's sharedzbus, 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-trayinvocation keeps itszbuson async-io; the host package copies the resulting binary into its$out. (The deb/rpm/arch builds co-build the two in onecargo build, so they share this latent crash on Linux — a separate fix.) - The bun packages (
punktfunk-web,punktfunk-scripting) — theirbun installdeps hashes. Both build theirnode_modulesin a fixed-output derivation (bun installneeds the network + the read-public@unomnpm registry). EachoutputHash(inpackaging/nix/packages.nix) is pinned to a resolved dependency set and must be refreshed when its lockfile changes —web/bun.lockfor the console,sdk/bun.lockfor the runner: set thatoutputHash = lib.fakeHash, runnix build .#punktfunk-web(or.#punktfunk-scripting), and copy thegot: sha256-…value Nix prints back into the field. Everything downstream is offline (the console's codegen + vite build; the runner'sbun build --target=bunbundle), so only the deps FODs ever need network. Both launchers execpkgs.bunfrom 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). It is generated on first eval and checked in. - Session Skia OSD is off under Nix.
punktfunk-session's defaultuifeature draws its on-screen stats/console overlay withskia-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.
Verified
Both 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.