Files
punktfunk/packaging/arch/build-sysext.sh
enricobuehler 4d383811c0
ci / bun-nix (pull_request) Successful in 17s
ci / web (pull_request) Successful in 1m7s
apple / swift (pull_request) Successful in 1m38s
ci / rust-arm64 (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 5m31s
ci / rust (pull_request) Failing after 9m2s
nix / flake (pull_request) Successful in 12m24s
fix(packaging): the same CAP_SYS_NICE broke KDE on FIVE channels, not one — Bazzite included
The Arch fix in the previous commit was incomplete. 0.26.0-1 granted the host CAP_SYS_NICE through
every Linux channel we ship, and each one breaks KWin identification the same way:

  * packaging/rpm/punktfunk.spec .......... %caps(cap_sys_nice=ep) in %files  <- Fedora AND Bazzite
                                            via rpm-ostree layering
  * packaging/bazzite/build-sysext.sh ..... setcap on the staging tree, recorded by mksquashfs
  * packaging/debian/build-deb.sh ......... setcap in the postinst
  * packaging/nix/nixos-module.nix ........ security.wrappers with capabilities = "cap_sys_nice=ep"
  * scripts/steamdeck/install.sh .......... setcap on $BIN, six lines after writing the .desktop
                                            whose Exec= it thereby voids

Bazzite was NOT a separate fault, as first reported here — it is this one. Verified by mounting the
published punktfunk-0.26.0-1-x86-64.raw: `getcap usr/bin/punktfunk-host` reports cap_sys_nice=ep,
stored as security.capability in the squashfs. The claim in packaging/arch/build-sysext.sh that
"file capabilities don't survive this squashfs path" is false and is corrected here; mksquashfs
records them, which is exactly why the image shipped one.

NixOS deserves its own note: a security.wrappers entry does not dodge the problem. The wrapper
raises the capability into its AMBIENT set before exec'ing the store binary, precisely so it
survives — which lands CAP_SYS_NICE in the exec'd process's permitted set and fails the readlink
identically to a file capability. ExecStart now points at the store path directly, which is also the
path packages.nix substitutes into the .desktop's Exec=, so the two finally agree.

Measured blast radius of holding a capability, same-uid reader, CachyOS kernel 7.1.6:

    /proc/PID/exe ....... EPERM   <- KWin's identification. Desktop sessions die.
    /proc/PID/root/* .... EPERM   <- xdg-desktop-portal reads .flatpak-info here to resolve an
                                     app id; the wlroots and Hyprland backends go through it
    /proc/PID/environ ... EPERM
    /proc/PID/cgroup .... OK
    /proc/PID/status .... OK
    /proc/PID/cmdline ... OK

Compositor backends, by exposure: KWin is broken outright (proven, field-confirmed). gamescope has
no identity gate and was never affected, which matches the field — only Desktop mode was reported.
Mutter drives Mutter's own D-Bus API, not the portal, and looks unaffected. wlroots and Hyprland go
through the ScreenCast portal, whose app-id resolution reads a path the capability blocks — a real
exposure, not something I reproduced end to end.

The sysext build now HARD-FAILS if a capability is staged, rather than trusting that the RPM payload
never carries one: a merged sysext's /usr is read-only squashfs, so a bad image cannot be repaired
on the box, and the spec was one %caps() away from baking one in again.

Docs corrected, because they advertised the capability as a feature:
  * docs-site running-as-a-service "GPU scheduling priority" — rewritten: the host carries no
    capability, why it must not, and how to clear a 0.26.0-1 install (Bazzite needs a new image)
  * docs-site configuration.md — the PYROWAVE_QUEUE_PRIORITY row no longer claims the packages grant it
  * packaging/bazzite/README.md — §6.5 still described the kde-desktop-setup.sh behaviour from
    before it stopped writing KWIN_WAYLAND_NO_PERMISSION_CHECKS and started REMOVING it; plus a
    note that 0.26.0-1 Desktop mode cannot be repaired in place
  * packaging/arch/README.md — the false "capabilities don't survive the sysext" line
  * CHANGELOG v0.26.0 PW1 — annotated with the 0.26.0-2 correction rather than rewritten, and the
    owed PyroWave-under-load A/B now says it needs a gamescope-only box

Verified: bash -n on all five changed shell files; nix-instantiate --parse on nixos-module.nix and
packages.nix; the published 0.26.0-1 sysext mounted and its capability read; getcap on an uncapped
file exits 0 with empty output, so the new build assertion cannot false-positive.
2026-08-09 09:56:36 +02:00

93 lines
4.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Wrap a built punktfunk pacman package into a systemd-sysext image — the update-survivable way to
# add it to an immutable Arch-derived distro (SteamOS 3): the .raw overlays /usr read-only from the
# writable /var/lib/extensions/, so it persists across A/B OS updates with no `steamos-readonly
# disable`. Works for either split package — on a Steam Deck you'd wrap the CLIENT. Needs
# `bsdtar`/`tar`, `squashfs-tools` (mksquashfs).
#
# Usage: bash build-sysext.sh [--gamescope <punktfunk-gamescope-*.pkg.tar.zst>] \
# <punktfunk-{host,client}-*.pkg.tar.zst>
# Output: <pkgname>.raw (e.g. punktfunk-client.raw)
#
# --gamescope folds the HDR-capable gamescope companion package (packaging/gamescope) into a HOST
# image as /usr/bin/punktfunk-gamescope — what lets the gamescope backend stream 10-bit BT.2020 PQ
# instead of 8-bit SDR (the host prefers that name on PATH and attempts HDR by default). Mirrors
# the Bazzite image's fold-in, including the honesty check: the binary is verified by executing
# its `+pfhdr` banner, never trusted by filename. Omit it and the image is exactly what it was —
# the host then stays SDR on that backend, by design.
#
# No CAP_SYS_NICE inside the image, for either binary. ⚠ NOT because capabilities are lost on the
# way in — that was this comment's earlier claim and it is false: mksquashfs records
# security.capability, and the published Bazzite 0.26.0-1 image really did carry `cap_sys_nice=ep`
# on usr/bin/punktfunk-host. It is left out on purpose. A capability on the HOST binary makes it
# unidentifiable to KWin (which resolves a client's /proc/<pid>/exe to match it against a .desktop,
# and cannot read it for a capability-carrying process) and kills every Desktop-mode session — see
# packaging/bazzite/build-sysext.sh, which now hard-fails if one is staged. `punktfunk-gamescope`
# is a compositor, not a KWin client, so it is unaffected by that rule and simply runs without the
# capability here, pacing slightly worse.
set -euo pipefail
GAMESCOPE=""
if [ "${1:-}" = "--gamescope" ]; then
GAMESCOPE="${2:?--gamescope needs a punktfunk-gamescope package}"; shift 2
fi
# No braces in the message: a literal `}` inside ${1:?...} terminates the expansion early and
# corrupts $PKG (the tail of the message gets appended to the value — a real field bug).
PKG="${1:?usage: build-sysext.sh [--gamescope <pkg>] <punktfunk-host|client pkg.tar.zst>}"
[ -f "$PKG" ] || { echo "no such package: $PKG" >&2; exit 1; }
# Derive the package name from the file (pkgname is everything before the -<version>).
NAME="$(basename "$PKG" | sed -E 's/-[0-9].*//')"
[ -n "$NAME" ] || { echo "could not derive package name from $PKG" >&2; exit 1; }
if [ -n "$GAMESCOPE" ] && [ "$NAME" != "punktfunk-host" ]; then
echo "--gamescope only makes sense for a punktfunk-host image (got: $NAME)" >&2; exit 1
fi
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# A pacman package is a (zstd) tarball; a sysext only carries /usr (the host /etc, /var are the
# system's). Extract just usr/ from the payload.
if command -v bsdtar >/dev/null 2>&1; then
bsdtar -C "$STAGE" -xf "$PKG" usr
else
tar -C "$STAGE" -xf "$PKG" usr
fi
# The HDR gamescope companion (see --gamescope in the header). Verified by its banner marker
# rather than trusted by filename: an unpatched gamescope shipped under this name would make the
# host promise HDR it cannot deliver, and the punktfunk/1 Welcome cannot take that back
# mid-session. Executing the staged binary needs a build box the binary runs on (the Arch CI
# container qualifies; it built it).
if [ -n "$GAMESCOPE" ]; then
[ -f "$GAMESCOPE" ] || { echo "no such package: $GAMESCOPE" >&2; exit 1; }
if command -v bsdtar >/dev/null 2>&1; then
bsdtar -C "$STAGE" -xf "$GAMESCOPE" usr
else
tar -C "$STAGE" -xf "$GAMESCOPE" usr
fi
GS_BIN="$STAGE/usr/bin/punktfunk-gamescope"
[ -x "$GS_BIN" ] || { echo "$GAMESCOPE did not provide usr/bin/punktfunk-gamescope" >&2; exit 1; }
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
echo "$GAMESCOPE's binary has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
echo "folded in $("$GS_BIN" --version 2>&1 | head -1)"
fi
# The marker systemd-sysext requires to merge the image. ID=_any merges onto ANY host os-release
# (SteamOS, Arch, Bazzite); ARCHITECTURE pins it to x86-64 so it's never merged on the wrong arch.
install -d "$STAGE/usr/lib/extension-release.d"
cat > "$STAGE/usr/lib/extension-release.d/extension-release.$NAME" <<EOF
ID=_any
ARCHITECTURE=x86-64
EOF
OUT="$NAME.raw"
rm -f "$OUT"
mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet
echo "built $OUT"
echo " install: sudo cp $OUT /var/lib/extensions/ && sudo systemctl enable --now systemd-sysext"
if [ "$NAME" = "punktfunk-host" ]; then
echo " then: systemctl --user enable --now punktfunk-host"
else
echo " then: run 'punktfunk-client' (or let the Decky plugin launch it)"
fi