main moved 93 commits while this branch ran. Two conflicts, both where main's new
work sat next to M10's excision:
packaging/flatpak/io.unom.Punktfunk.yml — main added the vendored gamescope WSI
layer (the only route to HDR on a Deck) and, before it, a vulkan-headers module.
Took both: this branch predates them and deletes neither. But the headers module's
stated consumer was pf-ffvk's bindgen over FFmpeg's hwcontext_vulkan.h, and M10
deleted pf-ffvk — so it now reads as dead weight to the next person. It is not:
the WSI layer IS a Vulkan layer, compiles against those headers, and builds after
it, so module order is the dependency. Rewrote the rationale to say so, including
why dropping it would be expensive to discover — flatpak.yml has no pull_request:
trigger, so a manifest break reaches main invisibly and a tag then ships no Linux
flatpak. Also recorded that the native decoder needs nothing from there: pf-vkdecode
reaches Vulkan through ash, which is pure Rust bindings, no bindgen, no C headers.
crates/pf-console-ui/src/screens/settings.rs — main restructured the gamepad
settings into TABS, which removed the per-row section headers; this branch had left
Some("Video") untouched from the merge base and added the pre-M10 decoder migration
next to it. Git could not tell those apart. Took main's structure (no header, its
deliberate change) with this branch's migration layered on: a stored `vulkan`,
`vaapi` or `d3d11va` names no preset in the tabbed list and would render as "—",
then silently rewrite the user's preference on the next save.
Gates on the merged tree, Linux container: fmt clean; cargo check --workspace
--all-targets clean; clippy --workspace --all-targets -D warnings clean; tests
green across pf-vkdecode (187), pf-client-core (163), pf-console-ui (58) and
punktfunk-host (447 of 448 — the one failure is the pre-existing
gamestream::stream::tests::sender_delivers_batches, a UDP-loopback EINTR under
qemu that fails identically on a pristine HEAD).
punktfunk-host — RPM (Bazzite / Fedora Atomic) via the Gitea registry
punktfunk-host is published as an RPM to Gitea's RPM package registry in the public unom
org (stable groups bazzite/fedora-44, canary groups bazzite-canary/fedora-44-canary), so
Bazzite / Fedora Atomic hosts layer and update it with rpm-ostree. CI (.gitea/workflows/rpm.yml)
builds and publishes on every push to main (a rolling <next-minor>-0.ciN.g<sha> build — the base
is derived from the latest stable tag by scripts/ci/pf-version.sh — to the *-canary
groups) and on vX.Y.Z tags (a clean X.Y.Z-1 to the base groups, plus attached to the unified
Gitea Release) — separate repos, so a stable box never jumps to a canary build (see
Release Channels). The baseurl below subscribes to the
bazzite stable group; use bazzite-canary for the latest main builds. The RPM is built in the
Fedora 43 image (ci/fedora-rpm.Dockerfile) so its auto-generated library Requires
(libavcodec.so.NN, …) match Bazzite's sonames; the NVIDIA driver lib (libcuda.so.1) is
excluded — NVENC/EGL come from whatever NVIDIA stack the host runs (a weak Recommends).
This is the same package as the COPR / bootc
paths — same spec (punktfunk.spec) — just self-hosted in Gitea instead of COPR, mirroring the
Debian/apt setup.
Install on a Bazzite host (one-time)
# Add the repo. Packages are GPG-signed (gpgcheck=1, the packages@unom.io key) AND the repo
# metadata is Gitea-signed (repo_gpgcheck=1); gpgkey lists both so dnf/rpm-ostree imports each.
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
[gitea-unom-bazzite]
name=punktfunk (unom, Bazzite)
baseurl=https://git.unom.io/api/packages/unom/rpm/bazzite
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
REPO
# Layer the host + the web console (pairing/status), then reboot into the new deployment.
# (punktfunk Recommends punktfunk-web; list it explicitly so it's pulled regardless of weak-dep
# settings. The registry carries punktfunk-web because CI builds the spec --with web; COPR can't.)
rpm-ostree install punktfunk punktfunk-web
systemctl reboot
If
rpm-ostreecan't complete the metadata GPG check non-interactively, setrepo_gpgcheck=0(TLS-only trust to the self-hosted registry).
Per-package signing (gpgcheck=1, active)
CI GPG-signs every RPM: packaging/rpm/sign-rpms.sh (run from rpm.yml between build and publish)
signs with the dedicated EdDSA key packages@unom.io (AF245C506F4E4763) and self-verifies
with rpmkeys --checksig before publishing, so an unsigned/bad build never reaches the registry.
The public key is served from the registry (the gpgkey= URL above) and committed at
packaging/rpm/RPM-GPG-KEY-punktfunk. (This is a GPG/OpenPGP key — a step-ca/X.509 cert can't
sign RPMs; step-ca is only for registry/console TLS.)
RPM_GPG_PRIVATE_KEYis an org-level secret onunom, not a repo secret — it will not show up under this repository's Actions secrets. Verify it end to end instead of by its absence there:curl -O <repo-url>/package/punktfunk-web/<ver>/x86_64/…rpm && rpm -qp --qf '%{RSAHEADER:pgpsig}\n'(orrpmkeys --checksig, which reportsNOKEYuntil you import the public key —NOKEYstill means signed, just by a key that box doesn't have yet).
On a v* tag build, a missing key fails the build: sign-rpms.sh will not publish unsigned
RPMs into a repo whose own instructions say gpgcheck=1, because every user's dnf upgrade would
then break on them. Non-release builds still fall through unsigned so forks and local builds work.
How it was set up (and how to rotate the key):
# 1. Generate a DEDICATED, passphrase-less signing key (separate from the Gitea metadata key).
gpg --batch --gen-key <<EOF
%no-protection
Key-Type: eddsa
Key-Curve: ed25519
Name-Real: punktfunk packages
Name-Email: packages@unom.io
Expire-Date: 0
%commit
EOF
gpg --armor --export-secret-keys packages@unom.io # -> the RPM_GPG_PRIVATE_KEY CI secret
gpg --armor --export packages@unom.io > packaging/rpm/RPM-GPG-KEY-punktfunk # public half
# 2. Add the armored PRIVATE key as the RPM_GPG_PRIVATE_KEY Gitea Actions secret, at the ORG level
# (git.unom.io/org/unom/settings/actions/secrets) so every repo's workflows inherit it. Commit
# the public half and publish it to the registry so the gpgkey= URL resolves:
curl --user "<user>:<write:package-PAT>" --upload-file packaging/rpm/RPM-GPG-KEY-punktfunk \
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
Rotating the key means a new generic-registry version (bump punktfunk-keys/1 → /2 and the
gpgkey= URL), since the registry rejects re-uploading an existing file.
This key also signs the Bazzite sysext feed, and a third copy of its public half is baked into
packaging/bazzite/punktfunk-sysext.sh (FEED_KEY=) — that script is bootstrapped by curl on
machines that have nothing installed yet, so it can't fetch the key from the thing it's
authenticating. A rotation must update all three: the CI secret, this directory's
RPM-GPG-KEY-punktfunk (+ its registry upload), and FEED_KEY. publish-sysext-feed.sh compares
its signing key's fingerprint against FEED_KEY and refuses to sign on a mismatch, so forgetting
the third one fails the publish instead of stranding every Bazzite box in front of a feed it
can't verify.
After reboot, as the desktop user:
ujust add-user-to-input-group # virtual gamepads need /dev/uinput (re-login).
# Bazzite is atomic — use ujust, NOT `usermod -aG input`.
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env # gamescope defaults
systemctl --user enable --now punktfunk-host
# Web console — enable it and read the auto-generated login password (then open https://<host-ip>:47992):
systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
(See ../bazzite/README.md for the full appliance walkthrough —
udev/group, host.env, the Steam session unit, firewall, verify.)
Updates
rpm-ostree upgrade # pulls the newest punktfunk with the system update
systemctl reboot # rpm-ostree changes apply on reboot
Layered packages are re-resolved against their repos on every rpm-ostree upgrade, so the box
tracks new builds automatically (Bazzite's auto-update timer does this for you). To pin or stop
tracking: rpm-ostree override / rpm-ostree uninstall punktfunk.
Build an RPM locally
PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh # host + client
PF_VERSION=0.0.1 PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh # + punktfunk-web (needs bun on PATH)
# -> dist/punktfunk-0.0.1-1.fcNN.x86_64.rpm (+ punktfunk-web-0.0.1-1.fcNN.x86_64.rpm with PF_WITH_WEB=1;
# the web subpackage vendors a bun binary, so it's arch-specific, not noarch)
Run it inside the Fedora 43 builder image so the deps resolve and match Bazzite:
docker build -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm ci
docker run --rm -v "$PWD:/src" -w /src punktfunk-fedora-rpm \
bash -lc 'git config --global --add safe.directory /src && PF_VERSION=0.0.1 bash packaging/rpm/build-rpm.sh'
A plain rpmbuild/COPR build with no pf_version/pf_release defines produces 0.3.0-1 (the
spec defaults).
aarch64 — the client RPM
The client builds for aarch64; the host does not (its encode stack is NVENC/QSV/AMF, all
x86). PF_WITHOUT_HOST=1 drops the host binary, the tray, the headless-session data, the
firewalld services and the main package's %files, leaving exactly one RPM: punktfunk-client.
Omitting the main %files is what keeps rpm from emitting an empty punktfunk next to it.
This is not a cross-compile — %build runs cargo for the host architecture, so run it on an
arm64 machine (or an emulated arm64 container, which is very slow):
docker build --platform linux/arm64 -f ci/fedora-rpm.Dockerfile -t punktfunk-fedora-rpm-arm64 ci
docker run --rm --platform linux/arm64 -v "$PWD:/src" -w /src punktfunk-fedora-rpm-arm64 \
bash -lc 'git config --global --add safe.directory /src && \
PF_VERSION=0.0.1 PF_WITHOUT_HOST=1 bash packaging/rpm/build-rpm.sh'
# -> dist/punktfunk-client-0.0.1-1.fcNN.aarch64.rpm
PF_WITHOUT_HOST=1 works on x86_64 too, if you only want the client RPM. The flag is orthogonal
to the architecture; it is just that aarch64 has no other option.