12 Commits

Author SHA1 Message Date
enricobuehler 202f40fd4e chore(release): bump workspace version to 0.7.4
apple / swift (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 1m33s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 51s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 55s
ci / rust (push) Successful in 7m8s
android-screenshots / screenshots (push) Successful in 46s
ci / bench (push) Successful in 4m49s
android / android (push) Successful in 3m20s
release / apple (push) Successful in 9m30s
windows-host / package (push) Successful in 7m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
arch / build-publish (push) Successful in 8m8s
apple / screenshots (push) Successful in 5m47s
deb / build-publish (push) Successful in 2m56s
decky / build-publish (push) Successful in 14s
flatpak / build-publish (push) Successful in 4m53s
linux-client-screenshots / screenshots (push) Successful in 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 9m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m36s
docker / deploy-docs (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
web-screenshots / screenshots (push) Successful in 2m40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:46:34 +00:00
enricobuehler 8f90563ffd docs: dedicated Arch Linux host+client guide
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
Every other distro has a full Host Setup page; Arch only had table rows. Add
docs/arch.md (signed pacman binary repo: key import + repo + install, GPU
prereqs, service/linger, web console, client, PKGBUILD appendix), slot it into
the nav after fedora-kde, and point the install/client tables at it. Update the
client-install rows from 'from the PKGBUILD' to the binary repo now that it exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:37:01 +00:00
enricobuehler 2e6b822fd6 docs(ci/arch): correct the header's pacman setup (key import, not TrustAll) + note the trust root
android / android (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:19:28 +00:00
enricobuehler f7c5314b5e fix(packaging/arch): correct pacman setup — import the registry key, cache cargo git
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 3m18s
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The Gitea Arch registry signs its DB + packages, so 'SigLevel = Optional TrustAll' fails
non-interactively (pacman still needs the key to verify). Document the one-time
pacman-key import instead; install is then signature-validated under pacman's default
SigLevel (verified end-to-end: clean archlinux container -> repo sync -> install,
'Validated By: Signature').

Also cache /usr/local/cargo/git in arch.yml: the workspace pulls clients/windows'
git-pinned windows-reactor/windows deps to resolve, cloning windows-rs (huge) every run
otherwise — same registry+git cache deb.yml uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:16:24 +00:00
enricobuehler d6669fc3fb fix(ci/arch): create CARGO_HOME before chown — actions/cache doesn't on a miss
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
arch / build-publish (push) Successful in 7m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:03:46 +00:00
enricobuehler e292084225 fix(ci/arch): install nodejs before actions/checkout — act_runner doesn't inject node
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
android / android (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
arch / build-publish (push) Failing after 43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:02:17 +00:00
enricobuehler c758b0393a docs: sysext + pacman repo are the Bazzite/Arch install paths
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m37s
ci / web (push) Successful in 53s
android / android (push) Successful in 3m37s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m23s
ci / bench (push) Successful in 4m52s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
deb / build-publish (push) Successful in 4m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 9m57s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m40s
arch / build-publish (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler d6a659a1ee feat(packaging/arch): distribute binary packages via the Gitea Arch registry
New arch.yml builds the split PKGBUILD (host/client/web, PF_WITH_WEB=1) in an
archlinux:base-devel container on every push and publishes to the pacman repos
'punktfunk' (tags) / 'punktfunk-canary' (main, X.Y.Z-0.<run#> — pkgrel allows
only digits+dots, so the run number carries the ordering). Consumers add one
pacman.conf section; no more build-it-yourself as the only Arch path.

PKGBUILD: pkgver/pkgrel env-driven (PF_PKGVER/PF_PKGREL), source=() when
PF_SRCDIR is set (a canary version has no tag to clone), stale NVENC-only
header fixed, and options=('!lto' '!debug') — makepkg's lto option injects
-flto=auto into CFLAGS, aws-lc-sys compiles its C with it, and rust's lld
cannot read GCC LTO bitcode: 'undefined symbol: aws_lc_*' at link (reproduced
minimally on Arch + rust 1.90). Full build + clean-container install
smoke-tested locally (binaries run, payload + scriptlets intact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler 2190dad2ad feat(packaging/bazzite): systemd-sysext replaces rpm-ostree layering as the primary install path
Layering is a last resort per the Bazzite docs (slows every OS update, can
block upgrades until removed); a sysext never enters an rpm-ostree
transaction, survives OS updates, and installs/updates with no reboot —
the mechanism Fedora Atomic ships via fedora-sysexts.

- build-sysext.sh wraps the built host+web RPMs into punktfunk-<V-R>-x86-64.raw:
  /etc payload relocated to /usr/share/punktfunk/etc (a sysext carries only
  /usr), the punktfunk-sysext helper embedded, ID=fedora + VERSION_ID pinned
  (merges on Bazzite via ID_LIKE; REFUSED after a major rebase instead of
  running soname-broken binaries — both behaviors validated live on Bazzite 43).
  SELinux labels are baked in as squashfs pseudo-xattrs from matchpathcon:
  unlabeled files run fine for user units but system daemons are DENIED
  (udev couldn't read the gamepad rule under enforcing) — validated on-glass.
  Refuses duplicate input package names (a stale noarch punktfunk-web next to
  the x86_64 one built a chimera image with the dead node launcher once).
- punktfunk-sysext.sh: install/update/status/remove against per-Fedora-major
  feeds (…/generic/punktfunk-sysext/f43[-canary]), SHA-256-verified, applies
  the udev/sysctl scriptlet work + /etc copies, prints the layering-migration
  hint. Live-validated on the .41 Bazzite box incl. service restart + web console.
- publish-sysext-feed.sh + rpm.yml: build + publish the image per matrix leg
  (fedver 43/44), canary feeds pruned to 6, stable release assets attached.
- update-punktfunk.sh warns when the sysext shadows a layered install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler 5b5ec15ead fix(client-linux): GL presenter — eglCreateImageKHR takes EGLint attribs, not EGLAttrib
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 5m47s
android / android (push) Successful in 3m18s
ci / rust (push) Successful in 1m35s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m35s
ci / bench (push) Successful in 4m57s
decky / build-publish (push) Successful in 15s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
deb / build-publish (push) Successful in 4m37s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
flatpak / build-publish (push) Successful in 4m18s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m57s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m14s
The KHR variant reads 32-bit attrib pairs; the pointer-sized array fed it
garbage and every plane import came back rejected (observed on-Deck; the
new fallback ladder caught it and demoted to software exactly as designed).
Also print the real EGL error enum instead of its discriminant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:32:06 +00:00
enricobuehler c9ff144492 Merge branch 'main' of git.unom.io:unom/punktfunk
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 1m49s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m51s
windows-host / package (push) Successful in 6m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m22s
deb / build-publish (push) Successful in 4m40s
ci / bench (push) Successful in 4m48s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
release / apple (push) Successful in 7m51s
decky / build-publish (push) Successful in 24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 58s
flatpak / build-publish (push) Successful in 4m12s
apple / screenshots (push) Successful in 5m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m41s
docker / deploy-docs (push) Successful in 20s
2026-07-04 14:29:40 +00:00
enricobuehler 7930d2f0f4 fix(core): split WIRE_VERSION from ABI_VERSION — new clients locked out of every deployed host
ABI_VERSION was doing double duty: the embeddable C surface AND the punktfunk/1
Hello/Welcome version that hosts equality-check. The WoL feature's v3 bump added
a client-local FFI function without changing a single wire byte — and every new
client started refusing against every deployed host ("ABI mismatch: client 3
host 2", observed live Deck → Bazzite). The wire now carries its own
WIRE_VERSION (still 2); ABI_VERSION stays 3 for the C header and the mgmt API's
informational field. Bump WIRE_VERSION only when the handshake/planes actually
change incompatibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:29:33 +00:00
31 changed files with 1131 additions and 250 deletions
+142
View File
@@ -0,0 +1,142 @@
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
# resolves today — the same sonames an up-to-date Arch box runs.
#
# Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry
# SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add +
# --lsign-key), then no SigLevel line is needed (pacman's default Required verifies):
# [punktfunk] # or [punktfunk-canary] for main-push builds
# Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
# NOTE: this token + the registry-held private key are the trust root — a token holder can
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
name: arch
on:
push:
branches: [main]
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: git.unom.io
OWNER: unom
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
image: docker.io/library/archlinux:base-devel
timeout-minutes: 90
env:
CARGO_HOME: /usr/local/cargo
steps:
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
- name: Install build + runtime-dev deps
run: |
pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake nasm pkgconf python \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
# it's AUR-only on Arch, so bootstrap the official binary.
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
}
bun --version
- uses: actions/checkout@v4
# Cache cargo's git dir too, not just the registry: the workspace includes
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
# is ever compiled here. Cached, that cost is paid once per runner.
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-arch-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-arch-
- name: Version + channel
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
esac
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
echo "REPO=$REPO" >> "$GITHUB_ENV"
echo "pacman $V-$R -> repo '$REPO'"
- name: Build packages (makepkg)
run: |
git config --global --add safe.directory "$PWD"
# libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI
# links -lcuda but the builder has no GPU; synthesize every cu* symbol the source
# references so a newly-added call can't silently break the link.
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then
STUB_C="$(mktemp --suffix=.c)"
for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C"
ln -sf libcuda.so.1 /usr/lib/libcuda.so
rm -f "$STUB_C"; ldconfig
echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols =="
fi
# makepkg refuses to run as root; deps are already installed above (-d skips the
# RPM-level check that can't see the script-installed bun anyway).
useradd -m builder
mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss
chown -R builder: "$PWD" "$CARGO_HOME"
sudo -u builder git config --global --add safe.directory "$PWD"
mkdir -p dist && chown builder: dist
cd packaging/arch
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
- name: Publish to the Gitea Arch registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for pkg in dist/*.pkg.tar.zst; do
echo "uploading $pkg"
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p')
ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p')
# A re-tagged release re-fires this workflow and the registry 409s on duplicate
# package versions — delete any prior copy first (404 on the first publish is fine).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO"
done
echo "published to $OWNER/arch/$REPO"
# On a real release, also attach the packages to the unified Gitea Release.
- name: Attach packages to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for pkg in dist/*.pkg.tar.zst; do
upsert_asset "$RID" "$pkg"
done
+28
View File
@@ -35,8 +35,10 @@ jobs:
include: include:
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base - image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
group: bazzite group: bazzite
fedver: 43
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin - image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
group: fedora-44 group: fedora-44
fedver: 44
container: container:
image: git.unom.io/unom/${{ matrix.image }}:latest image: git.unom.io/unom/${{ matrix.image }}:latest
timeout-minutes: 90 timeout-minutes: 90
@@ -53,6 +55,8 @@ jobs:
run: | run: |
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
dnf -y install gtk4-devel libadwaita-devel SDL3-devel dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# bun builds the punktfunk-web console (--with web). Baked into the image; install it # bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note). # here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
command -v bun >/dev/null || { command -v bun >/dev/null || {
@@ -117,6 +121,27 @@ jobs:
done done
echo "published to $OWNER/rpm/$GROUP" echo "published to $OWNER/rpm/$GROUP"
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
- name: Build the sysext image
run: |
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
- name: Publish the sysext feed
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
case "$GROUP" in
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
esac
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases # On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep # (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
# both on the release; canary builds live in the `*-canary` rpm groups (no release page). # both on the release; canary builds live in the `*-canary` rpm groups (no release page).
@@ -132,3 +157,6 @@ jobs:
base="$(basename "$rpm" .rpm)" base="$(basename "$rpm" .rpm)"
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm" upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
done done
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
Generated
+9 -9
View File
@@ -2129,7 +2129,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.7.2" version = "0.7.4"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2908,7 +2908,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2922,7 +2922,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2945,7 +2945,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2968,7 +2968,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2999,7 +2999,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3085,7 +3085,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.7.2" version = "0.7.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
+1 -1
View File
@@ -17,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.7.2" version = "0.7.4"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+3 -2
View File
@@ -83,8 +83,9 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| Platform | Install | Guide | | Platform | Install | Guide |
|--------|---------|-------| |--------|---------|-------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) | | **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) | | **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) | | **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) | | **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status). `punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
+1 -1
View File
@@ -80,7 +80,7 @@ const QamPanel: FC = () => {
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's {/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page → host row → games button). */} picker (fullscreen page → host row → games button). */}
{pins.pins.length > 0 && ( {pins.pins.length > 0 && (
<PanelSection title="Games"> <PanelSection title="Pinned Games">
{pins.pins.map((pin) => { {pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts); const { online } = resolvePinHost(pin, hosts);
return ( return (
+25 -26
View File
@@ -3,13 +3,14 @@
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in // can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad // the QAM's Games section; its header also launches the GTK client's on-screen gamepad
// library (`--browse`). // library (`--browse`).
import { DialogButton, Field, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui"; import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa"; import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend"; import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks"; import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam"; import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair"; import { PairModal } from "./pair";
import { RowActions, actionButton } from "./ui";
/** Human store tag (mirrors the GTK client's `store_label`). */ /** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string { export function storeLabel(store: string): string {
@@ -58,12 +59,6 @@ export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
void startStream(host, { launchId: pin.game_id }, pin.title); void startStream(host, { launchId: pin.game_id }, pin.title);
} }
const pickButton: CSSProperties = {
width: "fit-content",
minWidth: "5em",
flexShrink: 0,
};
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case. // Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
function errorCopy(res: LibraryResult): string { function errorCopy(res: LibraryResult): string {
switch (res.error) { switch (res.error) {
@@ -143,16 +138,18 @@ export const GamePickerModal: FC<{
description="Browse this host's games with the controller, full screen" description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={pickButton} <DialogButton
onClick={() => { style={actionButton}
closeModal?.(); onClick={() => {
void startBrowse(host); closeModal?.();
}} void startBrowse(host);
> }}
<FaTv style={{ marginRight: "0.4em" }} /> >
Open <FaTv style={{ marginRight: "0.4em" }} />
</DialogButton> Open
</DialogButton>
</RowActions>
</Field> </Field>
{clientUpdatePending && ( {clientUpdatePending && (
@@ -177,10 +174,10 @@ export const GamePickerModal: FC<{
{result !== null && !result.ok && ( {result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max"> <Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
{result.error === "not-paired" && ( {result.error === "not-paired" && (
<DialogButton <DialogButton
style={pickButton} style={actionButton}
onClick={() => onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />) showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
} }
@@ -188,10 +185,10 @@ export const GamePickerModal: FC<{
Pair Pair
</DialogButton> </DialogButton>
)} )}
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}> <DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
Retry Retry
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
)} )}
@@ -217,10 +214,12 @@ export const GamePickerModal: FC<{
} }
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}> <RowActions>
<FaThLarge style={{ marginRight: "0.4em" }} /> <DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
{pinned ? "Unpin" : "Pin"} <FaThLarge style={{ marginRight: "0.4em" }} />
</DialogButton> {pinned ? "Unpin" : "Pin"}
</DialogButton>
</RowActions>
</Field> </Field>
); );
})} })}
+58 -66
View File
@@ -10,6 +10,7 @@ import {
showModal, showModal,
staticClasses, staticClasses,
} from "@decky/ui"; } from "@decky/ui";
import { RowActions, actionButton, iconButton } from "./ui";
import { toaster } from "@decky/api"; import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react"; import { CSSProperties, FC, useState } from "react";
import { import {
@@ -58,27 +59,6 @@ const tabScroll: CSSProperties = {
boxSizing: "border-box", boxSizing: "border-box",
}; };
// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a
// screen-wide button. Size action buttons to their content instead (right-aligned by the
// Field's children container).
const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "6em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or
// the zero padding collapses it to the icon's line height.
const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check // Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
// against the host's own log / web console before trusting it. // against the host's own log / web console before trusting it.
@@ -144,7 +124,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
}`} }`}
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
<DialogButton <DialogButton
style={iconButton} style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} />)} onClick={() => showModal(<HostDetailsModal host={host} />)}
@@ -153,13 +133,13 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
</DialogButton> </DialogButton>
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen {/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */} library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}> <DialogButton style={actionButton} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} /> <FaThLarge style={{ marginRight: "0.4em" }} />
Games Games
</DialogButton> </DialogButton>
{needsPair && ( {needsPair && (
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "5em" }} style={actionButton}
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)} onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
> >
Pair Pair
@@ -178,7 +158,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
<FaPlay style={{ marginRight: "0.4em" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
Stream Stream
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
); );
}; };
@@ -201,14 +181,16 @@ const HostsTab: FC<{
childrenContainerWidth="max" childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"} bottomSeparator={hosts.length ? "standard" : "none"}
> >
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}> <RowActions>
{scanning ? ( <DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
<Spinner style={{ height: "1em", marginRight: "0.5em" }} /> {scanning ? (
) : ( <Spinner style={{ height: "1em", marginRight: "0.5em" }} />
<FaSyncAlt style={{ marginRight: "0.5em" }} /> ) : (
)} <FaSyncAlt style={{ marginRight: "0.5em" }} />
{scanning ? "Scanning…" : "Refresh"} )}
</DialogButton> {scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</RowActions>
</Field> </Field>
{hosts.length === 0 && !scanning && ( {hosts.length === 0 && !scanning && (
@@ -251,18 +233,18 @@ const HostsTab: FC<{
}${pin.paired ? "" : " · pairing required"}`} }${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}> <DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
<FaPlay style={{ marginRight: "0.4em" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
Play Play
</DialogButton> </DialogButton>
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "5em" }} style={actionButton}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)} onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
> >
Remove Remove
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
); );
})} })}
@@ -306,13 +288,15 @@ const AboutTab: FC<{
} }
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={{ ...actionButton, minWidth: "11em" }} <DialogButton
disabled={checking} style={actionButton}
onClick={() => void checkForUpdatesNow(check)} disabled={checking}
> onClick={() => void checkForUpdatesNow(check)}
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"} >
</DialogButton> {checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton>
</RowActions>
</Field> </Field>
{hasUpdate(update) && ( {hasUpdate(update) && (
<Field <Field
@@ -326,13 +310,12 @@ const AboutTab: FC<{
description="Installing can take a couple of minutes; Decky reloads the plugin when done" description="Installing can take a couple of minutes; Decky reloads the plugin when done"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={{ ...actionButton, minWidth: "9em" }} <DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
onClick={() => applyUpdate(update!, check)} <FaDownload style={{ marginRight: "0.4em" }} />
> Update
<FaDownload style={{ marginRight: "0.4em" }} /> </DialogButton>
Update </RowActions>
</DialogButton>
</Field> </Field>
)} )}
<Field <Field
@@ -340,13 +323,15 @@ const AboutTab: FC<{
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io" description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={{ ...actionButton, minWidth: "8em" }} <DialogButton
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)} style={actionButton}
> onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} /> >
Open <FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
</DialogButton> Open
</DialogButton>
</RowActions>
</Field> </Field>
<Field <Field
focusable={false} focusable={false}
@@ -358,9 +343,11 @@ const AboutTab: FC<{
description="Force-stop the stream client if a session wedges" description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}> <RowActions>
Force-stop <DialogButton style={actionButton} onClick={() => void forceStopStream()}>
</DialogButton> Force-stop
</DialogButton>
</RowActions>
</Field> </Field>
</div> </div>
); );
@@ -399,16 +386,21 @@ const PunktfunkPage: FC = () => {
</div> </div>
</Focusable> </Focusable>
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the {/* Two things fight each other on an L1/R1 tab switch:
right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that 1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole 2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
live in a clipped flex box; match that. */} position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
up and pans the whole page — the "screen jumps right, then animates back" glitch.
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
strip and Down enters the content, which is how Steam's own tabbed pages behave.
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}> <div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs <Tabs
activeTab={tab} activeTab={tab}
onShowTab={(id: string) => setTab(id)} onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[ tabs={[
{ {
id: "hosts", id: "hosts",
+52 -24
View File
@@ -2,8 +2,20 @@
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The // the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`. // accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui"; import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { FC, useEffect, useState } from "react"; import { CSSProperties, FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend"; import { getSettings, setSettings, StreamSettings } from "./backend";
import { RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
// edge. Matches the right-aligned, content-sized buttons everywhere else.
const selectShell: CSSProperties = {
width: "fit-content",
minWidth: "10em",
maxWidth: "24em",
};
const RESOLUTIONS: [number, number, string][] = [ const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"], [0, 0, "Native display"],
@@ -61,21 +73,29 @@ export const SettingsSection: FC = () => {
description="The host creates a virtual output at exactly this size" description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Dropdown <RowActions>
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))} <div style={selectShell}>
selectedOption={resIdx} <Dropdown
onChange={(o) => { rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
const [w, h] = RESOLUTIONS[o.data as number]; selectedOption={resIdx}
patch({ width: w, height: h }); onChange={(o) => {
}} const [w, h] = RESOLUTIONS[o.data as number];
/> patch({ width: w, height: h });
}}
/>
</div>
</RowActions>
</Field> </Field>
<Field label="Refresh rate" childrenContainerWidth="max"> <Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown <RowActions>
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} <div style={selectShell}>
selectedOption={s.refresh_hz} <Dropdown
onChange={(o) => patch({ refresh_hz: o.data as number })} rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
/> selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</div>
</RowActions>
</Field> </Field>
<SliderField <SliderField
label="Bitrate" label="Bitrate"
@@ -93,11 +113,15 @@ export const SettingsSection: FC = () => {
description="Which virtual controller the host creates for your inputs" description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Dropdown <RowActions>
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} <div style={selectShell}>
selectedOption={s.gamepad} <Dropdown
onChange={(o) => patch({ gamepad: o.data as string })} rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
/> selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</div>
</RowActions>
</Field> </Field>
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( {(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field <Field
@@ -110,11 +134,15 @@ export const SettingsSection: FC = () => {
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host" description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Dropdown <RowActions>
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))} <div style={selectShell}>
selectedOption={s.compositor} <Dropdown
onChange={(o) => patch({ compositor: o.data as string })} rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
/> selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })}
/>
</div>
</RowActions>
</Field> </Field>
<ToggleField <ToggleField
label="Stream microphone" label="Stream microphone"
+46
View File
@@ -0,0 +1,46 @@
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
// them and the label text — never hugging it.
//
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
import { Focusable } from "@decky/ui";
import { CSSProperties, FC, ReactNode } from "react";
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
<Focusable
style={{
display: "flex",
gap: "0.5em",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{children}
</Focusable>
);
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
// reads at the same weight.
export const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "7em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
// padding collapses it to the icon's line height.
export const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
+22 -20
View File
@@ -30,15 +30,17 @@ use std::sync::{Arc, Mutex};
// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------ // --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------
const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270; const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270;
const EGL_LINUX_DRM_FOURCC_EXT: usize = 0x3271; // eglCreateImageKHR takes 32-bit EGLint attribs (the core-1.5 eglCreateImage variant is the
const EGL_DMA_BUF_PLANE0_FD_EXT: usize = 0x3272; // one with pointer-sized EGLAttrib) — using the wrong width feeds the driver garbage pairs.
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: usize = 0x3273; const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271;
const EGL_DMA_BUF_PLANE0_PITCH_EXT: usize = 0x3274; const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272;
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: usize = 0x3443; const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273;
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: usize = 0x3444; const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274;
const EGL_WIDTH: usize = 0x3057; const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443;
const EGL_HEIGHT: usize = 0x3056; const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444;
const EGL_NONE: usize = 0x3038; const EGL_WIDTH: i32 = 0x3057;
const EGL_HEIGHT: i32 = 0x3056;
const EGL_NONE: i32 = 0x3038;
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff; const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when /// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when
@@ -140,7 +142,7 @@ type EglCreateImageKhr = unsafe extern "C" fn(
*mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf) *mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf)
egl::Enum, egl::Enum,
*mut c_void, // EGLClientBuffer (null for dmabuf) *mut c_void, // EGLClientBuffer (null for dmabuf)
*const usize, *const i32, // EGLint attrib list (KHR variant — NOT pointer-sized EGLAttrib)
) -> *const c_void; ) -> *const c_void;
type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean; type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean;
@@ -464,24 +466,24 @@ impl GlConverter {
) -> Result<*const c_void> { ) -> Result<*const c_void> {
let mut attribs = vec![ let mut attribs = vec![
EGL_WIDTH, EGL_WIDTH,
width as usize, width as i32,
EGL_HEIGHT, EGL_HEIGHT,
height as usize, height as i32,
EGL_LINUX_DRM_FOURCC_EXT, EGL_LINUX_DRM_FOURCC_EXT,
fourcc as usize, fourcc as i32,
EGL_DMA_BUF_PLANE0_FD_EXT, EGL_DMA_BUF_PLANE0_FD_EXT,
plane.fd as usize, plane.fd,
EGL_DMA_BUF_PLANE0_OFFSET_EXT, EGL_DMA_BUF_PLANE0_OFFSET_EXT,
plane.offset as usize, plane.offset as i32,
EGL_DMA_BUF_PLANE0_PITCH_EXT, EGL_DMA_BUF_PLANE0_PITCH_EXT,
plane.stride as usize, plane.stride as i32,
]; ];
if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 { if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 {
attribs.extend_from_slice(&[ attribs.extend_from_slice(&[
EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT,
(modifier & 0xffff_ffff) as usize, (modifier & 0xffff_ffff) as u32 as i32,
EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT,
(modifier >> 32) as usize, (modifier >> 32) as u32 as i32,
]); ]);
} }
attribs.push(EGL_NONE); attribs.push(EGL_NONE);
@@ -497,12 +499,12 @@ impl GlConverter {
}; };
if img.is_null() { if img.is_null() {
bail!( bail!(
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:#x}", "eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:?}",
width, width,
height, height,
fourcc, fourcc,
modifier, modifier,
self.egl.get_error().map(|e| e as u32).unwrap_or(0) self.egl.get_error()
); );
} }
Ok(img) Ok(img)
+1 -1
View File
@@ -412,7 +412,7 @@ async fn session(args: Args) -> Result<()> {
io::write_msg( io::write_msg(
&mut send, &mut send,
&Hello { &Hello {
abi_version: punktfunk_core::ABI_VERSION, abi_version: punktfunk_core::WIRE_VERSION,
mode: args.mode, mode: args.mode,
compositor: args.compositor, compositor: args.compositor,
gamepad: args.gamepad, gamepad: args.gamepad,
+1 -1
View File
@@ -876,7 +876,7 @@ async fn worker_main(args: WorkerArgs) {
io::write_msg( io::write_msg(
&mut send, &mut send,
&Hello { &Hello {
abi_version: crate::ABI_VERSION, abi_version: crate::WIRE_VERSION,
mode, mode,
compositor, compositor,
gamepad, gamepad,
+8
View File
@@ -54,3 +54,11 @@ pub use stats::Stats;
/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach /// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). /// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
pub const ABI_VERSION: u32 = 3; pub const ABI_VERSION: u32 = 3;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
/// (functions a client links), which can grow without changing a single wire byte — v3's
/// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
/// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
/// live). Bump this ONLY when the handshake/planes actually change incompatibly.
pub const WIRE_VERSION: u32 = 2;
+7 -7
View File
@@ -585,10 +585,10 @@ async fn serve_session(
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible. // the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!( anyhow::ensure!(
gate_hello.abi_version == punktfunk_core::ABI_VERSION, gate_hello.abi_version == punktfunk_core::WIRE_VERSION,
"ABI mismatch: client {} host {}", "wire version mismatch: client {} host {}",
gate_hello.abi_version, gate_hello.abi_version,
punktfunk_core::ABI_VERSION punktfunk_core::WIRE_VERSION
); );
let fp = endpoint::peer_fingerprint(&conn); let fp = endpoint::peer_fingerprint(&conn);
let known = fp let known = fp
@@ -654,10 +654,10 @@ async fn serve_session(
let handshake = async { let handshake = async {
let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!( anyhow::ensure!(
hello.abi_version == punktfunk_core::ABI_VERSION, hello.abi_version == punktfunk_core::WIRE_VERSION,
"ABI mismatch: client {} host {}", "wire version mismatch: client {} host {}",
hello.abi_version, hello.abi_version,
punktfunk_core::ABI_VERSION punktfunk_core::WIRE_VERSION
); );
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above, // The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
// before this future, so a client reaching here is paired (or the host is `--open`). // before this future, so a client reaching here is paired (or the host is `--open`).
@@ -805,7 +805,7 @@ async fn serve_session(
let mut key = [0u8; 16]; let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key); rand::thread_rng().fill_bytes(&mut key);
let welcome = Welcome { let welcome = Welcome {
abi_version: punktfunk_core::ABI_VERSION, abi_version: punktfunk_core::WIRE_VERSION,
udp_port, udp_port,
mode: hello.mode, mode: hello.mode,
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption. // The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
+134
View File
@@ -0,0 +1,134 @@
---
title: Arch Linux
description: Install a punktfunk host on Arch (and Arch-derived distros) from the signed pacman binary repo.
---
Set up a punktfunk host on **Arch Linux** (or an Arch-derived distro like CachyOS/EndeavourOS). The
host installs from a **signed pacman binary repo**, so it updates with `pacman -Syu` like the rest
of your system — no building required. Host encode is **NVENC on NVIDIA** and **VAAPI on
AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks per GPU).
> New here? Read [Security & Safe Use](/docs/security) first — a streaming host is remote control of
> the machine, so keep it on a trusted LAN or VPN and require pairing.
> Prefer to build it yourself? A split `PKGBUILD` (host + client + optional web console) is in the
> repo at `packaging/arch/` — see the [appendix](#appendix--build-from-source-pkgbuild). The binary
> repo below is the supported path.
## 1. GPU prerequisites
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils` (provides NVENC + the EGL/CUDA zero-copy path).
Arch's stock `ffmpeg` already has NVENC built in — no RPM-Fusion-style swap like Fedora needs.
- **AMD / Intel:** the Mesa stack (`mesa`, `libva-mesa-driver` for AMD, `intel-media-driver` for
Intel) provides the VAAPI encoder — usually already installed on a desktop.
## 2. Add the signed repo
The registry **signs its database and every package**, so first trust its key once (after this,
packages install signature-verified):
```sh
# Trust the registry signing key.
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
| sudo pacman-key --add -
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
# verifies signed packages against the key you just trusted.
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
```
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
> release is cut. For the latest `main` build, use `[punktfunk-canary]` instead (same `Server` line,
> just the repo name). Enable exactly one. See [Release Channels](/docs/channels).
## 3. Install the host
```sh
sudo pacman -Sy punktfunk-host # the streaming host
sudo pacman -S punktfunk-web # optional: the browser management console (pairing + status)
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
`punktfunk-client` (the GTK4 couch/Deck client) is in the same repo if this box is also a client.
The host package ships the systemd **user** units, the udev rule, the UDP socket-buffer sysctl
tuning, and example configs. Updates later are just `sudo pacman -Syu`.
## 4. Configure and run
The host runs as a systemd **`--user`** service — it needs your session's PipeWire and D-Bus.
Copy a starting config, enable the service, and enable linger so it starts at boot without a login:
```sh
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env # then edit
systemctl --user daemon-reload
systemctl --user enable --now punktfunk-host
sudo loginctl enable-linger "$USER"
```
Which compositor the host captures depends on your desktop — it drives a per-client virtual output
via KWin (Plasma), Mutter (GNOME), or wlroots (Sway), or spawns a headless **gamescope** session
per connect. For a headless appliance, the package also ships `punktfunk-kde-session.service`
(a dedicated `kwin --virtual` session, same as the [Fedora KDE](/docs/fedora-kde#3-kwin-streaming-session)
guide — `cp /usr/share/punktfunk/host.env.kde ~/.config/punktfunk/host.env` and enable it alongside
the host). See [Configuration](/docs/configuration) for every knob and
[Running as a Service](/docs/running-as-a-service) for the service model.
Check it came up:
```sh
systemctl --user status punktfunk-host # active
journalctl --user -u punktfunk-host -f # watch a client connect
```
### Web console
The console (status, paired devices, arm pairing) ships as `punktfunk-web` — enable it, then open
`http://<host-ip>:47992`:
```sh
systemctl --user enable --now punktfunk-web
```
#### Console login password
On first start `punktfunk-web-init` generates a random login password and saves it to
`~/.config/punktfunk/web-password` (as `PUNKTFUNK_UI_PASSWORD=…`). Read it back at any time:
```sh
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
```
To set your own, edit that file and `systemctl --user restart punktfunk-web`. Forgot it? See
[Forgot your Password?](/docs/forgot-password).
## 5. Connect a client
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
the **PIN pairing** — arm it from the host's web console, which displays a 4-digit PIN to type into
the client. (Pairing is required by default; pass `serve --open` only if you deliberately want to
disable it.) See [Clients](/docs/clients) and [Pairing](/docs/pairing).
## Appendix — build from source (PKGBUILD)
To build instead of using the binary repo, use the split `PKGBUILD` in `packaging/arch/` (produces
`punktfunk-host` + `punktfunk-client`; set `PF_WITH_WEB=1` to also build `punktfunk-web`, which needs
`bun`):
```sh
git clone https://git.unom.io/unom/punktfunk.git && cd punktfunk/packaging/arch
# Build the working tree (no git fetch):
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
sudo pacman -U punktfunk-host-*.pkg.tar.zst
```
NVENC/EGL come from the NVIDIA driver (`nvidia-utils`); on a GPU-less builder, symlink the CUDA
stub into the link path first (the `PKGBUILD` header documents this). Full details, the
Fedora→Arch dependency map, and the SteamOS systemd-sysext path are in
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md).
+33 -26
View File
@@ -24,36 +24,43 @@ mid-stream. You flip between Gaming Mode and Desktop with Bazzite's normal Steam
## Install ## Install
The host ships as an RPM in punktfunk's **Gitea RPM registry** (public), so a Bazzite / Fedora The host installs as a **systemd system extension (sysext)** — no `rpm-ostree` layering. The
Atomic box layers and updates it with `rpm-ostree`. Add the repo, then layer the host plus the web Bazzite docs treat layering as a last resort (layered packages slow every OS update and can block
console and reboot: upgrades until removed); a sysext never enters an rpm-ostree transaction: it overlays `/usr`
read-only from `/var/lib/extensions/`, survives OS updates, installs and updates **without a
reboot**, and is removable in one command. This is the same mechanism the Fedora Atomic
maintainers ship via the [fedora-sysexts](https://fedora-sysexts.github.io/) project.
```sh ```sh
# Add the repo. Packages are GPG-signed (gpgcheck=1, the packages@unom.io key) AND the repo # One-time bootstrap (afterwards the updater is on PATH as `punktfunk-sysext`):
# metadata is Gitea-signed (repo_gpgcheck=1); gpgkey lists both keys so dnf imports each. curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO' sudo bash punktfunk-sysext.sh install # add `--channel canary` for rolling builds
[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, then reboot into the new deployment.
# (punktfunk Recommends punktfunk-web; list it explicitly so it's pulled regardless of weak-dep
# settings — the Gitea registry carries punktfunk-web, which COPR can't build.)
rpm-ostree install punktfunk punktfunk-web
systemctl reboot
``` ```
`rpm-ostree upgrade` then tracks new builds automatically (Bazzite's auto-update timer does this That downloads the newest image (host + tray + web console, SHA-256-verified over HTTPS from
for you). For a fully baked appliance image there's also a **bootc** Containerfile that installs punktfunk's package registry), merges it, and applies the udev/sysctl setup on the spot — the
the same RPMs from this registry — see `packaging/bootc/` and `packaging/rpm/README.md` in the repo. host is usable immediately, no reboot. From then on:
Building from source works too (Bazzite is Fedora Atomic underneath, and its FFmpeg builds the host
fine — same steps as [Fedora KDE](/docs/fedora-kde)), but the registry is the supported path. ```sh
sudo punktfunk-sysext update # fetch + merge the newest build
sudo punktfunk-sysext status # channel, installed vs latest version
sudo punktfunk-sysext remove # unmerge and delete — the box is back to stock
```
Two things to know:
- **After a Bazzite major rebase** (Fedora 43 → 44) the old image **refuses to load** rather than
run against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches
the image built for the new base.
- **Already layering punktfunk?** Install the sysext (it shadows the layered copy immediately),
then drop the layer so it stops slowing your updates:
`sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
For a fully baked appliance image there's also a **bootc** Containerfile that installs the RPMs
from the registry at image-build time — see `packaging/bootc/` in the repo. Plain `rpm-ostree`
layering from the [RPM registry](https://git.unom.io/unom/-/packages) keeps working too (see
`packaging/bazzite/README.md`), but the sysext is the supported default. Building from source
also works (Bazzite is Fedora Atomic underneath — same steps as [Fedora KDE](/docs/fedora-kde)).
## Allow controller input ## Allow controller input
+2
View File
@@ -25,6 +25,8 @@ track per machine; switching is a one-line change.
|---|---|---| |---|---|---|
| **apt** (host/client) | `deb [signed-by=…] https://git.unom.io/api/packages/unom/debian canary main` | `… debian stable main` | | **apt** (host/client) | `deb [signed-by=…] https://git.unom.io/api/packages/unom/debian canary main` | `… debian stable main` |
| **rpm** (host) | baseurl `…/rpm/bazzite-canary` (or `fedora-44-canary`) | `…/rpm/bazzite` (or `fedora-44`) | | **rpm** (host) | baseurl `…/rpm/bazzite-canary` (or `fedora-44-canary`) | `…/rpm/bazzite` (or `fedora-44`) |
| **sysext** (Bazzite host) | `sudo punktfunk-sysext install --channel canary` | `… install` / default (feeds `…/punktfunk-sysext/f43[-canary]`) |
| **pacman** (Arch host/client) | `[punktfunk-canary]` repo section | `[punktfunk]` (`Server = …/api/packages/unom/arch/$repo/$arch`) |
| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` | | **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` |
| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` | | **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` |
| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page | | **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page |
+1 -1
View File
@@ -47,7 +47,7 @@ It ships as a real package, not just a source build — full steps in
`flatpak update`; this is also what the [Decky plugin](/docs/steam-deck) launches. `flatpak update`; this is also what the [Decky plugin](/docs/steam-deck) launches.
- **Ubuntu / Debian** — `apt install punktfunk-client` from the punktfunk apt registry. - **Ubuntu / Debian** — `apt install punktfunk-client` from the punktfunk apt registry.
- **Fedora / Bazzite** — `rpm-ostree install punktfunk-client` from the Gitea RPM registry. - **Fedora / Bazzite** — `rpm-ostree install punktfunk-client` from the Gitea RPM registry.
- **Arch / SteamOS** — the `punktfunk-client` split package from the `PKGBUILD`. - **Arch** — `sudo pacman -Sy punktfunk-client` from the signed binary repo (see [Arch Linux](/docs/arch)).
Launch it, pick your host from the list, and stream. For scripting you can skip the host list and Launch it, pick your host from the list, and stream. For scripting you can skip the host list and
connect straight away: connect straight away:
+1 -1
View File
@@ -48,7 +48,7 @@ see the linked guide — then it tracks updates with your normal `apt upgrade` /
|--------|---------|-------| |--------|---------|-------|
| **Ubuntu / Debian** | `sudo apt install punktfunk-client` | [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) | | **Ubuntu / Debian** | `sudo apt install punktfunk-client` | [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Fedora / Bazzite** | `rpm-ostree install punktfunk-client` | [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) | | **Fedora / Bazzite** | `rpm-ostree install punktfunk-client` | [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch / SteamOS** | `punktfunk-client` from the `PKGBUILD` | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) | | **Arch** | `sudo pacman -Sy punktfunk-client` (signed binary repo) | [Arch Linux](/docs/arch) |
Then launch it, pick your host from the list, and stream. For scripting, skip the picker: Then launch it, pick your host from the list, and stream. For scripting, skip the picker:
+7 -5
View File
@@ -17,13 +17,14 @@ On **Windows**, the host ships as a signed installer instead — see [Windows](#
| Distro | Package manager | One-command happy path | Guide | | Distro | Package manager | One-command happy path | Guide |
|--------|-----------------|------------------------|-------| |--------|-----------------|------------------------|-------|
| **Ubuntu / Debian** | apt | `sudo apt install punktfunk-host` | [Ubuntu — GNOME](/docs/ubuntu-gnome) · [Ubuntu — KDE](/docs/ubuntu-kde) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) | | **Ubuntu / Debian** | apt | `sudo apt install punktfunk-host` | [Ubuntu — GNOME](/docs/ubuntu-gnome) · [Ubuntu — KDE](/docs/ubuntu-kde) · [packaging/debian](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/debian/README.md) |
| **Fedora / Bazzite** | rpm-ostree | `rpm-ostree install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [Bazzite](/docs/bazzite) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) | | **Bazzite / Fedora Atomic** | systemd-sysext | `sudo bash punktfunk-sysext.sh install` (no layering, no reboot) | [Bazzite](/docs/bazzite) · [packaging/bazzite](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/bazzite/README.md) |
| **Arch** | PKGBUILD | `makepkg -si` | [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) | | **Fedora (dnf)** | dnf / rpm-ostree | `dnf install punktfunk punktfunk-web` | [Fedora — KDE](/docs/fedora-kde) · [packaging/rpm](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/rpm/README.md) |
| **Arch** | pacman | `pacman -Sy punktfunk-host` (binary repo) | [Arch Linux](/docs/arch) · [packaging/arch](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md) |
| **SteamOS (host)** | on-device script | `bash scripts/steamdeck/install.sh` | [SteamOS (Host)](/docs/steamos-host) | | **SteamOS (host)** | on-device script | `bash scripts/steamdeck/install.sh` | [SteamOS (Host)](/docs/steamos-host) |
Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a Each registry is public — no auth, you just trust the repo's signing key. Adding the repo is a
one-time step covered in the linked guide; after that, normal `apt upgrade` / `rpm-ostree upgrade` one-time step covered in the linked guide; after that, normal `apt upgrade` / `dnf upgrade` /
tracks new builds automatically. `pacman -Syu` (or `sudo punktfunk-sysext update` on Bazzite) tracks new builds.
> **Stable vs canary.** The repos in the per-distro guides are the **stable** channel — it only > **Stable vs canary.** The repos in the per-distro guides are the **stable** channel — it only
> moves when a `vX.Y.Z` release is cut. For the latest `main` build (fast, possibly broken), point > moves when a `vX.Y.Z` release is cut. For the latest `main` build (fast, possibly broken), point
@@ -59,7 +60,8 @@ fallback without one. More detail — including the CLI `punktfunk-host service
- **`punktfunk-host`** — the streaming host. Install this on your Linux gaming machine. - **`punktfunk-host`** — the streaming host. Install this on your Linux gaming machine.
- **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the - **`punktfunk-web`** — the browser management console (pairing + status). Recommended alongside the
host; on RPM list it explicitly (`rpm-ostree install punktfunk punktfunk-web`). host; on RPM list it explicitly (`dnf install punktfunk punktfunk-web`) — the Bazzite sysext
image already includes it.
- **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via - **`punktfunk-client`** — the GTK4 desktop client, for streaming *to* a Linux box (also shipped via
apt / RPM / Arch / Flatpak). On a Steam Deck, this is the package you want. apt / RPM / Arch / Flatpak). On a Steam Deck, this is the package you want.
+1
View File
@@ -11,6 +11,7 @@
"ubuntu-gnome", "ubuntu-gnome",
"ubuntu-kde", "ubuntu-kde",
"fedora-kde", "fedora-kde",
"arch",
"bazzite", "bazzite",
"steamos-host", "steamos-host",
"windows-host", "windows-host",
+8
View File
@@ -21,6 +21,14 @@
// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). // clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
#define ABI_VERSION 3 #define ABI_VERSION 3
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
// (functions a client links), which can grow without changing a single wire byte — v3's
// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
// live). Bump this ONLY when the handshake/planes actually change incompatibly.
#define WIRE_VERSION 2
// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid). // `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
#define PUNKTFUNK_HIDOUT_LED 1 #define PUNKTFUNK_HIDOUT_LED 1
+25 -8
View File
@@ -17,13 +17,15 @@ packaging/
rpm/punktfunk.spec # the RPM (builds punktfunk-host from source with cargo) rpm/punktfunk.spec # the RPM (builds punktfunk-host from source with cargo)
bazzite/host.env # gamescope-default config for a Bazzite appliance bazzite/host.env # gamescope-default config for a Bazzite appliance
bazzite/README.md # step-by-step Bazzite setup guide bazzite/README.md # step-by-step Bazzite setup guide
bazzite/*sysext*.sh # the no-layering path: build/install/publish the systemd-sysext
bootc/Containerfile # bake punktfunk into a Bazzite-based atomic image bootc/Containerfile # bake punktfunk into a Bazzite-based atomic image
copr/ # COPR build-from-SCM settings copr/ # COPR build-from-SCM settings
``` ```
The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt), The other packaging targets have their own READMEs: [`debian/`](debian/README.md) (apt),
[`arch/`](arch/README.md) (PKGBUILD + sysext), [`flatpak/`](flatpak/README.md) (the client), [`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext),
[`windows/`](windows/README.md) (host installer + drivers), plus `kde/` and `linux/` helpers. [`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer +
drivers), plus `kde/` and `linux/` helpers.
## What's needed beyond base Fedora ## What's needed beyond base Fedora
@@ -38,7 +40,22 @@ On **Bazzite** the only genuinely new runtime bits are `ffmpeg-libs` (RPM Fusion
`libei` — the rest of the stack is already there. The default backend is **gamescope** `libei` — the rest of the stack is already there. The default backend is **gamescope**
(`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login. (`packaging/bazzite/host.env`), which the host spawns headless per session — no desktop login.
## Option A — Gitea RPM registry (recommended; per-host, `rpm-ostree`) ## Option A — systemd-sysext (recommended; no layering, no reboot)
On Bazzite / Fedora Atomic the recommended install is the **systemd-sysext** image — rpm-ostree
layering is a last resort per the Bazzite docs (it slows every OS update and can block upgrades),
while a sysext overlays `/usr` at runtime, survives OS updates, and updates in one command with
no reboot. CI wraps the same RPMs below into the image, so content and channels are identical.
```sh
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
sudo bash punktfunk-sysext.sh install # then: sudo punktfunk-sysext update | status | remove
```
Full walkthrough (incl. the F43→F44 rebase behavior and migration off layering):
[`bazzite/README.md`](bazzite/README.md).
## Option B — Gitea RPM registry (per-host, `rpm-ostree` layering)
The host's RPM is published to **unom's self-hosted Gitea RPM registry** (CI builds it on every The host's RPM is published to **unom's self-hosted Gitea RPM registry** (CI builds it on every
push), mirroring the [Debian/apt](debian/README.md) setup. Add one repo file, install, and track push), mirroring the [Debian/apt](debian/README.md) setup. Add one repo file, install, and track
@@ -60,7 +77,7 @@ rpm-ostree install punktfunk && systemctl reboot
# updates: rpm-ostree upgrade && systemctl reboot # updates: rpm-ostree upgrade && systemctl reboot
``` ```
## Option B — COPR (per-host, `rpm-ostree install`) ## Option C — COPR (per-host, `rpm-ostree install`)
1. Create a COPR project, enable **build-from-SCM** pointing at this repo, spec path 1. Create a COPR project, enable **build-from-SCM** pointing at this repo, spec path
`packaging/rpm/punktfunk.spec` (see `copr/README.md`). Under *External Repositories* add `packaging/rpm/punktfunk.spec` (see `copr/README.md`). Under *External Repositories* add
@@ -78,7 +95,7 @@ rpm-ostree install punktfunk && systemctl reboot
systemctl reboot systemctl reboot
``` ```
## Option C — bootc (image-based, atomic) ## Option D — bootc (image-based, atomic)
Layer punktfunk into a Bazzite image once, then rebase any number of hosts onto it — no Layer punktfunk into a Bazzite image once, then rebase any number of hosts onto it — no
per-host drift. See `bootc/Containerfile`: per-host drift. See `bootc/Containerfile`:
@@ -89,7 +106,7 @@ podman push ghcr.io/<you>/bazzite-punktfunk
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
``` ```
## First-run setup (either option) ## First-run setup (all options)
```sh ```sh
ujust add-user-to-input-group # virtual gamepads need /dev/uinput (then re-login). ujust add-user-to-input-group # virtual gamepads need /dev/uinput (then re-login).
@@ -109,8 +126,8 @@ web console at `https://<host-ip>:47992` or directly.
> ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only > ⚠️ **COPR caveat:** COPR's mock chroot has no `bun`, so a COPR build produces only
> `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host, > `punktfunk` + `punktfunk-client` — **not** `punktfunk-web`. For the console on a COPR/bootc host,
> install from the **Gitea RPM registry** (Option A — it carries `punktfunk-web`), which is also why > install from the **Gitea RPM registry** (Option B — it carries `punktfunk-web`; the sysext image
> `bootc/Containerfile` installs from there rather than COPR. > includes it too), which is also why `bootc/Containerfile` installs from there rather than COPR.
## Why not Flatpak (for the HOST)? ## Why not Flatpak (for the HOST)?
+23 -9
View File
@@ -10,20 +10,28 @@
# - In-tree / CI: PF_SRCDIR=$(git rev-parse --show-toplevel) makepkg --holdver # - In-tree / CI: PF_SRCDIR=$(git rev-parse --show-toplevel) makepkg --holdver
# (builds the working tree instead of the tagged source — see build()). # (builds the working tree instead of the tagged source — see build()).
# #
# IMPORTANT: host encode is NVENC-only (crates/punktfunk-host/src/encode/linux.rs) — functional on # Host encode: NVENC on NVIDIA (nvidia-utils), VAAPI on AMD/Intel (mesa) — PUNKTFUNK_ENCODER=auto
# NVIDIA hosts; an AMD Deck-as-HOST needs a VAAPI backend first. The CLIENT decodes via VAAPI # picks per GPU. The CLIENT decodes via VAAPI (AMD/Intel, incl. the Deck) with a software
# (AMD/Intel, incl. the Deck) with a software fallback, so it works everywhere. See README.md. # fallback, so it works everywhere. See README.md.
pkgbase=punktfunk pkgbase=punktfunk
# punktfunk-web (the browser console) is OPT-IN: building it needs `bun` (AUR-only as bun-bin on # punktfunk-web (the browser console) is OPT-IN: building it needs `bun` (AUR-only as bun-bin on
# stock Arch/SteamOS), so a default makepkg builds only host+client with no JS tooling — mirroring # stock Arch/SteamOS), so a default makepkg builds only host+client with no JS tooling — mirroring
# the RPM spec's `%bcond_with web` (off by default). Set PF_WITH_WEB=1 to also build punktfunk-web # the RPM spec's `%bcond_with web` (off by default). Set PF_WITH_WEB=1 to also build punktfunk-web
# (appended to pkgname + bun to makedepends below). # (appended to pkgname + bun to makedepends below).
pkgname=('punktfunk-host' 'punktfunk-client') pkgname=('punktfunk-host' 'punktfunk-client')
pkgver=0.2.0 # CI (.gitea/workflows/arch.yml) drives the version: stable tags -> X.Y.Z-1, main pushes ->
pkgrel=1 # X.Y.Z-0.<run#> in the separate punktfunk-canary repo (mirrors the RPM's 0.ciN release; pkgrel
# allows only digits+dots, so the run number carries the monotonic ordering).
pkgver="${PF_PKGVER:-0.7.0}"
pkgrel="${PF_PKGREL:-1}"
arch=('x86_64') arch=('x86_64')
url="https://git.unom.io/unom/punktfunk" url="https://git.unom.io/unom/punktfunk"
license=('MIT OR Apache-2.0') license=('MIT OR Apache-2.0')
# !lto: makepkg's `lto` option injects -flto=auto into CFLAGS; aws-lc-sys (rustls' crypto)
# compiles its C with those flags and GCC LTO bitcode objects are unreadable by rust's lld
# linker -> "undefined symbol: aws_lc_*" at link (reproduced 2026-07-04, Arch + rust 1.90).
# !debug: skip the -debug split package (debuginfo bloat, not shipped).
options=('!lto' '!debug')
# All build deps for both crates (Arch runtime packages ship their own headers, so these cover # All build deps for both crates (Arch runtime packages ship their own headers, so these cover
# build + link). aws-lc/ring need clang+cmake; nasm is for asm. # build + link). aws-lc/ring need clang+cmake; nasm is for asm.
@@ -36,10 +44,16 @@ if [ "${PF_WITH_WEB:-0}" = 1 ]; then
makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos makedepends+=('bun') # `bun-bin` from the AUR if bun isn't in your configured repos
fi fi
# AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root and # AUR source (a tagged release). For an in-tree CI build, set PF_SRCDIR to the repo root
# build() uses it instead; see the README. # build() uses it instead AND the fetch is skipped entirely (a canary pkgver has no tag to
source=("git+https://git.unom.io/unom/punktfunk.git#tag=v${pkgver}") # clone, and CI already has the checkout).
sha256sums=('SKIP') if [ -z "${PF_SRCDIR:-}" ]; then
source=("git+https://git.unom.io/unom/punktfunk.git#tag=v${pkgver}")
sha256sums=('SKIP')
else
source=()
sha256sums=()
fi
_repo() { printf '%s' "${PF_SRCDIR:-$srcdir/punktfunk}"; } _repo() { printf '%s' "${PF_SRCDIR:-$srcdir/punktfunk}"; }
+39 -1
View File
@@ -23,7 +23,45 @@ default `makepkg` builds only host+client with no JS tooling — mirroring the R
> Arch + NVIDIA **and** AMD/Intel (incl. the Steam Deck — see the on-device path above). The client > Arch + NVIDIA **and** AMD/Intel (incl. the Steam Deck — see the on-device path above). The client
> decodes via VAAPI on AMD/Intel with a software fallback. > decodes via VAAPI on AMD/Intel with a software fallback.
## Arch Linux (mutable) ## Install from the binary repo (recommended)
CI (`.gitea/workflows/arch.yml`) builds this PKGBUILD in an `archlinux:base-devel` container on
every push and publishes the packages to the **Gitea Arch package registry** — a plain pacman
repo, so an Arch box installs and updates punktfunk with `pacman -Syu` like everything else.
Two repos mirror the deb/rpm channels: `punktfunk` (release tags) and `punktfunk-canary`
(rolling main-branch builds, versioned `X.Y.Z-0.<run#>` so a later release always outranks
them). Enable exactly one.
The registry **signs the repo database and every package**, so first import its key into
pacman's keyring (a one-time step — after this, packages install signature-verified):
```sh
# 1. Trust the registry signing key.
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
| sudo pacman-key --add -
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
# 3. Sync + install.
sudo pacman -Sy punktfunk-host # gaming rig
sudo pacman -Sy punktfunk-client # couch/Deck side
sudo pacman -Sy punktfunk-web # optional browser management console
```
(No `SigLevel` line needed — pacman's default `Required DatabaseOptional` verifies the signed
packages against the key you just trusted. Arch is rolling, so the packages are built against
current Arch sonames — keep the box itself updated too.)
Then the same first-run steps as a source build (printed by the install scriptlet): `input`
group, `host.env`, `systemctl --user enable --now punktfunk-host` — see the next section.
## Build from source — Arch Linux (mutable)
```sh ```sh
cd packaging/arch cd packaging/arch
+75 -41
View File
@@ -12,34 +12,91 @@ flagged explicitly. For the higher-level packaging rationale ("why not Flatpak",
> NVENC, from RPM Fusion **nonfree**), `opus`, and `libei`. > NVENC, from RPM Fusion **nonfree**), `opus`, and `libei`.
> Source: `packaging/README.md`, `packaging/rpm/punktfunk.spec`. > Source: `packaging/README.md`, `packaging/rpm/punktfunk.spec`.
> ⚠️ **Read this first — the COPR is operator-run, not yet published.** > ⚠️ **COPR note (Path C only).** The legacy layering path's commands reference a COPR project
> Both install paths below pull the punktfunk RPM from a COPR project named > named `enricobuehler/punktfunk` that is operator-run and may not be published (see
> `enricobuehler/punktfunk`. That COPR is a configuration the maintainer has to **create and > `packaging/copr/README.md`); layer from the **Gitea RPM registry** instead (`../rpm/README.md`,
> build** (see `packaging/copr/README.md` — it documents how to set it up, not a live repo URL you > the repo file `https://git.unom.io/api/packages/unom/rpm/bazzite.repo`) — it's what CI
> can assume exists). If `rpm-ostree install punktfunk` 404s, the COPR hasn't been published yet, > actually publishes to. Paths A (sysext) and B (bootc) don't involve the COPR at all.
> and your only path is to **build the RPM yourself** (see the appendix). The guide flags every
> command that depends on the COPR being live.
--- ---
## 1. Choose an install path ## 1. Choose an install path
There are two supported paths on Bazzite, driven by different files in `packaging/`: There are three paths on Bazzite, driven by different files in `packaging/`:
| Path | Driven by | What it does | Best for | | Path | Driven by | What it does | Best for |
|---|---|---|---| |---|---|---|---|
| **A — rpm-ostree layering** | `packaging/copr/README.md` + `packaging/rpm/punktfunk.spec` | Layers the `punktfunk` RPM onto your existing Bazzite deployment with `rpm-ostree install` | One host, quick iteration | | **A — systemd-sysext** ✅ recommended | `packaging/bazzite/punktfunk-sysext.sh` + `build-sysext.sh` (published by `.gitea/workflows/rpm.yml`) | Overlays the host onto `/usr` as a system extension — no layering, no reboot, one-command updates | Everyone; the default |
| **B — bootc / OCI image** | `packaging/bootc/Containerfile` | Bakes punktfunk into a `FROM bazzite-nvidia` image once; you `bootc switch` any number of hosts onto it | Fleets, reproducible appliances, no per-host drift | | **B — bootc / OCI image** | `packaging/bootc/Containerfile` | Bakes punktfunk into a `FROM bazzite-nvidia` image once; you `bootc switch` any number of hosts onto it | Fleets, reproducible appliances, no per-host drift |
| **C — rpm-ostree layering** (legacy) | `packaging/rpm/` + the Gitea RPM registry | Layers the `punktfunk` RPM onto your deployment with `rpm-ostree install` | Only if you specifically want the RPM database to own the files |
**Trade-off:** Path A is a per-host package layer — simple, but each host accumulates its own **Why A over C:** the Bazzite docs treat layering as a last resort — every layered package makes
layered-package state. Path B builds one image (RPM Fusion + the Gitea RPM repo + the host and every OS update slower and can **block upgrades entirely** until removed. A sysext never enters an
**web console** + udev rule pre-installed) that you push to a registry and rebase hosts onto rpm-ostree transaction: it merges/unmerges at runtime, survives OS updates, and updating punktfunk
atomically — no per-host `rpm-ostree install` drift, at the cost of running a `podman build`/`push` is one command with **no reboot** (layering needs one per update). It's the mechanism the Fedora
pipeline. Both require the **same first-run setup** (sections 36); note Path B installs from the Atomic maintainers ship via [fedora-sysexts](https://fedora-sysexts.github.io/). All paths require
**Gitea RPM registry** (which carries `punktfunk-web`), whereas Path A's COPR builds host+client the **same first-run setup** (sections 36).
only — for the web console on Path A, layer from the Gitea registry instead (`../rpm/README.md`).
### Path A — rpm-ostree layering from the COPR ### Path A — systemd-sysext (recommended)
Run on the Bazzite host:
```sh
# One-time bootstrap; afterwards the tool is on PATH as `punktfunk-sysext` (it ships inside
# the image). `--channel canary` for rolling main-branch builds instead of releases.
curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
sudo bash punktfunk-sysext.sh install
```
This downloads the newest image for your Fedora base (host + tray + **web console**,
SHA-256-verified from the feed `…/packages/unom/generic/punktfunk-sysext/f<ver>[-canary]/`),
installs it as `/var/lib/extensions/punktfunk.raw`, merges it, and immediately applies what the
RPM scriptlets would have (udev reload, sysctl) plus the two `/etc` files a sysext can't carry
(the gamescope-session drop-in and the tray autostart entry, staged under
`/usr/share/punktfunk/etc/`). No reboot at any point. Day-2:
```sh
sudo punktfunk-sysext update # fetch + merge the newest build (then restart the user service)
sudo punktfunk-sysext status # merged?, installed vs latest, channel/feed
sudo punktfunk-sysext remove # unmerge + delete; ~/.config/punktfunk is left alone
```
Details worth knowing:
- The image embeds `ID=fedora` + `VERSION_ID` (matched through Bazzite's `ID_LIKE`), so after a
**major Bazzite rebase** (F43 → F44) the old image is **refused** instead of merging
soname-broken binaries — `punktfunk-sysext update` then fetches the image built for the new
base (feeds exist per Fedora major, from the same CI matrix as the RPM groups).
- SELinux labels are baked into the image at build time (squashfs pseudo-xattrs computed from
the targeted policy) — without them udev couldn't read the gamepad rule under enforcing.
Validated live on Bazzite 43.
- **Migrating from layering (path C):** install the sysext (it shadows the layered copy at
once), then `sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot`.
### Path B — bootc image (`FROM bazzite-nvidia`)
The image is built **off-host** (on any machine with `podman`) from
`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable`
(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM
repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web
console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically
because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`).
```sh
# Build + push (run from the repo root, on your builder machine):
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
podman push ghcr.io/<you>/bazzite-punktfunk
# On each target Bazzite host:
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
```
> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on
> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` +
> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata
> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`.
### Path C — rpm-ostree layering (legacy)
Run on the Bazzite host. (Commands verbatim from `packaging/README.md`.) Run on the Bazzite host. (Commands verbatim from `packaging/README.md`.)
@@ -62,7 +119,7 @@ systemctl reboot
> The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes > The **reboot is mandatory** — `rpm-ostree install` stages a new deployment that only takes
> effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk. > effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk.
#### Updating a Path-A host — `rpm-ostree upgrade` is NOT enough #### Updating a Path-C host — `rpm-ostree upgrade` is NOT enough
> ⚠️ **`rpm-ostree upgrade` will not update punktfunk on its own.** `upgrade` bumps the **base > ⚠️ **`rpm-ostree upgrade` will not update punktfunk on its own.** `upgrade` bumps the **base
> image** and only re-resolves *layered* packages **when the base changes**. A Bazzite base can > image** and only re-resolves *layered* packages **when the base changes**. A Bazzite base can
@@ -94,29 +151,6 @@ sudo bash packaging/bazzite/update-punktfunk.sh --reboot # stage + reboot now
> `punktfunk.repo`, canary's `<next-minor>.0-0.ciN` **outranks** the stable `X.Y.Z-1` and the box > `punktfunk.repo`, canary's `<next-minor>.0-0.ciN` **outranks** the stable `X.Y.Z-1` and the box
> silently tracks canary. Enable exactly one channel — set `enabled=0` in the other repo file. > silently tracks canary. Enable exactly one channel — set `enabled=0` in the other repo file.
### Path B — bootc image (`FROM bazzite-nvidia`)
The image is built **off-host** (on any machine with `podman`) from
`packaging/bootc/Containerfile`, which bases on `ghcr.io/ublue-os/bazzite-nvidia:stable`
(override with `--build-arg BASE_IMAGE=…`), enables RPM Fusion free + nonfree, adds the Gitea RPM
repo (`--build-arg PUNKTFUNK_RPM_GROUP=…`, default `bazzite`), and installs the host **and the web
console** (`punktfunk punktfunk-web`). It uses the Gitea registry rather than the COPR specifically
because the registry carries `punktfunk-web` (COPR's mock chroot can't build it — no `bun`).
```sh
# Build + push (run from the repo root, on your builder machine):
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
podman push ghcr.io/<you>/bazzite-punktfunk
# On each target Bazzite host:
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
```
> ⚠️ The image installs from the **Gitea RPM registry** (group `bazzite`), so **Path B depends on
> that registry being populated** — CI (`.gitea/workflows/rpm.yml`) publishes `punktfunk` +
> `punktfunk-web` on every push to `main`. Packages are unsigned with GPG-signed metadata
> (`repo_gpgcheck=1`), matching `packaging/rpm/README.md`.
--- ---
## 2. Prerequisites — what Bazzite gives you vs. what you must still do ## 2. Prerequisites — what Bazzite gives you vs. what you must still do
+115
View File
@@ -0,0 +1,115 @@
#!/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 \
# dist/punktfunk-0.7.1-1.fc43.x86_64.rpm dist/punktfunk-web-0.7.1-1.fc43.noarch.rpm
#
# 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="" RPMS=()
while [ $# -gt 0 ]; do
case "$1" in
--version-id) VERSION_ID="${2:?}"; shift 2 ;;
--out) OUT="${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
# 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
# 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)"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Publish a punktfunk sysext image into its feed on the Gitea generic package registry —
# called by .gitea/workflows/rpm.yml after the RPM publish. A feed is one fixed URL
# (…/punktfunk-sysext/<feed>/) holding versioned .raw files plus a SHA256SUMS manifest;
# punktfunk-sysext(8) on the boxes reads SHA256SUMS to find + verify the newest image
# (the layout is also exactly what systemd-sysupdate's url-file source expects, so a
# .transfer feed can be added later without re-publishing anything).
#
# Usage: TOKEN=… [KEEP=6] bash publish-sysext-feed.sh <feed> <image.raw>
# <feed> e.g. f43, f43-canary, f44 (Fedora major x channel)
# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name)
set -euo pipefail
FEED="${1:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
RAW="${2:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; }
REGISTRY="${REGISTRY:-git.unom.io}"
OWNER="${OWNER:-unom}"
KEEP="${KEEP:-0}"
AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}")
BASE="https://$REGISTRY/api/packages/$OWNER/generic/punktfunk-sysext/$FEED"
FNAME="$(basename "$RAW")"
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
# Merge into the existing manifest: drop any prior line for this filename, append ours.
SUMS="$(mktemp)"; trap 'rm -f "$SUMS"' EXIT
curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry.
PRUNE=()
if [ "$KEEP" -gt 0 ]; then
mapfile -t PRUNE < <(awk '{print $2}' "$SUMS" | sort -V | head -n -"$KEEP")
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && sed -i "\| $f\$|d" "$SUMS"
done
fi
# Upload order keeps consumers consistent: image first, then the manifest referencing it,
# then prune deletions (already absent from the manifest). Delete-before-put makes workflow
# re-runs idempotent (the registry 409s on duplicate filenames; first-publish 404s are fine).
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$FNAME" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$RAW" "$BASE/$FNAME"
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
done
echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)"
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# punktfunk-sysext — install/update the punktfunk host on Bazzite / Fedora Atomic as a
# systemd-sysext, the no-layering path (rpm-ostree layering is a last resort per the Bazzite
# docs: it slows every update and can block upgrades; a sysext never enters an rpm-ostree
# transaction, needs no reboot, and is trivially removable).
#
# The image overlays /usr from /var/lib/extensions/punktfunk.raw with the host, tray and web
# console + their udev/sysctl/systemd-user payload; the RPMs' two /etc files (gamescope
# session drop-in, tray autostart) ride inside at /usr/share/punktfunk/etc/ and are copied
# into the real /etc here (a sysext can only carry /usr).
#
# Bootstrap (the script also ships inside the image as /usr/bin/punktfunk-sysext):
# curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh
# sudo bash punktfunk-sysext.sh install # or: install --channel canary
# Thereafter:
# sudo punktfunk-sysext update | status | remove
#
# Feed: the Gitea generic package registry, one feed per Fedora major x channel
# (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + versioned .raw files —
# published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path uses.
# The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused
# (not merged broken) and `punktfunk-sysext update` re-resolves against the new release.
set -euo pipefail
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}"
CONF=/etc/punktfunk-sysext.conf
EXT_DIR=/var/lib/extensions
IMG="$EXT_DIR/punktfunk.raw"
SIDECAR="$EXT_DIR/.punktfunk.version"
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
ETC_SRC=/usr/share/punktfunk/etc
usage() {
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
echo "usage: punktfunk-sysext install [--channel stable|canary] [--from-file X.raw]"
echo " punktfunk-sysext update [--from-file X.raw] | status | remove"
exit "${1:-0}"
}
need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; }; }
os_version_id() { . /etc/os-release; echo "${VERSION_ID%%.*}"; }
channel() { # shellcheck disable=SC1090
[ -f "$CONF" ] && . "$CONF"; echo "${CHANNEL:-stable}"; }
feed_url() {
local suffix=""
[ "$(channel)" = canary ] && suffix="-canary"
echo "$REGISTRY/f$(os_version_id)$suffix"
}
# latest -> "VERSION FILENAME SHA256" from the feed's SHA256SUMS (highest by version sort).
latest() {
local feed; feed="$(feed_url)"
curl -fsSL "$feed/SHA256SUMS" \
| awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
| sort -V | tail -n1
}
installed_version() {
if [ -f "$MARKER" ]; then
sed -n 's/^SYSEXT_VERSION_ID=//p' "$MARKER"
elif [ -f "$SIDECAR" ]; then
cat "$SIDECAR"
fi
}
merged() { [ -f "$MARKER" ]; }
post_merge() {
if ! merged; then
echo "!! image installed but NOT merged — 'systemd-sysext status' / 'journalctl -u systemd-sysext'" >&2
echo "!! (an OS release the image doesn't match? 'punktfunk-sysext update' fetches the right one)" >&2
return 1
fi
# What the RPM scriptlets would have done: pick up the uinput/uhid rule + the UDP buffer
# sysctl now, no reboot (both also auto-apply at boot once merged — the files live in /usr/lib).
udevadm control --reload 2>/dev/null || :
udevadm trigger --subsystem-match=misc 2>/dev/null || :
for f in /usr/lib/sysctl.d/99-punktfunk-net.conf /usr/lib/sysctl.d/99-punktfunk-client-net.conf; do
[ -f "$f" ] && sysctl -q -p "$f" 2>/dev/null || :
done
# The /etc payload a sysext can't carry. The gamescope-session drop-in is %config(noreplace):
# only seed it, never clobber a local edit. The tray autostart entry is not user config.
if [ -f "$ETC_SRC/gamescope-session-plus/sessions.d/steam" ] \
&& [ ! -e /etc/gamescope-session-plus/sessions.d/steam ]; then
install -Dm0644 "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
/etc/gamescope-session-plus/sessions.d/steam
fi
if [ -f "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" ]; then
install -Dm0644 "$ETC_SRC/xdg/autostart/io.unom.Punktfunk.Tray.desktop" \
/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
fi
}
# do_install VERSION FILENAME SHA256 | do_install --from-file X.raw
do_install() {
need_root
mkdir -p "$EXT_DIR"
local tmp="$EXT_DIR/.punktfunk.raw.new" ver
if [ "$1" = --from-file ]; then
ver="(local: $(basename "$2"))"
cp -f "$2" "$tmp"
else
ver="$1"
echo "downloading punktfunk $ver ($(channel), fedora $(os_version_id))…"
curl -fL --progress-bar -o "$tmp" "$(feed_url)/$2"
echo "$3 $tmp" | sha256sum -c --quiet
fi
mv -f "$tmp" "$IMG" # marker inside is extension-release.punktfunk — name must match
echo "$ver" > "$SIDECAR"
systemctl enable --now systemd-sysext.service >/dev/null 2>&1 || :
systemd-sysext refresh
post_merge
echo "punktfunk $ver merged into /usr."
}
layering_hint() {
if command -v rpm-ostree >/dev/null 2>&1 \
&& rpm-ostree status 2>/dev/null | grep -q 'LayeredPackages:.*punktfunk'; then
cat >&2 <<'EOF'
!! punktfunk is ALSO layered via rpm-ostree. The sysext now shadows it, but remove the
!! layer so it stops slowing/blocking OS updates (the reason this sysext exists):
!! sudo rpm-ostree uninstall punktfunk punktfunk-web && systemctl reboot
EOF
fi
}
cmd_install() {
need_root
local from_file=""
while [ $# -gt 0 ]; do
case "$1" in
--channel) printf 'CHANNEL=%s\n' "${2:?}" > "$CONF"; shift 2 ;;
--from-file) from_file="${2:?}"; shift 2 ;;
*) usage 1 ;;
esac
done
if [ -n "$from_file" ]; then
do_install --from-file "$from_file"
else
local l; l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
# shellcheck disable=SC2086
do_install $l
fi
layering_hint
cat <<'EOF'
First-run (once):
ujust add-user-to-input-group # virtual gamepads; then log out + back in
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
systemctl --user daemon-reload && systemctl --user enable --now punktfunk-host
Updates: sudo punktfunk-sysext update
EOF
}
cmd_update() {
need_root
if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi
local cur l ver
cur="$(installed_version)"
l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
ver="${l%% *}"
if [ "$ver" = "$cur" ] && merged; then
echo "already on $cur (channel $(channel)) — nothing to do."
return
fi
echo "updating: ${cur:-<none>} -> $ver"
# shellcheck disable=SC2086
do_install $l
echo "restart the host to pick up the new binary: systemctl --user restart punktfunk-host"
}
cmd_status() {
echo "channel: $(channel)"
echo "feed: $(feed_url)"
echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')"
echo "merged: $(merged && echo yes || echo no)"
echo "installed: $(installed_version || true)"
echo "latest: $(latest 2>/dev/null | cut -d' ' -f1 || true)"
}
cmd_remove() {
need_root
# /etc cleanup needs the /usr payload for the unmodified-compare — do it BEFORE unmerging.
if merged; then
if cmp -s "$ETC_SRC/gamescope-session-plus/sessions.d/steam" \
/etc/gamescope-session-plus/sessions.d/steam 2>/dev/null; then
rm -f /etc/gamescope-session-plus/sessions.d/steam
fi
fi
rm -f /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
rm -f "$IMG" "$SIDECAR" "$CONF"
systemd-sysext refresh 2>/dev/null || :
echo "punktfunk sysext removed (user config in ~/.config/punktfunk is untouched)."
}
case "${1:-}" in
install) shift; cmd_install "$@" ;;
update) shift; cmd_update "$@" ;;
status) shift; cmd_status ;;
remove) shift; cmd_remove ;;
*) usage ;;
esac
+8
View File
@@ -23,6 +23,14 @@ if [[ $EUID -ne 0 ]]; then
exit 1 exit 1
fi fi
# The sysext path (packaging/bazzite/punktfunk-sysext.sh) supersedes layering entirely — if the
# box runs the sysext, it shadows any layered copy and THIS script won't change what executes.
if [[ -f /var/lib/extensions/punktfunk.raw ]]; then
echo "NOTE: the punktfunk sysext is installed — update with 'punktfunk-sysext update' instead." >&2
echo " (a layered punktfunk is shadowed by the sysext; consider removing the layer:" >&2
echo " rpm-ostree uninstall punktfunk punktfunk-web)" >&2
fi
# Which punktfunk packages are actually layered right now (host, web, or both). # Which punktfunk packages are actually layered right now (host, web, or both).
mapfile -t layered < <(rpm-ostree status --json 2>/dev/null \ mapfile -t layered < <(rpm-ostree status --json 2>/dev/null \
| grep -oE '"punktfunk(-web)?"' | tr -d '"' | sort -u) | grep -oE '"punktfunk(-web)?"' | tr -d '"' | sort -u)