Files
enricobuehler 082c65755f fix(packaging): every channel ships the WSI layer, so in-game HDR works off a stock install
The previous commit built the layer and taught the host to use it, but only the
Arch PKGBUILD carried the files, so every other channel still landed on the
no-game-HDR fallback. This finishes the job.

The packaging scripts now take `--stage`, the DESTDIR the gamescope build script
wrote, instead of a path to one binary. That is the part worth keeping: the next
file this package needs will not require a new flag in four scripts and two
workflows. CI caches the whole staged tree for the same reason. The gs-cache key
already hashes packaging/gamescope/**, which this commit changes, so stale caches
in the old single-file shape cannot be restored into the new layout.

Channels, all of them:
  rpm     spec gains Source1/Source2 and %files entries
  deb     build-gamescope-deb.sh copies the layer into the package root
  Arch    PKGBUILD (previous commit); the sysext extracts the whole usr tree
  sysext  bazzite takes --gamescope-stage; arch asserts the layer arrived
  nix     the derivation keeps, renames and rewrites the layer rather than
          deleting it with everything else

A missing layer is fatal in every one of them, not best-effort. A package that
carries the compositor without it looks completely healthy and then silently
denies every game an HDR10 swapchain -- the exact failure this whole change
exists to end, so it must not be possible to ship it again by accident.

Two things needed care:

The layer manifest carries an ABSOLUTE library_path baked in at build time, so
every channel has to install the .so at exactly that path. That means literal
/usr/lib/punktfunk, not %{_libdir} (which is /usr/lib64 on Fedora) and not a
Debian multiarch triplet. Nothing links the .so by soname -- the loader dlopens
it by that path -- so multilib has no claim here. The rpm and nix install checks
now read the path back out of the manifest and fail if it names a file the
package does not install, because a manifest pointing at nothing is the silent
shape of this bug.

NixOS has no /usr, so the layer lives inside the gamescope derivation and the
host's path is overridable via PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR, which the
module sets -- the same posture as PUNKTFUNK_GAMESCOPE_BIN, and documented.

The manifest rewrite moved out of a heredoc into
packaging/gamescope/rewrite-wsi-layer-manifest.py because the FHS builds and the
Nix store both need it and must rename the layer identically; two copies would
drift into a host looking for a name only one of them produces.

Verified: 214 pf-vdisplay tests pass in a linux container, clippy -D warnings and
rustfmt clean, bash -n on all five changed shell scripts, both workflow YAMLs
parse, and the rewrite script was run against a synthetic FROG manifest to
confirm it renames/repoints/regates while preserving the `functions` block --
which is the field that decides whether the layer loads at all.
NOT verified: no nix on this machine, so gamescope.nix, flake.nix and the module
are unevaluated; no gamescope build, no package build of any kind, and no game
has taken an HDR swapchain on glass.
2026-08-13 23:37:48 +02:00

272 lines
16 KiB
Bash

#!/usr/bin/env bash
# Build the punktfunk systemd-sysext image for Bazzite / Fedora Atomic from the built RPMs —
# the no-layering install path (rpm-ostree layering slows every update and can block upgrades;
# a sysext never enters an rpm-ostree transaction). The .raw overlays /usr read-only from
# /var/lib/extensions/, survives OS updates, and is toggled/updated without a reboot.
#
# Counterpart to ../arch/build-sysext.sh (which wraps a pacman package for SteamOS). This one
# wraps the Fedora RPMs (punktfunk + punktfunk-web) and additionally:
# * relocates the RPMs' /etc payload to /usr/share/punktfunk/etc/ (a sysext carries ONLY /usr;
# punktfunk-sysext(8) copies these into the real /etc on install),
# * bakes SELinux labels in as squashfs pseudo-xattrs, computed with matchpathcon from the
# build container's targeted policy. Without them every file is unlabeled_t at runtime:
# fine for the user session + systemd --user units (unconfined), but system daemons are
# DENIED — udev couldn't read 60-punktfunk.rules and systemd-sysctl couldn't read the
# sysctl drop-in (validated live on Bazzite 43, SELinux enforcing, 2026-07-04),
# * pins compatibility via ID=fedora + VERSION_ID: merges on Bazzite/Silverblue/Aurora of the
# SAME Fedora major (ID_LIKE matching, systemd >= 256) and is REFUSED after a major rebase
# instead of running soname-broken binaries (`punktfunk-sysext update` then re-resolves),
# * embeds the punktfunk-sysext helper so an installed box can update itself.
#
# Build in the matching Fedora container (ci/fedora*-rpm.Dockerfile) — matchpathcon needs the
# Fedora targeted policy (libselinux-utils + selinux-policy-targeted), and the RPMs are
# soname-coupled to their base anyway. Needs: rpm2cpio, cpio, mksquashfs (>= 4.6), matchpathcon.
#
# Usage:
# bash build-sysext.sh --version-id 43 --out dist/punktfunk-0.7.1-1-x86-64.raw \
# [--gamescope-stage path/to/gamescope-destdir] \
# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm
#
# --gamescope-stage folds in a prebuilt HDR-capable gamescope (packaging/gamescope) as
# /usr/bin/punktfunk-gamescope, which is what lets the gamescope backend stream 10-bit BT.2020 PQ.
# It is NOT built here: it is a C++ meson build with gamescope's whole dependency set, so CI builds
# it in the same Fedora container beforehand (`bash packaging/gamescope/build-punktfunk-gamescope.sh
# --destdir stage --prefix /usr`) and passes that DESTDIR in. Omit it and the image is exactly what
# it was — the host then stays SDR on that backend, by design.
#
# A directory rather than the binary, because the tree also carries the Vulkan WSI layer built beside
# the compositor. That layer is the only route to an HDR10 swapchain for a game nested under
# gamescope, so an image with the compositor and without it would stream HDR while every game in it
# rendered SDR.
#
# The installed image MUST be named punktfunk.raw (the embedded extension-release marker is
# extension-release.punktfunk; systemd-sysext requires marker == image name) — the feed carries
# versioned filenames and punktfunk-sysext installs to the fixed name.
set -euo pipefail
VERSION_ID="" OUT="" GAMESCOPE="" RPMS=()
while [ $# -gt 0 ]; do
case "$1" in
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
--out) OUT="${2:?}"; shift 2 ;;
--gamescope-stage) GAMESCOPE="${2:?}"; shift 2 ;;
*) RPMS+=("$1"); shift ;;
esac
done
[ -n "$VERSION_ID" ] || { echo "missing --version-id <fedora major, e.g. 43>" >&2; exit 1; }
[ -n "$OUT" ] || { echo "missing --out <image.raw>" >&2; exit 1; }
[ "${#RPMS[@]}" -gt 0 ] || { echo "no RPMs given" >&2; exit 1; }
for tool in rpm2cpio cpio mksquashfs matchpathcon; do
command -v "$tool" >/dev/null || { echo "missing tool: $tool" >&2; exit 1; }
done
HERE="$(cd "$(dirname "$0")" && pwd)"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# SYSEXT_VERSION_ID from the punktfunk RPM (V-R without the dist tag): what
# `punktfunk-sysext status` reports as the installed version.
PF_VR=""
SEEN_NAMES=" "
for rpm in "${RPMS[@]}"; do
[ -f "$rpm" ] || { echo "no such RPM: $rpm" >&2; exit 1; }
name="$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)"
# Two RPMs of the same NAME (e.g. a stale noarch next to the current x86_64 from a sloppy
# download glob) silently shadow each other's files — refuse instead of building a chimera.
case "$SEEN_NAMES" in *" $name "*) echo "duplicate RPM name '$name' in inputs — pass exactly one RPM per package" >&2; exit 1 ;; esac
SEEN_NAMES="$SEEN_NAMES$name "
if [ "$name" = punktfunk ]; then
PF_VR="$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)"
PF_VR="${PF_VR%.fc*}"
fi
rpm2cpio "$rpm" | ( cd "$STAGE" && cpio -idmu --quiet )
done
[ -n "$PF_VR" ] || { echo "the punktfunk (host) RPM must be among the inputs" >&2; exit 1; }
# A sysext carries only /usr. Relocate the RPMs' /etc payload (gamescope-session drop-in, tray
# autostart entry) under /usr/share/punktfunk/etc/ — punktfunk-sysext copies it into /etc.
if [ -d "$STAGE/etc" ]; then
mkdir -p "$STAGE/usr/share/punktfunk/etc"
cp -a "$STAGE/etc/." "$STAGE/usr/share/punktfunk/etc/"
rm -rf "${STAGE:?}/etc"
fi
rm -rf "${STAGE:?}/var" # rpm ghosts etc. — nothing outside /usr may remain
# The HDR-capable gamescope, when one was built (see --gamescope-stage 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.
if [ -n "$GAMESCOPE" ]; then
GS_BIN="$GAMESCOPE/usr/bin/punktfunk-gamescope"
GS_LAYER_SO="$GAMESCOPE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
GS_LAYER_JSON="$GAMESCOPE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
[ -x "$GS_BIN" ] || { echo "no such executable: $GS_BIN" >&2; exit 1; }
"$GS_BIN" --version 2>&1 | grep -q '+pfhdr' || {
echo "$GS_BIN has no +pfhdr marker — it is not a punktfunk HDR build" >&2; exit 1; }
# Fatal for the same reason the marker check is: an image carrying the compositor without its
# layer streams HDR while every game inside it renders SDR, and says nothing about why.
for f in "$GS_LAYER_SO" "$GS_LAYER_JSON"; do
[ -f "$f" ] || { echo "$f missing — the gamescope stage has no WSI layer" >&2; exit 1; }
done
install -Dm0755 "$GS_BIN" "$STAGE/usr/bin/punktfunk-gamescope"
install -Dm0755 "$GS_LAYER_SO" \
"$STAGE/usr/lib/punktfunk/libVkLayer_PUNKTFUNK_gamescope_wsi.so"
install -Dm0644 "$GS_LAYER_JSON" \
"$STAGE/usr/lib/punktfunk/vulkan/implicit_layer.d/punktfunk_gamescope_wsi.json"
fi
# Enable the plugin/script runner for every user, by baking its `[Install] WantedBy=default.target`
# symlink straight into the image.
#
# A sysext carries only /usr, and RPM scriptlets never run from one — so the `systemctl --global
# enable` the .rpm/.deb do at install time has no equivalent here, and without this the runner would
# ship present-but-off on exactly the platform (Bazzite / Fedora Atomic) where an operator is least
# likely to go hunting for it. The game-library scanners are plugins now (design D9), so an
# unenabled runner means an empty library.
#
# Opt-out is unchanged and still wins: `systemctl --user mask punktfunk-scripting` in the user's own
# ~/.config/systemd/user takes precedence over anything under /usr.
if [ -f "$STAGE/usr/lib/systemd/user/punktfunk-scripting.service" ]; then
install -d "$STAGE/usr/lib/systemd/user/default.target.wants"
ln -sf ../punktfunk-scripting.service \
"$STAGE/usr/lib/systemd/user/default.target.wants/punktfunk-scripting.service"
fi
# Self-update: the helper rides inside the image.
install -Dm0755 "$HERE/punktfunk-sysext.sh" "$STAGE/usr/bin/punktfunk-sysext"
# Compatibility marker. ID=fedora matches Bazzite & friends through os-release ID_LIKE;
# VERSION_ID makes a major-rebased host refuse the old ABI instead of merging it.
install -d "$STAGE/usr/lib/extension-release.d"
cat > "$STAGE/usr/lib/extension-release.d/extension-release.punktfunk" <<EOF
ID=fedora
VERSION_ID=$VERSION_ID
ARCHITECTURE=x86-64
SYSEXT_ID=punktfunk
SYSEXT_VERSION_ID=$PF_VR
EXTENSION_RELOAD_MANAGER=1
EOF
# CAP_SYS_NICE on the ENCODE WORKER, never on the host — and an assertion of BOTH halves.
#
# 0.26.0-1 setcap'd the staged HOST binary here for the GPU-priority lever. mksquashfs records
# security.capability, so the capability really did ship: verified by mounting the published
# punktfunk-0.26.0-1-x86-64.raw, where `getcap usr/bin/punktfunk-host` reports `cap_sys_nice=ep`.
# That broke desktop streaming on every Bazzite KDE box, field-reported as
# "KWin does not expose zkde_screencast_unstable_v1 to this client".
#
# KWin advertises its restricted protocols (zkde_screencast_unstable_v1 for the virtual output,
# org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by resolving that client's
# /proc/<pid>/exe and matching it against an installed .desktop's Exec= — the image ships
# usr/share/applications/io.unom.Punktfunk.Host.desktop for exactly that. The kernel refuses that
# readlink to any reader whose effective set is not a superset of the target's PERMITTED set
# (cap_ptrace_access_check), and KWin holds no capabilities. So a capability on the HOST in this
# image makes it unidentifiable and every Desktop-mode session dies. Full matrix, including why
# neither prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it, in
# packaging/arch/punktfunk-host.install.
#
# usr/bin/punktfunk-encode-worker is the OTHER binary: a separate executable (never a hardlink or a
# host subcommand — a shared inode shares the capability and re-creates the above), spawned per
# PyroWave session, speaking one socketpair to its parent and touching neither Wayland nor D-Bus
# nor the network. Nothing resolves ITS /proc/<pid>/exe, so it can carry the capability the lever
# needs. This is the ONLY place the sysext can acquire it: a merged sysext's /usr is a read-only
# squashfs, and it cannot ride in from the RPM either — the spec declares %caps(cap_sys_nice=ep),
# but rpm keeps capabilities in its own header and `rpm2cpio | cpio` carries only the payload, so
# the staged file arrives with none. mksquashfs DOES record security.capability (only
# security.selinux is excluded below), so a setcap on the staging tree is what lands in the image.
#
# Needs CAP_SETFCAP, i.e. root (or fakeroot). A plain-user build simply cannot, and that is NOT
# fatal: an uncapped worker still encodes, at default priority. Warn and carry on rather than fail
# a release over a pacing lever.
#
# `getcap` on a file with no capability exits 0 and prints nothing, so an empty read is unambiguous.
# The output form differs across libcap versions ("path cap_sys_nice=ep" since ~2.36, "path =
# cap_sys_nice+ep" before), hence the normalizer.
_pf_caps_of() {
# -> canonical "cap_sys_nice=ep", or "" when the file carries no capability.
local raw; raw="$(getcap "$1" 2>/dev/null || true)"
[ -n "$raw" ] || { printf ''; return 0; }
printf '%s' "${raw#* }" | sed -e 's/^= *//' -e 's/+/=/' -e 's/[[:space:]]*$//'
}
# BEFORE granting: refuse a capability that arrived from somewhere else. The setcap below would
# overwrite it and ship a correct-looking image while the surprise — a stray %caps() in the spec, a
# payload from an unexpected source — went unreported on every other channel. Order matters: assert
# first, then grant, or the "anything else" arm can never fire.
if command -v getcap >/dev/null 2>&1 && [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
arrived_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")"
case "$arrived_caps" in
''|cap_sys_nice=ep) : ;;
*)
echo "ERROR: staged usr/bin/punktfunk-encode-worker ARRIVED carrying '$arrived_caps'." >&2
echo " Nothing upstream of this script should grant it anything: rpm keeps capabilities" >&2
echo " in its own header and 'rpm2cpio | cpio' carries only the payload. Find out what" >&2
echo " did — it is granting the same thing on the plain RPM path, unchecked." >&2
exit 1 ;;
esac
fi
if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
if setcap 'cap_sys_nice=ep' "$STAGE/usr/bin/punktfunk-encode-worker" 2>/dev/null; then
echo "granted CAP_SYS_NICE to usr/bin/punktfunk-encode-worker (GPU-priority lever active)"
else
echo "WARNING: could not setcap CAP_SYS_NICE on usr/bin/punktfunk-encode-worker (need" >&2
echo " root/CAP_SETFCAP) — the image ships without it and PyroWave encodes at" >&2
echo " default GPU priority." >&2
fi
fi
# Assert the final matrix rather than trust it. A merged sysext's /usr is a read-only squashfs, so
# a bad image cannot be repaired on the box — the image is the only place this can be got right.
#
# host -> MUST be empty. Hard fail. (The RPM payload carries no capabilities today, but the
# spec is one `%caps()` away from changing that and this build would bake it in.)
# worker -> MUST be exactly cap_sys_nice=ep if it carries anything at all. MISSING IS NOT AN
# ERROR (a plain-user build cannot setcap; best-effort by design), but a DIFFERENT or
# WIDER capability is — and a read-only image is not the place to discover it.
if command -v getcap >/dev/null 2>&1; then
if [ -f "$STAGE/usr/bin/punktfunk-host" ]; then
staged_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-host")"
if [ -n "$staged_caps" ]; then
echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2
echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2
echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2
echo " The GPU-priority capability belongs on usr/bin/punktfunk-encode-worker, never here." >&2
exit 1
fi
fi
if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then
worker_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")"
case "$worker_caps" in
'') echo "note: usr/bin/punktfunk-encode-worker ships uncapped — PyroWave encodes at default GPU priority" ;;
cap_sys_nice=ep) : ;;
*)
echo "ERROR: staged usr/bin/punktfunk-encode-worker carries '$worker_caps'," >&2
echo " expected exactly 'cap_sys_nice=ep' (or nothing at all)." >&2
echo " Refusing to bake an unexpected capability into a read-only image." >&2
exit 1 ;;
esac
fi
fi
# SELinux labels as pseudo-xattrs (see header). matchpathcon resolves each target path against
# the targeted policy's file_contexts; <<none>> means "no specific entry" — skip those (the
# handful of matches all resolve to real contexts for our payload).
PSEUDO="$STAGE.pseudo"
( cd "$STAGE" && find . -mindepth 1 \( -type f -o -type d \) -printf '/%P\n' ) | sort \
| while IFS= read -r path; do
ctx="$(matchpathcon -n "$path" 2>/dev/null || true)"
case "$ctx" in ''|'<<none>>') continue ;; esac
printf '%s x security.selinux=%s\n' "$path" "$ctx"
done > "$PSEUDO"
[ -s "$PSEUDO" ] || { echo "matchpathcon produced no labels — refusing to build an unlabeled image" >&2; exit 1; }
rm -f "$OUT"; mkdir -p "$(dirname "$OUT")"
# -xattrs-exclude drops any security.selinux the staging fs already had (would collide with the
# pseudo defs when building on an SELinux host); -all-root because cpio extracted as the CI uid.
mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet \
-xattrs-exclude '^security.selinux' -pf "$PSEUDO"
rm -f "$PSEUDO"
echo "built $OUT (punktfunk $PF_VR, fedora $VERSION_ID, $(du -h "$OUT" | cut -f1))"
echo " install on the box: punktfunk-sysext install (or --from-file $OUT)"