The dependency currency wave took skia-safe/skia-bindings 0.87.0 -> 0.99.0 in
crates/pf-console-ui/Cargo.toml, but packaging/flatpak/io.unom.Punktfunk.yml still
pinned the 0.87.0 prebuilt archive, so every flatpak leg since the merge dies with
error[E0599]: no variant, associated function, or constant named `Default`
found for enum `SkPathFillType` (and `SkPathDirection`)
--> cargo/vendor/skia-bindings-0.99.0/src/defaults.rs:57
Nothing about that message points at the manifest, so it reads like a crate bug. It
isn't. `SKIA_BINARIES_URL: file://…` makes skia-bindings unpack the pinned tarball
verbatim into target/…/build/skia-bindings-*/out/skia/ — *including the bindings.rs
it was generated with*. Those two `Default`s are associated consts emitted INTO
bindings.rs, so they travel with the archive, not with the crate: 0.99.0's
src/defaults.rs was compiling against 0.87.0-era bindings. Verified directly — the
0.99.0 archive carries `impl SkPathFillType { pub const Default = Winding }` and
`impl SkPathDirection { pub const Default = CW }` on both x86_64 and aarch64.
Because the URL is file://, the fetch can never fail, so there is no download error
to notice — the only symptom is a compile error deep in a vendored crate.
The asset name changed across the bump: `jpeg` entered skia-safe's defaults at 0.99,
so the resolved-feature key went `pdf-textlayout-vulkan` -> `jpegd-jpege-pdf-textlayout-vulkan`.
Confirmed against each archive's own key.txt/tag.txt (tag 0.99.0, key
a25a0fdb7d90429aa2d1-<target>-jpegd-jpege-pdf-textlayout-vulkan), and libskparagraph.a
plus the Vulkan backend symbols are present, so the feature set still matches what
pf-console-ui resolves.
Everything else in the offline chain (Cargo.lock, cargo-sources.json) is regenerated
from the lock and self-corrects; this tarball is the single hand-maintained pin, which
is exactly why it was the thing left behind. Both bump sites now carry a pointer to
the other so the next one can't split-brain the same way.
punktfunk client — Flatpak (Steam Deck / SteamOS, and any flatpak distro)
The native Linux client — the shell (crate punktfunk-client-linux, binary
punktfunk-client) plus the Vulkan session binary it execs for streaming (crate
punktfunk-client-session, binary punktfunk-session) — is
published two ways by CI (.gitea/workflows/flatpak.yml), on every push to main (a rolling
<next-minor>-ciN.g<sha> build, base derived from the latest stable tag by
scripts/ci/pf-version.sh) and on v* tags (a clean X.Y.Z):
- Hosted OSTree repo at
https://flatpak.unom.io(recommended) — a GPG-signed Flatpak remote served by a static Caddy container on unom-1, so users install once and thenflatpak update. Shared unom-wide repo (remote nameunom), reusable by other unom apps under the same signing key. See "Install (recommended)" below. - Single-file
.flatpakbundle in Gitea's generic package registry (unomorg) — the no-remote fallback the Decky plugin consumes (stablelatest/punktfunk-client.flatpakURL) and the offline/manual path. On tags it's also attached to the Gitea release.
The host is NOT a flatpak (it needs unsandboxed
/dev/uinput+ zero-copy NVENC — see../README.md"Why not Flatpak"). Only the client is sandbox-friendly.
Why flatpak for the Steam Deck
SteamOS /usr is read-only and image-based, and the system is missing libadwaita and
libSDL3 — so a bare punktfunk-client binary dropped into ~/.local/bin won't run. Flatpak
is the Deck's native, update-survivable app path (the user already runs Moonlight and chiaki-ng
as flatpaks), and the bundle carries libadwaita (from org.gnome.Platform//50) + a bundled SDL3.
It carries no FFmpeg: since M10 the client decodes on the user's own GPU drivers (Vulkan Video,
VAAPI) with openh264 + rav1d as the CPU floor, so the runtime's codecs-extra extension — and the
encumbered-codec question it answered — no longer enter into it.
App id: io.unom.Punktfunk (matches the Apple bundle id family and the Decky plugin's
flatpak fallback).
Install (recommended): the hosted repo
One command adds the signed unom remote and installs the client; it auto-adds Flathub for the
GNOME runtime, and flatpak update tracks new builds from then on:
flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.flatpakref
flatpak run io.unom.Punktfunk
Equivalent two-step (add the whole remote, then install by app id):
flatpak remote-add --user --if-not-exists unom https://flatpak.unom.io/unom.flatpakrepo
flatpak install --user unom io.unom.Punktfunk
Updates — the whole point of the hosted repo:
flatpak update # or: flatpak update io.unom.Punktfunk
Install on the Deck via the bundle (no-remote fallback)
The generic registry is a plain HTTP file store, so just download the bundle and install it per-user (no root, survives SteamOS updates). This is what the Decky plugin uses; the hosted repo above is the better path for a human on the Deck:
# Pick a version: a tag like 1.2.3, or the newest main build's <next-minor>-ciN.gSHA.
VER=1.2.3
URL="https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/$VER/punktfunk-client-$VER.flatpak"
# Flathub must be enabled (it is on the Deck) so the GNOME runtime pulls in:
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
curl -fL -o /tmp/punktfunk-client.flatpak "$URL"
flatpak install --user --bundle /tmp/punktfunk-client.flatpak
Run it:
flatpak run io.unom.Punktfunk # GUI host list (mDNS)
flatpak run io.unom.Punktfunk --connect HOST:PORT
The Decky plugin launches exactly this (flatpak run io.unom.Punktfunk --connect …) once
installed — see ../../clients/decky/README.md.
Updating the bundle install
If you installed from the bundle (not the hosted repo), it has no remote to track, so updates are "download the newer bundle and reinstall":
flatpak install --user --bundle /tmp/punktfunk-client.flatpak # same command, newer file
Installs from https://flatpak.unom.io instead just take flatpak update (see "Install
(recommended)" above).
Build locally / the CI fallback
CI builds this in a --privileged Fedora container, because flatpak-builder runs
bubblewrap, which needs user namespaces the default Docker executor denies. If the Gitea
runner can't grant --privileged (the job fails at flatpak-builder with
"Creating new namespace failed: Operation not permitted"), build it out-of-band and upload
by hand. The easiest place is on the Deck itself (it can run org.flatpak.Builder
user-scope, no root):
# On the Deck (or any flatpak box), one-time:
flatpak install --user -y flathub org.flatpak.Builder
# build-flatpak.sh auto-detects org.flatpak.Builder, generates cargo-sources.json (or reuses an
# existing one — see below), builds, and exports dist/punktfunk-client-<version>.flatpak:
bash packaging/flatpak/build-flatpak.sh
# Upload to the generic registry (PAT with write:package):
curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" \
--upload-file dist/punktfunk-client-*.flatpak \
"https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/0.0.1-manual/punktfunk-client.flatpak"
cargo-sources.jsongeneration needspython3+aiohttp+tomlkit, which the Deck lacks. Generate it on a dev box (build-flatpak.shdoes it, or run the upstreamflatpak-cargo-generator.py Cargo.lock -o packaging/flatpak/cargo-sources.json), rsync it next to the manifest, andbuild-flatpak.shreuses it (it only regenerates when the file is absent orFORCE_GEN=1).
The Mac build host cannot build a Linux flatpak (no flatpak-builder for macOS), and home-worker-2 has no flatpak and no passwordless sudo to install it — so the Deck or the privileged CI container are the only two viable build sites.
aarch64
The manifest builds for aarch64 as well as x86_64. Two things are architecture-specific, and both are now expressed properly rather than hardcoded:
PKG_CONFIG_PATHcontains the runtime's multiarch directory. flatpak-builder does not shell-expandenvvalues, so${FLATPAK_ARCH}would be taken literally — abuild-options.archoverride supplies the aarch64 string instead, inheriting everything else.- The prebuilt Skia archive is per-target and pinned by sha256. There are now two
type: filesources discriminated byonly-arches, both landing on the samedest-filename, soSKIA_BINARIES_URLstays one literal path. Upstream publishes the aarch64 archive under the same skia commit hash and the same resolved-feature key (at 0.99:jpegd-jpege-pdf-textlayout-vulkan), so on a skia-safe bump update both URLs and both hashes together. The feature key is not stable across bumps — 0.87 waspdf-textlayout-vulkan;jpegentering skia-safe's defaults at 0.99 renamed it.
ARCH=aarch64 bash packaging/flatpak/build-flatpak.sh
# -> dist/punktfunk-client-<version>-aarch64.flatpak
ARCH defaults to this machine's, and the bundle name now carries the architecture so an x86_64
and an aarch64 build can coexist in dist/. This is not a cross-compile: flatpak-builder runs
the build in a sandbox for the target arch, so building aarch64 anywhere but an arm64 machine
needs qemu binfmt and is very slow. Not yet verified end to end — the manifest is correct by
construction and the Skia hash was checked against the published archive, but no aarch64 flatpak
has been built.
Manifest
io.unom.Punktfunk.yml. Runtime org.gnome.Platform//50
(GTK 4.20 + libadwaita 1.8 ≥ the crate floors of v4_16 / v1_5), built on freedesktop-sdk 25.08,
with two build-time SDK extensions: org.freedesktop.Sdk.Extension.rust-stable (→ //25.08,
rustc 1.96 — the GTK4 dep chain, e.g. pango-sys 0.22, needs ≥ 1.92, which the EOL GNOME-48 /
24.08 rust-stable at 1.89 could not provide) and org.freedesktop.Sdk.Extension.llvm20 (libclang,
needed by bindgen in sdl3-sys / pyrowave-sys). No libavcodec at any layer — the client links no
FFmpeg since M10, so neither the SDK's stripped build nor the runtime's codecs-extra shadow of it
is involved; HEVC decodes on the GPU's own driver, and there is deliberately no software HEVC
rung (see the manifest header). A bundled
SDL3 3.4.10 module (pinned to match sdl3-sys 0.6.6+SDL-3.4.10), and finish-args for Wayland +
--device=all (GPU/VAAPI render node + evdev + the hidraw char-devices SDL3 needs for DualSense)
--socket=pulseaudio(PipeWire-pulse: playback + mic) +--share=network. Alongside it:io.unom.Punktfunk.desktop,io.unom.Punktfunk.metainfo.xml,io.unom.Punktfunk.svg(all installed by the manifest). Novulkan-headersmodule: it existed forpf-ffvk's bindgen over FFmpeg'shwcontext_vulkan.h, and ash generates its own bindings and dlopens the loader.cargo-sources.json(the offline crate cache) is a pure function ofCargo.lock; CI regenerates it each build and it is gitignored — generate it on any box with network +python3/aiohttp/tomlkit(build-flatpak.shdoes this automatically) and, for a build host that lacks those (the Deck), rsync the generated file in alongside the manifest.
Offline Skia: the session binary's Skia console UI (pf-console-ui → skia-safe) normally
downloads prebuilt libskia binaries at build time, which is dead in the offline sandbox — so the
manifest pins a skia-binaries-….tar.gz source and points the build at it with
SKIA_BINARIES_URL: file://…. When bumping the skia-safe/skia-bindings crate version, update
that pinned tarball (URL + sha256) to the matching skia-binaries release in the same commit,
or the build breaks offline.
The failure is not a download error, because file:// always succeeds — skia-bindings unpacks the
stale archive verbatim, including the bindings.rs it was generated with, so the new crate's
src/defaults.rs compiles against old bindings and the leg dies on missing associated consts
(SkPathFillType::Default, SkPathDirection::Default — they live in the generated bindings.rs,
so they travel with the archive, not the crate). Everything else in the offline chain is derived
from Cargo.lock and self-corrects; this tarball is the only hand-maintained pin, and the
0.87 → 0.99 bump (#193) left it behind.
Hosting the repo (unom-1) + one-time setup
The OSTree repo flatpak-builder produces is GPG-signed in CI and rsynced to unom-1, where a tiny
static Caddy container (server/compose.production.yml + server/Caddyfile, port 3230)
serves the ./site tree (repo/ + unom.flatpakrepo + io.unom.Punktfunk.flatpakref +
index.html). The edge Caddy on home-reverse-proxy-1 fronts it at https://flatpak.unom.io.
The CI deploy step no-ops until the secret + infra exist, so it won't redden builds mid-setup.
Signing key: dedicated RSA-4096 key unom Flatpak Repo <flatpak@unom.io>. Public key committed
at unom-flatpak.gpg (its base64 goes into the .flatpakrepo/.flatpakref
GPGKey=); private key (ASCII-armored, then base64) lives only in the CI secret.
One-time setup (mirrors any new unom DMZ service — see the deploy-infra notes):
- Secret
FLATPAK_GPG_PRIVATE_KEYon this repo = base64 of the armored private key (gpg --armor --export-secret-keys <fpr> | base64 -w0).DEPLOY_*+REGISTRY_TOKENalready exist. - Edge Caddy on home-reverse-proxy-1 (
/home/caddy/caddy/Caddyfile, apply by hand +./reload.sh):flatpak.unom.io { reverse_proxy 192.168.50.50:3230 } - Port allowlist: add
3230tocaddy_target_portsinunom/infra(proxmox/unom-1) + terraform apply. - DNS: ensure
flatpak.unom.ioresolves to the edge proxy.
Re-signing/rotation: regenerate the key, replace unom-flatpak.gpg + the secret; every client must
re-add the remote (the GPGKey changed), so rotate rarely.
Alternatives considered
- Hosted OSTree repo (chosen): the only option that gives
flatpak update. We self-host the static tree on unom-1 behind Caddy (Gitea has no flatpak/ostree registry); the build already produces the repo, so the marginal cost is GPG signing + an rsync + a 10-line static container. - Generic registry bundle (kept as fallback): one curl to publish, one
flatpak install --bundleto consume; mirrors the deb/rpm curl-upload pattern. No auto-update — this is what the Decky plugin pulls (stablelatest/punktfunk-client.flatpak), plus the offline/manual path. - Release attachment: also done on tags, good for a human-facing download page.
- Flathub (deferred): best discoverability + zero hosting, but a separate submission/review process and less control; revisit once the client is past scaffold quality.