diff --git a/.gitea/workflows/nix.yml b/.gitea/workflows/nix.yml index 5693d755..d8a99526 100644 --- a/.gitea/workflows/nix.yml +++ b/.gitea/workflows/nix.yml @@ -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..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 diff --git a/flake.nix b/flake.nix index c6862b3c..faa9e701 100644 --- a/flake.nix +++ b/flake.nix @@ -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; + }; } ); diff --git a/packaging/nix/README.md b/packaging/nix/README.md index 6d1a3456..786f90af 100644 --- a/packaging/nix/README.md +++ b/packaging/nix/README.md @@ -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://: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 diff --git a/packaging/nix/module-check.nix b/packaging/nix/module-check.nix new file mode 100644 index 00000000..c085e1ec --- /dev/null +++ b/packaging/nix/module-check.nix @@ -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//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" + '' diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index e66b72cf..c1f8fd5c 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -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