diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..a2edc58b --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..645820aa --- /dev/null +++ b/flake.nix @@ -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; + }; +} diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 00000000..f3e08b58 --- /dev/null +++ b/nix/README.md @@ -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. diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix new file mode 100644 index 00000000..e5b6bce5 --- /dev/null +++ b/nix/nixos-module.nix @@ -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..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..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 ]; + }; + }) + ]; +} diff --git a/nix/packages.nix b/nix/packages.nix new file mode 100644 index 00000000..5739a9bc --- /dev/null +++ b/nix/packages.nix @@ -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//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 `. + # 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"; + }; + }); +}