feat(nix): actually check the NixOS module in CI, and close the sweep's open issues

THE CI GAP. `nix flake check` does not check `nixosModules`. It forces the value
and asserts it is a lambda taking an open attribute set — nothing more; nix's own
source carries `// FIXME: if we have a 'nixpkgs' input, use it to check the
module.` Measured: a flake whose module sets a nonexistent OPTION, references a
nonexistent `pkgs` attribute AND calls a nonexistent `lib` function passes clean,
printing `checking NixOS module 'nixosModules.default'... all checks passed!`.
nix.yml's header claimed that leg covered the module; it never did, for the
module's whole life — on a flake whose history is Nix regressions reaching main
invisibly.

Closed with `checks.<system>.nixos-module` (packaging/nix/module-check.nix): it
evaluates the module against real nixpkgs in four scenarios (desktop, appliance,
native-only, client-only) and asserts on the rendered systemd units. The
assertions are PURE NIX so instantiating the check runs them — which means the
eval-only `--no-build` leg CI already runs is sufficient, and no Rust is built.
Stub fake-derivation packages keep it independent of punktfunk-host/-client and
the from-source gamescope; crane and bun2nix are provably not needed (they are
`throw`s in the wiring test and it still instantiates).

17 checks, including regression guards for every divergence the sweep found and
for the KWin identification trap (host ExecStart must stay on the plain store
path, never a capability wrapper, while the encode worker points AT the wrapper).
Mutation-tested: 8 mutants, each re-introducing one real defect, all 8 rejected,
baseline green. The suite already earned it once — its first run failed a correct
module because systemd renders `After=` as one space-separated line, so those
assertions now read the evaluated lists instead of the text.

Also closed from the sweep:

  * services.punktfunk.host.desktopSession (new, default false) — binds the host
    to graphical-session.target, the declarative form of the
    punktfunk-host-desktop-session.conf drop-in. Without it a Plasma/GNOME
    restart leaves the host holding a Wayland socket and portal D-Bus connection
    that died with the old compositor: it still listens, still answers, and every
    session it then serves fails at capture. Off by default because an appliance
    may never reach that target and would be left permanently stopped.

  * scripting.autoStart now defaults ON, matching the deb postinst and RPM %post,
    which both `systemctl --global enable` the runner. It was opt-in here on the
    reasoning that the runner is inert until you add automation — which stopped
    being true when the game-library scanners became plugins. A NixOS host came up
    with an empty library and no obvious reason why. The module and README carried
    the superseded rationale verbatim; both updated.

  * A warning when the host is enabled and xdg.portal is not. A warning rather
    than `xdg.portal.enable = mkDefault true`, because enabling the portal service
    with no `extraPortals` backend is its own broken state and only the operator
    knows which backend their compositor needs.

  * punktfunk-gamescope gets a `build-gamescope` dispatch input. It is on the
    critical path of every host build (`gamescopeHdr` defaults true) yet nothing
    compiled it; it tracks nixpkgs' gamescope, so a flake.lock bump — not a change
    of ours — is what breaks it, and the first to find out would be an operator
    whose system rebuild fails.

