chore(nix): NixOS flake snapshot — host+client packages, module, devShell (WIP)

Local snapshot of the in-flight NixOS support: flake.nix + flake.lock + nix/
(crane host and client packages, services.punktfunk module, devShell).
Standalone — nothing in the Rust/Cargo tree references it. Held from push
pending its owning session's finalization (Skia-under-Nix follow-up + intended
per-workstream PR).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 12:41:55 +02:00
parent 532b313b8c
commit d466e3e2b2
5 changed files with 863 additions and 0 deletions
Generated
+64
View File
@@ -0,0 +1,64 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1784172867,
"narHash": "sha256-hzzeOgTP0MXMvg4djVwysTDvvUXIpsUCgsA0wPMF/Qk=",
"owner": "ipetkov",
"repo": "crane",
"rev": "8833b7dc3c7426ce1110ed6fed31b6f63d74f2dc",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784120854,
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784178955,
"narHash": "sha256-YtXHVFBzzqSidcEhPhfTcF2yzzu0p0yA7x07LOR+LaY=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "1785b85aeccca381caf8777133410f577b8f2f59",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+137
View File
@@ -0,0 +1,137 @@
{
description = "punktfunk low-latency desktop/game streaming host + native Linux client";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
crane,
rust-overlay,
}:
let
# Linux/x86_64 only — the host encodes with desktop NVENC and CI publishes no aarch64 leg
# (mirrors the RPM's `ExclusiveArch: x86_64`). Add a system here once there's an arm64 build.
systems = [ "x86_64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
# The workspace version is the single source of truth (crates/*/Cargo.toml inherit it).
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
pkgsFor = system: import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
# Pin cargo/rustc EXACTLY to rust-toolchain.toml (channel 1.96.0 + rustfmt/clippy) so a Nix
# build, a dev shell and CI all use the identical toolchain — the repo is deliberate about
# this (see rust-toolchain.toml's header on rustfmt format-drift).
toolchainFor = pkgs: pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
craneLibFor = pkgs: (crane.mkLib pkgs).overrideToolchain toolchainFor;
packagesFor = system:
let
pkgs = pkgsFor system;
in
pkgs.callPackage ./nix/packages.nix {
craneLib = craneLibFor pkgs;
src = self;
inherit version;
};
in
{
packages = forAllSystems (system:
let pf = packagesFor system;
in {
inherit (pf) punktfunk-host punktfunk-client;
default = pf.punktfunk-host;
});
# `nix run .#punktfunk-host -- serve` / `nix run .#punktfunk-client`.
apps = forAllSystems (system:
let pf = packagesFor system;
in {
punktfunk-host = {
type = "app";
program = "${pf.punktfunk-host}/bin/punktfunk-host";
};
punktfunk-client = {
type = "app";
program = "${pf.punktfunk-client}/bin/punktfunk-client";
};
default = self.apps.${system}.punktfunk-host;
});
# `nix flake check` builds both packages.
checks = forAllSystems (system:
let pf = packagesFor system;
in {
inherit (pf) punktfunk-host punktfunk-client;
});
# `nix develop` — the pinned toolchain plus every system lib the workspace links, wired so
# `cargo build` (all crates, host + client) works out of the box.
devShells = forAllSystems (system:
let
pkgs = pkgsFor system;
toolchain = toolchainFor pkgs;
gbm = pkgs.libgbm or pkgs.mesa;
in
{
default = pkgs.mkShell {
strictDeps = false;
nativeBuildInputs = [
toolchain
pkgs.rust-analyzer
pkgs.pkg-config
pkgs.cmake
pkgs.nasm
pkgs.perl
pkgs.rustPlatform.bindgenHook
];
buildInputs = [
# host
pkgs.ffmpeg
pkgs.pipewire
pkgs.libopus
pkgs.wayland
pkgs.libxkbcommon
pkgs.libGL
gbm
pkgs.vulkan-loader
# client
pkgs.sdl3
pkgs.gtk4
pkgs.libadwaita
pkgs.glib
pkgs.librsvg
pkgs.gsettings-desktop-schemas
pkgs.adwaita-icon-theme
pkgs.vulkan-headers
];
# pf-ffvk bindgen (no /usr/include on NixOS); GPU driver libs at runtime for `cargo run`.
PF_FFVK_VULKAN_INCLUDE = "${pkgs.vulkan-headers}/include";
# CMake ≥ 4 rejects the pre-3.5 minimums some vendored C libs (libopus) still declare.
CMAKE_POLICY_VERSION_MINIMUM = "3.5";
LD_LIBRARY_PATH = "/run/opengl-driver/lib:${pkgs.lib.makeLibraryPath [ pkgs.vulkan-loader pkgs.libGL gbm ]}";
};
});
formatter = forAllSystems (system: (pkgsFor system).nixfmt-rfc-style);
# NixOS integration — see nix/nixos-module.nix and nix/README.md.
# imports = [ punktfunk.nixosModules.default ];
# services.punktfunk.host.enable = true;
nixosModules.default = import ./nix/nixos-module.nix self;
nixosModules.punktfunk = self.nixosModules.default;
};
}
+197
View File
@@ -0,0 +1,197 @@
# 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.default` | = `punktfunk-host` |
| `nixosModules.default` | `services.punktfunk.host` / `services.punktfunk.client` |
| `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)
```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
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:
```sh
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`.
### 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`.
- **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`.
- **`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.
- **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 = 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 pick a backend in `settings`:
```nix
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
```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 -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](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.
- **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 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.
## 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.
+230
View File
@@ -0,0 +1,230 @@
# NixOS integration for punktfunk — the declarative equivalent of everything the RPM/deb do in
# their %install + %post (packaging/rpm/punktfunk.spec, packaging/debian/build-deb.sh):
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
# UDP socket-buffer sysctls, the firewall openers, and the `input`-group membership for virtual
# gamepads.
#
# Usage (flake):
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
# outputs = { punktfunk, nixpkgs, ... }: {
# nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
# modules = [ punktfunk.nixosModules.default
# { services.punktfunk.host.enable = true;
# services.punktfunk.host.users = [ "alice" ]; } ];
# };
# };
# }
#
# The host is fundamentally a per-user, in-graphical-session service (it drives the live
# compositor, PipeWire and the desktop portals), so it ships as a `systemd.user` unit. Enable it
# for a session with `systemctl --user enable --now punktfunk-host` (or set `autoStart = true` for a
# headless appliance with `users.users.<u>.linger = true`).
self:
{ config, lib, pkgs, ... }:
let
inherit (lib)
mkEnableOption mkOption mkIf mkMerge mkDefault types
optional optionals optionalString literalExpression
concatStringsSep mapAttrsToList genAttrs;
cfg = config.services.punktfunk;
system = pkgs.stdenv.hostPlatform.system;
# host.env rendering: booleans → 1/0 (what PUNKTFUNK_* knobs expect), everything else verbatim.
renderVal = v:
if lib.isBool v then (if v then "1" else "0")
else toString v;
renderEnv = attrs:
concatStringsSep "\n" (mapAttrsToList (k: v: "${k}=${renderVal v}") attrs) + "\n";
hostSettingsFile = pkgs.writeText "punktfunk-host.env" (renderEnv cfg.host.settings);
# Native punktfunk/1 ports (control plane + discovery + mgmt API). The media data plane is an
# ephemeral per-session UDP port the host hole-punches, so nothing fixed to open (see
# packaging/linux/punktfunk.ufw).
nativeTCP = [ 47990 ]; # mgmt/library REST API (HTTPS + mTLS)
nativeUDP = [ 9777 5353 ]; # QUIC control plane + mDNS
# GameStream/Moonlight-compat fixed ports (opt-in with `host.gamestream`).
gamestreamTCP = [ 47984 47989 48010 ];
gamestreamUDP = [ 47998 47999 48000 ];
in
{
options.services.punktfunk = {
host = {
enable = mkEnableOption "the punktfunk streaming host (systemd --user service + system wiring)";
package = mkOption {
type = types.package;
default = self.packages.${system}.punktfunk-host;
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-host";
description = "The punktfunk-host package (bundles punktfunk-host + punktfunk-tray).";
};
gamestream = mkOption {
type = types.bool;
default = true;
description = ''
Advertise the GameStream/Moonlight-compatible planes (`serve --gamestream`) so a stock
Moonlight client can pair. Set to `false` for a native-only, more secure host (no
plain-HTTP pairing / legacy GCM path) and drop the GameStream firewall ports.
'';
};
autoStart = mkOption {
type = types.bool;
default = false;
description = ''
Start the host automatically in every user's graphical session (adds it to the user
`default.target`). For a login-less appliance, also enable lingering for the host user
(`users.users.<name>.linger = true`) so the user service comes up at boot.
'';
};
users = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "alice" ];
description = ''
Users to add to the `input` group required for the virtual gamepads the host creates
(`/dev/uinput`, `/dev/uhid`, and the usbip/vhci virtual Steam Deck). The host runs as
these users' `systemd --user` service.
'';
};
settings = mkOption {
type = types.attrsOf (types.oneOf [ types.str types.int types.bool ]);
default = { };
example = literalExpression ''
{
PUNKTFUNK_VIDEO_SOURCE = "virtual";
PUNKTFUNK_COMPOSITOR = "kwin";
PUNKTFUNK_444 = true;
RUST_LOG = "info";
}
'';
description = ''
`host.env` key/value pairs passed to the service via `EnvironmentFile`. See
`''${package}/share/punktfunk-host/host.env.example` for the full surface. Booleans render
as `1`/`0`. Leave empty to rely on the host's per-connect auto-detection of the
compositor + input backend. Do NOT put secrets here (world-readable in the store) use
`environmentFile` instead.
'';
};
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
example = "/run/secrets/punktfunk-host.env";
description = ''
Extra `EnvironmentFile` layered AFTER `settings` (its values win). For secrets such as
`PUNKTFUNK_MGMT_TOKEN`. Loaded optionally (a missing file does not fail the unit).
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Open the host's inbound ports. Native punktfunk/1 always: UDP 9777 (QUIC) + 5353 (mDNS),
TCP 47990 (mgmt API). With `gamestream = true` also TCP 47984/47989/48010 and UDP
47998/47999/48000. The ephemeral media UDP port is hole-punched, so a default-deny
firewall still streams (it just adds ~2.5 s at session start).
'';
};
};
client = {
enable = mkEnableOption "the native punktfunk Linux client";
package = mkOption {
type = types.package;
default = self.packages.${system}.punktfunk-client;
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-client";
description = "The punktfunk-client package (bundles punktfunk-client + punktfunk-session).";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Open UDP 5353 (mDNS) so the client can auto-discover hosts on the LAN.";
};
};
};
config = mkMerge [
# --- shared: whenever either half is enabled -----------------------------------------------
(mkIf (cfg.host.enable || cfg.client.enable) {
assertions = [{
assertion = system == "x86_64-linux";
message = "services.punktfunk is x86_64-linux only (desktop NVENC host; no aarch64 build).";
}];
# 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;
# 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 = {
"net.core.wmem_max" = mkDefault 33554432;
"net.core.rmem_max" = mkDefault 33554432;
};
})
# --- host ----------------------------------------------------------------------------------
(mkIf cfg.host.enable {
environment.systemPackages = [ cfg.host.package ];
# 60-punktfunk.rules: /dev/uinput + /dev/uhid group access + the vhci sysfs perms.
services.udev.packages = [ cfg.host.package ];
# The vhci attach/detach rule shells out to chgrp/chmod (coreutils) — put them on udev's PATH.
services.udev.path = [ pkgs.coreutils ];
# uinput/uhid: the virtual X360 + DualSense nodes. vhci-hcd: the usbip transport that makes
# the virtual Steam Deck a real USB device (Steam Input only adopts USB pads).
boot.kernelModules = [ "uinput" "uhid" "vhci-hcd" ];
# `input` group membership for the virtual-gamepad nodes (mirrors the RPM's usermod hint).
users.groups.input = { };
users.users = genAttrs cfg.host.users (_: { extraGroups = [ "input" ]; });
# Status-tray autostart entry (self-gating: `--autostart` exits unless this user runs a host).
environment.etc."xdg/autostart/io.unom.Punktfunk.Tray.desktop".source =
"${cfg.host.package}/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop";
networking.firewall = mkIf cfg.host.openFirewall {
allowedTCPPorts = nativeTCP ++ optionals cfg.host.gamestream gamestreamTCP;
allowedUDPPorts = nativeUDP ++ optionals cfg.host.gamestream gamestreamUDP;
};
systemd.user.services.punktfunk-host = {
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" ];
wants = [ "pipewire.service" ];
wantedBy = optional cfg.host.autoStart "default.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 = [ pkgs.bash pkgs.coreutils pkgs.pipewire ];
serviceConfig = {
ExecStart = "${cfg.host.package}/bin/punktfunk-host serve"
+ optionalString cfg.host.gamestream " --gamestream";
Restart = "on-failure";
RestartSec = 2;
EnvironmentFile =
(optional (cfg.host.settings != { }) "${hostSettingsFile}")
++ (optional (cfg.host.environmentFile != null) "-${toString cfg.host.environmentFile}");
};
};
})
# --- client --------------------------------------------------------------------------------
(mkIf cfg.client.enable {
environment.systemPackages = [ cfg.client.package ];
# 70-punktfunk-client.rules: hidraw access for the seated user's DualSense (SDL HIDAPI). The
# rule is uaccess-tagged, so the active-seat user gets it with no group membership.
services.udev.packages = [ cfg.client.package ];
networking.firewall = mkIf cfg.client.openFirewall {
allowedUDPPorts = [ 5353 ];
};
})
];
}
+235
View File
@@ -0,0 +1,235 @@
# punktfunk Nix packages — built with crane (github:ipetkov/crane).
#
# Two derivations, mirroring the RPM's `punktfunk` / `punktfunk-client` split (packaging/rpm/
# punktfunk.spec) so a headless streaming host never drags in the GTK client closure:
#
# * punktfunk-host — the streaming host + status tray (punktfunk-host, punktfunk-tray).
# Built with the same feature set CI ships: the direct-SDK NVENC path
# (`nvenc`) + the raw Vulkan-Video HEVC encoder (`vulkan-encode`). Both are
# AMD/Intel-safe (NVENC/CUDA entry points are dlopen'd at RUNTIME, never
# link-imported), so ONE binary runs on NVIDIA, AMD and Intel.
# * punktfunk-client — the native Linux client: the GTK4/libadwaita shell (punktfunk-client) plus
# the Vulkan/ash session streamer it execs (punktfunk-session).
#
# Why crane and not rustPlatform.buildRustPackage:
# * The lockfile carries `windows 0.62.2` (and friends) from BOTH crates.io AND a pinned
# microsoft/windows-rs git rev — the Windows client uses the git one. importCargoLock keys its
# vendor dir by `name-version`, so those same-name-same-version-different-source entries collide;
# crane vendors per-source and handles it. (Those crates are `cfg(windows)`-gated and never
# COMPILE on Linux — they only need to be vendored to satisfy the lock.)
# * crane fetches git deps with `builtins.fetchGit` (the rev is a full sha ⇒ pure-eval-safe), so
# no hand-maintained `outputHashes` for the windows-rs checkout.
#
# The whole workspace is built from real source (no crane dep-only "dummy" pre-build): pyrowave-sys
# runs CMake over its committed `vendor/pyrowave` tree in build.rs, which a dummy src would omit —
# so `cargoArtifacts = null` keeps deps + crate in one derivation.
{
lib,
craneLib,
src,
version,
# native tooling
pkg-config,
cmake,
nasm,
perl,
rustPlatform,
addDriverRunpath,
wrapGAppsHook4,
# host + client shared libs
ffmpeg,
pipewire,
libopus,
wayland,
libxkbcommon,
libGL,
vulkan-loader,
# gbm lives in `libgbm` on recent nixpkgs, `mesa` on older ones.
libgbm ? null,
mesa,
# client-only libs
sdl3,
gtk4,
libadwaita,
glib,
librsvg,
gsettings-desktop-schemas,
adwaita-icon-theme,
vulkan-headers,
}:
let
gbm = if libgbm != null then libgbm else mesa;
# Build provenance stamped into the binary (build.rs reads PUNKTFUNK_BUILD_VERSION for
# `--version` / mgmt /health), mirroring the deb/rpm `~ci`-style stamp.
buildVersion = "${version}-nix";
# `cargo build --release --locked` scoped to the crates each package ships. crane appends this to
# both the vendor step and the compile.
commonArgs = {
inherit src version;
strictDeps = true;
# nixpkgs ships CMake ≥ 4, which errors on `cmake_minimum_required(VERSION <3.5)`. Several
# vendored C libraries built through the `cmake` crate still declare a pre-3.5 minimum
# (audiopus_sys' libopus; belt-and-braces for pyrowave-sys / aws-lc-sys). CMake reads this env
# var as the floor policy version, letting those configure instead of aborting.
CMAKE_POLICY_VERSION_MINIMUM = "3.5";
# crane's default is a two-derivation split (buildDepsOnly → buildPackage); disable it, because
# the deps-only pass builds a "dummy" source tree that drops pyrowave-sys's `vendor/pyrowave`
# CMake inputs and would fail its build.rs. One derivation compiles everything from real src.
cargoArtifacts = null;
# The workspace has hardware-bound tests (uinput, a real GPU); packaging builds never run them,
# exactly like the deb/rpm/PKGBUILD paths.
doCheck = false;
nativeBuildInputs = [
pkg-config
cmake # pyrowave-sys (C++/Vulkan), the vendored libopus (opus crate), aws-lc-sys (rustls)
nasm # libopus SIMD + OpenH264 (openh264 `source` feature)
perl # aws-lc-sys asm generation (rustls' aws-lc-rs crypto provider)
rustPlatform.bindgenHook # LIBCLANG_PATH + clang args for ffmpeg-sys-next / pf-ffvk / pyrowave-sys bindgen
addDriverRunpath # provides the `addDriverRunpath` shell fn used in postFixup
];
};
meta = {
homepage = "https://git.unom.io/unom/punktfunk";
license = with lib.licenses; [ mit asl20 ];
platforms = [ "x86_64-linux" ]; # NVENC desktop host; matches the RPM's ExclusiveArch: x86_64
maintainers = [ ];
};
in
{
punktfunk-host = craneLib.buildPackage (commonArgs // {
pname = "punktfunk-host";
cargoExtraArgs =
"--locked -p punktfunk-host -p punktfunk-tray "
+ "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode";
PUNKTFUNK_BUILD_VERSION = buildVersion;
buildInputs = [
ffmpeg # libavcodec/avformat/avutil (NVENC + VAAPI encode), via ffmpeg-next → ffmpeg-sys-next pkg-config
pipewire # libpipewire-0.3 + libspa-0.2 (portal capture + the `pipewire` crate)
libopus # audiopus_sys links system opus via pkg-config (else it vendors a static libopus that mis-links)
wayland # libwayland-client (wlr / KWin fake_input backends)
libxkbcommon # virtual-keyboard keymap build/validate
libGL # src/linux/zerocopy/egl.rs `#[link(name = "GL")]`
gbm # src/linux/zerocopy/egl.rs `#[link(name = "gbm")]`
vulkan-loader # ash (Vulkan-Video encode + the dmabuf→VK bridge); loaded at runtime
];
# KWin resolves a streaming client to a .desktop by matching /proc/<pid>/exe against `Exec`, so
# the host's authorization .desktop must name the ACTUAL binary path (the store path here). The
# tray/client .desktop Exec lines are rewritten for the same reason (menu launch / autostart).
postInstall = ''
# udev: /dev/uinput + /dev/uhid (virtual gamepads) + the vhci sysfs perms for the virtual Deck.
install -Dm0644 scripts/60-punktfunk.rules "$out/lib/udev/rules.d/60-punktfunk.rules"
# KWin Desktop-mode authorization (zkde_screencast + fake_input). Point Exec at the store binary.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Host.desktop \
"$out/share/applications/io.unom.Punktfunk.Host.desktop"
substituteInPlace "$out/share/applications/io.unom.Punktfunk.Host.desktop" \
--replace-fail "/usr/bin/punktfunk-host" "$out/bin/punktfunk-host"
# Status-tray autostart entry + its hicolor status icons.
install -Dm0644 packaging/linux/io.unom.Punktfunk.Tray.desktop \
"$out/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop"
substituteInPlace "$out/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop" \
--replace-fail "/usr/bin/punktfunk-tray" "$out/bin/punktfunk-tray"
for sz in 22x22 48x48; do
for png in packaging/linux/icons/hicolor/$sz/apps/*.png; do
install -Dm0644 "$png" "$out/share/icons/hicolor/$sz/apps/$(basename "$png")"
done
done
# Reference material: headless session helpers, example env files, the OpenAPI doc.
install -Dm0755 scripts/headless/run-headless-kde.sh "$out/share/punktfunk-host/headless/run-headless-kde.sh"
install -Dm0755 scripts/headless/run-headless-sway.sh "$out/share/punktfunk-host/headless/run-headless-sway.sh"
install -Dm0644 scripts/headless/kde-authorized "$out/share/punktfunk-host/headless/kde-authorized"
install -Dm0644 scripts/headless/punktfunk-sink.conf "$out/share/punktfunk-host/headless/punktfunk-sink.conf"
install -Dm0644 scripts/host.env.example "$out/share/punktfunk-host/host.env.example"
install -Dm0644 packaging/bazzite/host.env "$out/share/punktfunk-host/host.env.bazzite"
install -Dm0644 packaging/kde/host.env "$out/share/punktfunk-host/host.env.kde"
install -Dm0644 api/openapi.json "$out/share/punktfunk-host/openapi.json"
'';
# Run AFTER fixup (patchelf --shrink-rpath would otherwise drop /run/opengl-driver/lib, which is
# empty at build time): append the driver runpath so the runtime dlopen of libcuda.so.1 /
# libnvidia-encode.so.1 / libEGL.so.1 / the GPU's libvulkan ICD resolves from the running system.
postFixup = ''
addDriverRunpath "$out/bin/punktfunk-host" "$out/bin/punktfunk-tray"
'';
meta = meta // {
description = "Low-latency desktop/game streaming host (Moonlight-compatible + native punktfunk/1)";
mainProgram = "punktfunk-host";
};
});
punktfunk-client = craneLib.buildPackage (commonArgs // {
pname = "punktfunk-client";
# The session's default `ui` feature pulls skia-safe, whose build DOWNLOADS a prebuilt Skia
# (rust-skia releases) — impossible in Nix's network-less build sandbox, and a from-source Skia
# build needs the full gn/ninja/python toolchain + network-fetched third-party. The `ui` feature
# is explicitly droppable (clients/session/Cargo.toml: "same streaming, stats on stdout only"),
# so build the session without it. The GTK shell (punktfunk-client-linux) is skia-free and full.
# Re-adding the Skia OSD under Nix is tracked in nix/README.md.
cargoExtraArgs =
"--locked -p punktfunk-client-linux -p punktfunk-client-session "
+ "--no-default-features --features punktfunk-client-session/pyrowave";
# pf-ffvk runs bindgen over libavutil/hwcontext_vulkan.h, which `#include <vulkan/vulkan.h>`.
# There is no /usr/include on NixOS, so hand it the Vulkan-Headers include dir explicitly
# (build.rs turns this into a clang `-I`). libavutil's own include path comes from pkg-config.
PF_FFVK_VULKAN_INCLUDE = "${vulkan-headers}/include";
nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ wrapGAppsHook4 ];
buildInputs = [
ffmpeg # FFmpeg decode (pf-client-core) + pf-ffvk links libavutil
pipewire # PipeWire audio playback + mic capture
libopus # audiopus_sys → system opus via pkg-config (Opus decode)
sdl3 # window + gamepads (SDL3 HIDAPI: DualSense touchpad/motion/triggers)
gtk4 # the GTK4 shell (clients/linux, relm4)
libadwaita
glib
librsvg # gdk-pixbuf SVG loader for symbolic icons
gsettings-desktop-schemas
adwaita-icon-theme
libxkbcommon
libGL
vulkan-loader # ash presenter + Vulkan-Video decode (loaded at runtime)
];
# wrapGApp needs to run on the real ELF, and addDriverRunpath must run post-shrink — so disable
# the hook's blanket auto-wrap and drive both by hand in postFixup (see the host note above).
dontWrapGApps = true;
postInstall = ''
# hidraw access for the seated user's DualSense (SDL HIDAPI full-fidelity path).
install -Dm0644 scripts/70-punktfunk-client.rules "$out/lib/udev/rules.d/70-punktfunk-client.rules"
# Desktop launcher point Exec at the store binary (wrapped below).
install -Dm0644 packaging/linux/io.unom.Punktfunk.desktop \
"$out/share/applications/io.unom.Punktfunk.desktop"
substituteInPlace "$out/share/applications/io.unom.Punktfunk.desktop" \
--replace-fail "Exec=punktfunk-client" "Exec=$out/bin/punktfunk-client"
'';
postFixup = ''
addDriverRunpath "$out/bin/punktfunk-client" "$out/bin/punktfunk-session"
# Only the GTK shell needs the GApps wrapper (GSETTINGS_SCHEMA_DIR, icon themes, typelibs);
# the ash session binary is not a GTK app.
wrapGApp "$out/bin/punktfunk-client"
'';
meta = meta // {
description = "Native Linux punktfunk/1 streaming client (GTK4 shell + Vulkan session)";
mainProgram = "punktfunk-client";
};
});
}