# punktfunk on Arch Linux / SteamOS

Packaging for punktfunk on Arch and Arch-derived immutable distros. The `PKGBUILD` is a **split
package** producing **`punktfunk-host`** (the gaming-rig host) and **`punktfunk-client`** (the native
GTK4/libadwaita Linux client) — mirrors the rpm subpackages (`packaging/rpm/punktfunk.spec`) and the
deb build scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's what the
[Decky plugin](../../clients/decky/) launches); on a gaming rig, `punktfunk-host`.

> **Steam Deck as a HOST:** don't use this PKGBUILD — SteamOS's read-only root makes `makepkg`/sysext
> awkward, and a prebuilt binary breaks on OS library bumps. Use the on-device build script instead:
> **[`scripts/steamdeck/install.sh`](../../scripts/steamdeck/)** (it builds in a Debian-trixie distrobox
> ABI-matched to SteamOS and uses **VAAPI** on the Deck's AMD GPU). The Deck host path is the one
> exception to "host encode is NVENC-only" below.

A third member, **`punktfunk-web`** (the browser management console — pairing + status), is
**opt-in**: build it by setting `PF_WITH_WEB=1`, which requires **`bun`** at build time (`bun-bin`
from the AUR if it isn't in your repos). bun is also the **runtime** — the console serves HTTPS
(HTTP/1.1 over TLS) via `Bun.serve`, so the package vendors the bun binary (no `nodejs` dependency). A
default `makepkg` builds only host+client with no JS tooling — mirroring the RPM spec's `%bcond_with web`.

> **Host encode: NVENC on NVIDIA, VAAPI on AMD/Intel** (`PUNKTFUNK_ENCODER=auto` picks one). The host
> now has a VAAPI encoder + zero-copy dmabuf path alongside NVENC/CUDA, so `punktfunk-host` works on
> 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.

## 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):

The user-facing walkthrough — key import, repo add, `pacman -Syu` install — lives on the
[Arch docs page](https://docs.punktfunk.unom.io/docs/arch), stated once so it can't drift (see
"Where facts live" in [`CONTRIBUTING.md`](../../CONTRIBUTING.md)). Packager notes: no `SigLevel`
line is needed (pacman's default `Required DatabaseOptional` verifies the signed packages against
the imported key), the repo-add uses `printf` rather than a heredoc so it works in fish (CachyOS's
default shell has no `<<EOF`), and Arch is rolling — packages are built against current Arch
sonames, so the box itself must stay updated.

The repo-add **appends** to `/etc/pacman.conf`; the documented line is guarded with a `grep -q` so a
second run is a no-op, and the symptom of an unguarded double add (`database already registered`)
is on the
[troubleshooting page](https://docs.punktfunk.unom.io/docs/troubleshooting#pacman-error-could-not-register-punktfunk-database-database-already-registered).

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.

### If pacman says `unable to satisfy dependency 'libavcodec.so=…'`

```
:: unable to satisfy dependency 'libavcodec.so=62-64' required by punktfunk-host
```

`punktfunk-host` links FFmpeg, so it depends on the exact libav sonames it was built against —
FFmpeg 8 provides `libavcodec.so=62`, FFmpeg 9 provides `libavcodec.so=63`. This message means the
package on offer was built against a *different* FFmpeg major than your box has. Because pacman
prepares the whole transaction at once, it stops your entire `pacman -Syu`, not just this package.

The bound is deliberate. Without it the upgrade succeeds and leaves a host binary that cannot
start at all — exit 127 before `main()`, in a systemd restart loop, with nothing in its own log
to explain it (`ldd /usr/bin/punktfunk-host | grep 'not found'` is the one-line diagnosis).

1. `sudo pacman -Syyu` — a forced db refresh, in case the matching build is already published.
   Compare `pacman -Si punktfunk-host` against your `pacman -Q ffmpeg`.
2. Still refused? Then we published a build made against the wrong FFmpeg — please report it. The
   repair arrives as a higher **pkgrel** of the same version (`0.25.0-2`), so a later `-Syu`
   picks it up with nothing to undo.
3. To let the rest of the system upgrade in the meantime: `sudo pacman -Syu --ignore punktfunk-host`.
   If pacman still refuses (your *installed* copy is the one carrying the bound), remove it with
   `sudo pacman -Rdd punktfunk-host`, upgrade, and install it again once the rebuild lands. Either
   way the host stays down until then — that is the soname break itself, not a second fault.

## Build from source — Arch Linux (mutable)

```sh
cd packaging/arch
# Build the working tree (CI / dev) — no git fetch:
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
# …or build the tagged release the AUR way:
makepkg -si
# …add the web console too (needs bun / bun-bin):
PF_WITH_WEB=1 PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
```

### aarch64 (Arch Linux ARM) — the client

The PKGBUILD declares `arch=('x86_64' 'aarch64')`. On aarch64 it builds the **client only** —
`pkgname` drops `punktfunk-host`, so makepkg never enters the host's build or package path, and
`build()` skips the host/tray cargo invocations and their NVENC/Vulkan-encode features. The host
stays x86-only because its encode stack (NVENC/QSV/AMF) is.

Nothing else changes — run the same command on an Arch Linux ARM box:

```sh
cd packaging/arch
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
# -> punktfunk-client-<ver>-<rel>-aarch64.pkg.tar.zst   (no punktfunk-host package)
```

There is no cross-compile path here: makepkg builds for `CARCH`, so this wants a real aarch64
Arch machine (or an emulated Arch Linux ARM container, which is slow). Unlike the deb, it has
**not** been verified end to end yet — there is no official arm64 Arch container to test in.
Then the standard first-run (printed by the install scriptlet):
```sh
sudo usermod -aG input "$USER"          # virtual gamepads; re-login after
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env   # gamescope backend
systemctl --user enable --now punktfunk-host
# Web console (if you installed the punktfunk-web package): enable it + read the login password.
systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'   # open https://<host-ip>:47992
```
NVENC/EGL come from the NVIDIA driver: `sudo pacman -S --needed nvidia-utils`. Arch's stock
`ffmpeg` already has NVENC built in — no RPM-Fusion-style swap needed (unlike Fedora).

### Runtime dependency map (Fedora/Debian → Arch)

| Need | Arch package |
|------|--------------|
| FFmpeg + NVENC | `ffmpeg` (NVENC built in) |
| PipeWire + session mgr | `pipewire` `wireplumber` |
| PulseAudio-API audio for games | `pipewire-pulse` *(host optdepend — real `pulseaudio` also works; never a hard dep, it CONFLICTS with `pulseaudio`)* |
| Opus / input injection | `opus` `libei` |
| GL/EGL + gbm + xkb + wayland | `libglvnd` `mesa` `libxkbcommon` `wayland` |
| NVIDIA driver (NVENC/EGL/CUDA) | `nvidia-utils` *(optdepend — never a hard dep)* |
| Compositor backends | `gamescope` (≥3.16.22) / `kwin` / `mutter` / `sway` *(optdepends)* |

## Immutable Arch (SteamOS 3) — the systemd-sysext mechanism

SteamOS has a **read-only `/usr` on A/B partitions**, and every OS update reimages the rootfs —
so `steamos-readonly disable` + `pacman` is fragile for anything that must survive updates. The
SteamOS-blessed overlay mechanism is a **systemd-sysext**: an image merged read-only over `/usr`
at boot, living in the writable `/var/lib/extensions/`.

> **For a SteamOS HOST this is NOT the supported path** — that is
> [`scripts/steamdeck/install.sh`](../../scripts/steamdeck/) (the on-device distrobox build,
> which also builds the HDR gamescope). A host sysext carries a prebuilt binary that breaks on
> the next SteamOS soname bump, and `/var` — where sysexts live — is per-A/B-partition-set.
> The mechanism below is what the Deck **client** image uses (next section), and an option for
> operators on other immutable Arch derivatives who accept the prebuilt trade-off.

Build the package, then wrap its `/usr` payload into a sysext image:
```sh
# 1. build the pacman packages (needs an Arch environment / container)
cd packaging/arch && PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
( cd ../gamescope && makepkg -f -d --holdver )   # optional: the HDR gamescope companion
# 2. turn it into a sysext .raw (extracts the packages' /usr into an image + extension-release);
#    --gamescope folds the HDR build into a HOST image (verified by its +pfhdr banner)
bash build-sysext.sh --gamescope ../gamescope/punktfunk-gamescope-*.pkg.tar.zst punktfunk-host-*.pkg.tar.zst
# 3. on the box:
sudo cp punktfunk-host.raw /var/lib/extensions/
sudo systemctl enable --now systemd-sysext      # merges it
systemctl --user enable --now punktfunk-host     # the user unit is now under /usr/lib
```
The udev rule, sysctl, and systemd **user** unit all live under `/usr/lib`, so the merged sysext
exposes them. `systemd-sysext refresh` re-merges after a reboot. (One HDR nuance of the sysext
path: the image ships gamescope without `CAP_SYS_NICE`, so its frame pacing is marginally worse —
everything works. Capabilities inside the image: `punktfunk-host` carries **none**, on *either*
path, deliberately — one would make it unidentifiable to KWin and break desktop streaming;
`punktfunk-encode-worker` carries `cap_sys_nice=ep`, applied by `build-sysext.sh` because pacman
scriptlets never run for a sysext and a merged `/usr` is read-only. Both are asserted at build
time. See
[Running as a service](https://punktfunk.io/docs/running-as-a-service#gpu-scheduling-priority).)

## Steam Deck — the client (what the Decky plugin launches)

To stream *to* a Deck, you install **`punktfunk-client`** there — same sysext mechanism, but
wrapping the client package instead. The split `makepkg` produces both `.pkg.tar.zst` files; on the
Deck use the client one:
```sh
cd packaging/arch && PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
bash build-sysext.sh punktfunk-client-*.pkg.tar.zst        # → punktfunk-client.raw
# on the Deck:
sudo cp punktfunk-client.raw /var/lib/extensions/
sudo systemctl enable --now systemd-sysext
sudo pacman -S --needed libva-mesa-driver                  # VAAPI hw decode on the Deck's AMD APU
```
Now `punktfunk-client` is on `PATH`, so the **[Decky plugin](../../clients/decky/)** finds and
launches it (`punktfunk-client --connect host:port`) — gamescope composites its video like a game.
The client needs no `/dev/uinput` or compositor-spawning rights (it captures input and decodes),
so it's a much lighter sysext than the host.

## Firewall

**Stock Arch ships no firewall** — every port is open by default, so there is nothing to do.
Spins that enable one **do not** get their ports opened for you: an Arch package never touches the
admin's running firewall. **CachyOS is the common case** — it ships `ufw` enabled by default (not
firewalld), so out of the box the host is unreachable until you allow it. Some other spins (e.g.
EndeavourOS) enable `firewalld` instead.

The `punktfunk-host` package ships openers for **both** — a ufw application profile
(`/etc/ufw/applications.d/punktfunk`) and firewalld service definitions
(`/usr/lib/firewalld/services/`) — so enabling is one command whichever you run:

```sh
# ufw (CachyOS, and Ubuntu once you enable ufw) — reads the profile at once, no reload needed:
sudo ufw allow punktfunk-native        # the native-only host (the default)
sudo ufw allow punktfunk-gamestream    # …or add this for the Moonlight/GameStream host

# firewalld (EndeavourOS and other Fedora-like spins):
sudo firewall-cmd --reload                                        # pick up the installed def
sudo firewall-cmd --permanent --add-service=punktfunk-native
#                              --add-service=punktfunk-gamestream  # …for the Moonlight host
sudo firewall-cmd --reload
```

`punktfunk-gamestream` opens the fixed Moonlight ports + mDNS; `punktfunk-native` opens the QUIC
control port (UDP 9777) + mDNS + the mgmt/library API (TCP 47990, HTTPS + mTLS). Enable both if the
host runs `serve --gamestream` (which serves both planes). The **data plane is an *ephemeral* UDP port** the client opens with a hole-punch, so
there is no fixed data port in either service — the host streams back out through the path the
client opened, which any firewall that allows outbound UDP (the default) passes. The mgmt REST API
(TCP 47990, HTTPS + mTLS) binds all interfaces by default so paired clients can browse the game library
— the `punktfunk-native` profile opens it. Off-loopback it serves only read-only status/library to a
paired client cert and keeps the admin surface loopback-only (`--mgmt-bind 127.0.0.1:47990` to opt out).

If you installed the **web console** (`punktfunk-web`) and want it reachable from another device,
open its port with the matching one-liner — `sudo ufw allow punktfunk-web` or `sudo firewall-cmd
--permanent --add-service=punktfunk-web && sudo firewall-cmd --reload` — which opens **TCP 47992**
(HTTPS, login-gated). The mgmt API (47990) is opened for paired clients by the `punktfunk-native`
profile (game-library browsing over mTLS); off-loopback it serves only read-only status/library and
keeps admin loopback-only.

Prefer explicit rules (or a firewall the shipped profiles don't cover)? Open the ports directly.
The **native `punktfunk/1`** plane:

- **QUIC control plane: UDP 9777** (`serve --native-port N` to change).
- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
  tells the client which port it got. Video flows host → client, but the **client sends the first
  packet** (a hole-punch), so the host learns the client's real source and streams back — this
  traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound
  firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a
  stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay,
  pin it with **`serve --data-port <PORT>`** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed
  port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one
  session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the
  client's reported address to be reachable (flat LAN / a non-remapping port-forward).

And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with
`serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these:

| Port | Proto | Purpose |
|---|---|---|
| 47984 | TCP | HTTPS nvhttp (paired, mutual-TLS) |
| 47989 | TCP | HTTP nvhttp (`/serverinfo`, `/pair` PIN flow) |
| 48010 | TCP | RTSP handshake |
| 47998–48010 | UDP | Video RTP (+ FEC), ENet control (47999), audio (48000) |
| 5353 | UDP | mDNS auto-discovery |

The mgmt API (TCP 47990, HTTPS + mTLS) binds all interfaces by default so paired clients can browse the
game library — the `punktfunk-native` profile opens it. Off-loopback it serves only read-only
status/library to a paired client cert; the admin surface stays loopback-only. Pass
`--mgmt-bind 127.0.0.1:47990` to keep it loopback-only (then leave 47990 closed).

With `ufw` (explicit ports, instead of the shipped `punktfunk-native`/`punktfunk-gamestream` profile):

```sh
sudo ufw allow 9777/udp                                 # punktfunk/1 control plane
sudo ufw allow 47990/tcp                                # mgmt/library API (HTTPS + mTLS; LAN = read-only, paired)
sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
sudo ufw allow 47998,47999,48000/udp                    # GameStream video/control/audio
sudo ufw allow 5353/udp                                 # mDNS discovery
# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches
# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port
# 9778` and `ufw allow 9778/udp`.
```

With raw `nftables` (add to your `inet filter input` chain):

```
udp dport 9777 accept                  # punktfunk/1 control plane
tcp dport 47990 accept                 # mgmt/library API (HTTPS + mTLS; LAN = read-only, paired)
tcp dport { 47984, 47989, 48010 } accept
udp dport { 47998-48000, 5353 } accept # GameStream video/control/audio + mDNS
# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s
# fallback). Pin it with `serve --data-port <PORT>` to open exactly one instead.
```

## Files
- `PKGBUILD` — split package: `punktfunk-host` + `punktfunk-client` (builds the working tree via
  `PF_SRCDIR`, or a git tag for AUR).
- `punktfunk-host.install` / `punktfunk-client.install` — pacman scriptlets (udev reload + sysctl +
  first-run hint, incl. the ufw/firewalld enable command for whichever is present), mirror the RPM
  `%post` / deb postinst.
- The firewall openers are shared across all Linux packaging and live in [`../linux/`](../linux/):
  the ufw application profile (`punktfunk.ufw` → `/etc/ufw/applications.d/punktfunk`) and the
  firewalld service definitions (`punktfunk-native.xml` / `punktfunk-gamestream.xml` /
  `punktfunk-web.xml` → `/usr/lib/firewalld/services/`). None auto-enabled; see Firewall above.
- `build-sysext.sh` — wraps either built `.pkg.tar.zst` into a `systemd-sysext` `.raw` for SteamOS
  (derives the name from the package, so it works for host or client).