All .nix files reformatted with the flake's own declared formatter
(nixfmt-rfc-style from the PINNED nixpkgs, not a channel's).
This commit is contained in:
2026-08-10 19:57:10 +02:00
parent 159bbdbfc2
commit f8cde0adaf
5 changed files with 432 additions and 23 deletions
+38 -4
View File
@@ -7,10 +7,24 @@
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and
# the NixOS module without building them. Catches the failures that actually happen to
# this flake — a renamed file, a callPackage argument that no longer exists, a syntax
# error, a package attribute dropped from packages.nix.
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
# without building them. Catches the failures that actually happen to this flake — a
# renamed file, a callPackage argument that no longer exists, a syntax error, a package
# attribute dropped from packages.nix.
#
# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles
# `nixosModules` by forcing the value and asserting it is a lambda taking an open
# attribute set — nothing more (nix's own source: `// 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 ... all checks passed!`. This
# header used to claim the module was covered here; it was not, for the module's whole
# life. It is covered NOW because `checks.<system>.nixos-module`
# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the
# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING
# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in
# the derivation body would only run under a full `nix flake check`, which builds the
# hour-long Rust packages.
# * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations
# whose inputs churn constantly (every dependency bump moves a lockfile) and they cost
# minutes, not hours, because neither compiles Rust. This is the end-to-end proof that
@@ -22,6 +36,12 @@
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
#
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
# ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's
@@ -66,6 +86,10 @@ on:
description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)"
type: boolean
default: false
build-gamescope:
description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)"
type: boolean
default: false
jobs:
flake:
@@ -165,3 +189,13 @@ jobs:
if: ${{ github.event.inputs.build-rust == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
# The patched compositor. Separate from build-rust because its failure mode is different: it
# tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather
# than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
# something actually builds it.
- name: Build the patched gamescope (dispatch opt-in)
if: ${{ github.event.inputs.build-gamescope == 'true' }}
run: |
"$NIX" build --print-build-logs .#punktfunk-gamescope
+12
View File
@@ -126,6 +126,7 @@
system:
let
pf = packagesFor system;
pkgs = pkgsFor system;
in
{
inherit (pf)
@@ -134,6 +135,17 @@
punktfunk-web
punktfunk-scripting
;
# The NixOS module, actually evaluated. `nix flake check` does NOT do this for
# `nixosModules` — it only forces the value and asserts it is a lambda taking an open
# attribute set, so a module with a nonexistent option, a nonexistent `pkgs` attribute
# and a nonexistent `lib` function passes clean (measured). Routing the module through a
# `checks` entry instead means the eval-only CI leg has to instantiate it, and every
# assertion in module-check.nix is pure Nix so instantiation is enough to run them.
nixos-module = pkgs.callPackage ./packaging/nix/module-check.nix {
inherit nixpkgs system;
module = import ./packaging/nix/nixos-module.nix;
};
}
);
+38 -5
View File
@@ -21,6 +21,7 @@ and the native Linux **client**, a **NixOS module** that wires up everything the
| `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
@@ -99,12 +100,33 @@ systemctl --user enable --now punktfunk-host
| `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` 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. |
**`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**,
@@ -125,20 +147,31 @@ with `journalctl --user -u punktfunk-web-init` (or `~/.config/punktfunk/web-pass
`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**):
`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` | `false` | Add the unit to `default.target`. Off even on an auto-start host — running operator scripts/plugins is a deliberate opt-in. |
| `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's inert until you add automation, so the unit ships
un-started; turn it on with `systemctl --user enable --now punktfunk-scripting`.
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
+263
View File
@@ -0,0 +1,263 @@
# Does the NixOS module actually evaluate, and does it still render the units we decided on?
#
# WHY THIS FILE EXISTS. `nix flake check` does NOT check `nixosModules`. It forces the value and
# asserts it is a lambda taking an open attribute set — nothing more; nix's own source carries
# `// FIXME: if we have a 'nixpkgs' input, use it to check the module.` MEASURED: a flake whose
# `nixosModules.default` sets a nonexistent OPTION, references a nonexistent `pkgs` attribute AND
# calls a nonexistent `lib` function passes clean, printing the thoroughly reassuring
#
# checking NixOS module 'nixosModules.default'... all checks passed!
#
# So every option name, type, `pkgs.*` reference and systemd directive in nixos-module.nix was
# unverified by CI while reading as covered — on a flake whose own history is Nix regressions
# reaching main invisibly (`nix build .#punktfunk-web` was broken for 553 commits).
#
# HOW IT CLOSES THAT. Exposed as a flake `checks` output, so `nix flake check --no-build` — the leg
# CI already runs — must INSTANTIATE it, and instantiating forces the `assert` below. Every
# assertion is therefore pure Nix, evaluated at instantiation: a shell script inside the derivation
# would only run under a full `nix flake check`, which builds the hour-long Rust packages and is
# exactly what CI cannot afford. Keep it that way — if you add a check, add it to `results`, not to
# a `runCommand` body.
#
# STUB PACKAGES, on purpose. The real derivations would drag punktfunk-host, punktfunk-client and
# (via `gamescopeHdr`) a from-source gamescope into this check's closure, making the cheap leg
# expensive and coupling a module regression to a Rust build. What is under test here is the MODULE.
# ⚠ The stubs must be fake DERIVATIONS, not store-path strings: `types.package` accepts anything
# `isDerivation` as-is, but runs a store-path string through `builtins.storePath`, which demands the
# path actually exist and fails eval with "no substituter can build it".
{
lib,
runCommand,
# The nixpkgs SOURCE. Interpolated rather than `nixpkgs + "/..."` because this is called with the
# flake INPUT, which is an attribute set (coerced through its `outPath`) and not a path — the
# difference only shows up as "expected a set but found a string" from whichever side is wrong.
nixpkgs,
system,
module,
}:
let
fakeDrv = name: {
type = "derivation";
inherit name;
outPath = "/pf-stub/${name}";
outputs = [ "out" ];
};
stubSelf = {
packages.${system} = lib.genAttrs [
"punktfunk-host"
"punktfunk-client"
"punktfunk-web"
"punktfunk-scripting"
"punktfunk-gamescope"
] fakeDrv;
};
# A machine just complete enough for eval-config, plus the scenario under test.
evalWith =
scenario:
(import "${nixpkgs}/nixos/lib/eval-config.nix" {
inherit system;
modules = [
(module stubSelf)
{
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "/dev/sda1";
fsType = "ext4";
};
system.stateVersion = "24.11";
nixpkgs.hostPlatform = system;
# `host.users` adds group membership to an existing user; declare one so NixOS's own
# "isNormalUser or isSystemUser" assertion is not what this check trips over.
users.users.alice.isNormalUser = true;
}
scenario
];
}).config;
# Every scenario keeps `gamescopeHdr = false`: it is the one option whose default would pull a
# real (stub, here) gamescope onto the unit PATH, and nothing below is about that.
desktop = evalWith {
services.punktfunk.host = {
enable = true;
users = [ "alice" ];
gamescopeHdr = false;
desktopSession = true;
};
};
appliance = evalWith {
services.punktfunk.host = {
enable = true;
autoStart = true;
openFirewall = true;
gamescopeHdr = false;
};
};
nativeOnly = evalWith {
services.punktfunk.host = {
enable = true;
openFirewall = true;
gamestream = false;
gamescopeHdr = false;
};
};
clientOnly = evalWith { services.punktfunk.client.enable = true; };
unit = cfg: name: cfg.systemd.user.units."${name}.service".text;
has =
cfg: name: infix:
lib.hasInfix infix (unit cfg name);
# A module's own failed assertions, as messages.
failedAssertions = cfg: map (a: a.message) (lib.filter (a: !a.assertion) cfg.assertions);
results = [
# --- the module evaluates at all, in every shape an operator can ask for -------------------
{
name = "desktop scenario has no failing assertions";
ok = failedAssertions desktop == [ ];
}
{
name = "appliance scenario has no failing assertions";
ok = failedAssertions appliance == [ ];
}
{
name = "client-only scenario has no failing assertions";
ok = failedAssertions clientOnly == [ ];
}
# --- the KWin identification trap (packaging/arch/punktfunk-host.install) -------------------
# The host MUST exec the plain store path. A capability wrapper here would put CAP_SYS_NICE in
# the process's permitted set, and the kernel then refuses KWin the /proc/<pid>/exe readlink it
# identifies the client by — which cost every KDE box its desktop streaming in 0.26.0-1.
{
name = "host ExecStart is the store binary, never a capability wrapper";
ok =
has desktop "punktfunk-host" "ExecStart=/pf-stub/punktfunk-host/bin/punktfunk-host serve"
&& !(has desktop "punktfunk-host" "ExecStart=/run/wrappers");
}
# ...while the ENCODE WORKER, which nothing ever has to identify, is pointed at the wrapper.
{
name = "host points PUNKTFUNK_ENCODE_WORKER at the capability wrapper";
ok =
has desktop "punktfunk-host"
"PUNKTFUNK_ENCODE_WORKER=/run/wrappers/bin/punktfunk-encode-worker";
}
{
name = "the encode-worker wrapper carries exactly cap_sys_nice=ep";
ok = desktop.security.wrappers.punktfunk-encode-worker.capabilities == "cap_sys_nice=ep";
}
# --- the desktop-login route (scripts/punktfunk-host-desktop-session.conf) -----------------
# Asserted on the evaluated LISTS, not the rendered text: systemd renders `After=` as one
# space-separated line, so `hasInfix "After=graphical-session.target"` silently depends on
# ordering — it failed against a correct module the first time this check ran.
{
name = "desktopSession binds the host to graphical-session.target";
ok =
let
u = desktop.systemd.user.services.punktfunk-host;
in
lib.elem "graphical-session.target" u.after
&& lib.elem "graphical-session.target" u.partOf
# IN ADDITION to default.target, never instead of it.
&& lib.elem "graphical-session.target" u.wantedBy;
}
{
name = "desktopSession is NOT applied to the appliance route (it would stay stopped there)";
ok =
let
u = appliance.systemd.user.services.punktfunk-host;
in
!(lib.elem "graphical-session.target" u.partOf) && lib.elem "default.target" u.wantedBy;
}
# --- GameStream opt-out reaches both the argv and the firewall -----------------------------
{
name = "gamestream=true passes --gamestream";
ok = has desktop "punktfunk-host" "serve --gamestream";
}
{
name = "gamestream=false drops --gamestream and its firewall ports";
ok =
!(has nativeOnly "punktfunk-host" "--gamestream")
&& !(lib.elem 47984 nativeOnly.networking.firewall.allowedTCPPorts)
&& lib.elem 47990 nativeOnly.networking.firewall.allowedTCPPorts;
}
{
name = "openFirewall opens the console AND its plugin origin";
ok =
lib.elem 47992 appliance.networking.firewall.allowedTCPPorts
&& lib.elem 47993 appliance.networking.firewall.allowedTCPPorts;
}
# --- the three divergences from the shipped units, as regression guards --------------------
# Each of these was ONCE wrong here while right in scripts/*.service. Assert the decision, so a
# future edit cannot quietly drift back.
{
# Without this, systemd's default 5-starts-per-10s against RestartSec=2 gives up permanently
# after ~10 s — the exact window before the host's first `serve` writes the mgmt token.
name = "web console retries indefinitely while the host writes its mgmt token";
ok = has appliance "punktfunk-web" "StartLimitIntervalSec=0";
}
{
# A console that exits 0 has still stopped serving.
name = "web console restarts on ANY exit, not just failure";
ok =
has appliance "punktfunk-web" "Restart=always"
&& !(has appliance "punktfunk-web" "Restart=on-failure");
}
{
# The one unit here that runs arbitrary operator TypeScript by design.
name = "the plugin runner is sandboxed like the deb/rpm unit";
ok =
has appliance "punktfunk-scripting" "NoNewPrivileges=true"
&& has appliance "punktfunk-scripting" "ProtectSystem=strict"
&& has appliance "punktfunk-scripting" "ReadWritePaths=%h"
&& has appliance "punktfunk-scripting" "ReadWritePaths=/tmp"
&& has appliance "punktfunk-scripting" "RestrictAddressFamilies=AF_UNIX";
}
{
# PrivateTmp is OFF on purpose (the VirtualHere field report: a private /tmp hides
# /tmp/vhclient and /tmp/.X11-unix, so a plugin cannot reach the daemon it integrates with).
name = "the plugin runner keeps the real /tmp";
ok = has appliance "punktfunk-scripting" "PrivateTmp=false";
}
{
# Since the library scanners became plugins, a runner that is off means an EMPTY LIBRARY and
# no obvious reason why — which is why deb+rpm `systemctl --global enable` it.
name = "the plugin runner is started by default, like every other channel";
ok = has appliance "punktfunk-scripting" "WantedBy=default.target";
}
# --- the client half must not drag the host's system wiring in -----------------------------
{
name = "a client-only machine defines no host/web/scripting units";
ok =
!(clientOnly.systemd.user.services ? punktfunk-host)
&& !(clientOnly.systemd.user.services ? punktfunk-web)
&& !(clientOnly.systemd.user.services ? punktfunk-scripting);
}
];
failures = map (r: r.name) (lib.filter (r: !r.ok) results);
in
# The `assert` is what makes `--no-build` sufficient: instantiating this derivation forces it.
assert
failures == [ ]
|| throw ''
The punktfunk NixOS module no longer renders what packaging/nix/module-check.nix requires.
Failing checks (${toString (lib.length failures)} of ${toString (lib.length results)}):
- ${lib.concatStringsSep "\n - " failures}
'';
runCommand "punktfunk-nixos-module-check"
{
# Recorded in the output so a green run says what it actually covered.
passed = toString (lib.length results);
}
''
echo "punktfunk NixOS module: $passed checks passed at eval time" > "$out"
''
+81 -14
View File
@@ -3,8 +3,8 @@
# the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB
# UDP socket-buffer sysctls, the firewall openers, the `input`- and `punktfunk`-group membership
# for virtual gamepads, the management web console (`services.punktfunk.web`, on by default with
# the host — the RPM/deb Recommends), and the opt-in plugin/script runner
# (`services.punktfunk.scripting`).
# the host — the RPM/deb Recommends), and the plugin/script runner
# (`services.punktfunk.scripting`, likewise on by default — the game-library scanners are plugins).
#
# Usage (flake):
# { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
@@ -107,6 +107,34 @@ in
'';
};
desktopSession = mkOption {
type = types.bool;
default = false;
description = ''
Bind the host to the DESKTOP LOGIN session's lifetime
(`PartOf=`/`WantedBy=graphical-session.target`), so a Plasma/GNOME restart restarts the
host with it.
Turn this on for a machine somebody logs into. Without it, when the compositor restarts
(a crash, a logout/login, "restart the shell") the host keeps running while holding 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. A host that idles for
days between sessions is exactly the shape that gets discovered at the worst moment.
Leave it OFF for an appliance a pinned `PUNKTFUNK_COMPOSITOR`, a headless KWin or a
gamescope box. Those start their own compositor and may never reach
`graphical-session.target` at all, and this would leave the host permanently stopped.
No effect under sway/Hyprland or any session not managed by systemd (they never reach
that target either): there, start the host from the compositor's own config, after
`systemctl --user import-environment`, so it dies and comes back with the session.
This is the declarative equivalent of the `scripts/punktfunk-host-desktop-session.conf`
drop-in the deb/rpm document for the same route.
'';
};
users = mkOption {
type = types.listOf types.str;
default = [ ];
@@ -257,9 +285,9 @@ in
};
# The plugin/script runner — host automation on bun. Ships with the host (the RPM/deb Recommends
# it), but running it is OPT-IN: the `systemd --user` unit is defined yet NOT added to
# `default.target`, because the runner is inert until you add scripts/plugins. Turn it on with
# `systemctl --user enable --now punktfunk-scripting`.
# it) and, like them, runs by default: the game-library scanners are plugins, so a host with the
# runner off has an empty library. Opt out with `scripting.autoStart = false` or, per user,
# `systemctl --user mask punktfunk-scripting`.
scripting = {
enable = mkOption {
type = types.bool;
@@ -267,9 +295,10 @@ in
defaultText = literalExpression "config.services.punktfunk.host.enable";
description = ''
Install the plugin/script runner and define its `systemd --user` unit
(`punktfunk-scripting`). Enabled by default whenever the host is but the unit is not
auto-started (see `autoStart`), since the runner does nothing until you add scripts to
`~/.config/punktfunk/scripts` or install `punktfunk-plugin-*` packages under
(`punktfunk-scripting`). Enabled by default whenever the host is, and started by default
too (see `autoStart`) the game-library scanners are plugins, so a host without the
runner has an empty library. It also runs whatever you put in
`~/.config/punktfunk/scripts` or install as `punktfunk-plugin-*` under
`~/.config/punktfunk/plugins`. A plugin auto-wires to the host's mgmt token + identity cert.
'';
};
@@ -283,11 +312,21 @@ in
autoStart = mkOption {
type = types.bool;
default = false;
default = cfg.scripting.enable;
defaultText = literalExpression "config.services.punktfunk.scripting.enable";
description = ''
Start the runner automatically in every user's graphical session (adds it to the user
`default.target`). Off by default even when the host auto-starts running arbitrary
operator scripts/plugins is a deliberate opt-in; enable it once you have automation to run.
`default.target`).
ON by default, matching every other channel: the deb postinst and the RPM `%post` both
run `systemctl --global enable punktfunk-scripting.service`, and the sysext image bakes
in the `default.target.wants` symlink. It used to be opt-in here, on the reasoning that
the runner does nothing until you add scripts or plugins that stopped being true when
the game-library scanners became plugins. A host whose runner is off now comes up with an
empty library and no obvious reason why (design/library-scanner-plugins.md D9).
It remains opt-OUT: set this to `false`, or per user
`systemctl --user mask punktfunk-scripting`.
'';
};
};
@@ -305,6 +344,23 @@ in
# 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;
# A WARNING, not `xdg.portal.enable = mkDefault true`: enabling the portal service without an
# `extraPortals` backend is its own broken state, and only the operator knows which backend
# their compositor needs. The desktop-manager modules (plasma6, gnome) already wire theirs, so
# this fires exactly where it should — a headless/appliance or sway/Hyprland box assembled by
# hand. It matters because the host reaches the desktop through portals on several backends:
# Mutter's virtual output is ashpd ScreenCast/RemoteDesktop, and the libei input path's own
# error message is "is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?".
warnings = optional (cfg.host.enable && !config.xdg.portal.enable) ''
services.punktfunk.host is enabled but xdg.portal.enable is false. The host drives the
compositor through xdg-desktop-portal on several backends (Mutter's ScreenCast/RemoteDesktop
and the libei input path), so capture or input will fail there with a portal error. Set
xdg.portal.enable = true and add the backend for your compositor, e.g.
xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-kde ] # or -gnome / -hyprland / -wlr
(the KDE and GNOME desktop-manager modules already do this for you). The KWin backend's own
virtual output uses the privileged zkde_screencast protocol and needs no portal.
'';
# 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 = {
@@ -409,9 +465,17 @@ in
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" ];
after = [ "pipewire.service" ] ++ optional cfg.host.desktopSession "graphical-session.target";
wants = [ "pipewire.service" ];
wantedBy = optional cfg.host.autoStart "default.target";
# `graphical-session.target` is IN ADDITION to `default.target`, never instead of it: the
# host still comes up at login before the graphical session is ready — it listens without
# touching the compositor and only opens one per client connect, so an early start costs
# nothing. `partOf` is the half that matters, taking the host down with the session so the
# next one gets a fresh compositor connection (see `desktopSession`).
partOf = optional cfg.host.desktopSession "graphical-session.target";
wantedBy =
optional cfg.host.autoStart "default.target"
++ optional cfg.host.desktopSession "graphical-session.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 = [
@@ -477,7 +541,10 @@ in
# policy keeps a plugin from acting as the logged-in operator. Leaving it closed does not
# degrade gracefully — every plugin interface is simply an empty panel from any other device.
# Keep in step with packaging/linux/punktfunk-web.xml and punktfunk.ufw.
allowedTCPPorts = [ 47992 47993 ];
allowedTCPPorts = [
47992
47993
];
};
# First-run setup: generate the console login password once, in the user's config dir, and