Files
punktfunk/clients/decky
enricobuehler 2fd303e22f refactor(decky): delete the second client
The Decky plugin was a second client. It had its own mDNS discovery, its own host-store
editor, its own settings UI over the entire client settings store, its own per-game pin store
and picker, and its own fullscreen route with three tabs — about 3,000 lines of TypeScript and
Python mirroring, in two other languages, things the Rust client already does. Every one of
them drifted from the original: the TXT parser fell behind each key the host advert added, the
settings screen modelled a subset of a store that kept growing.

They existed because when this plugin was written there was nothing headless to ask. There has
been since v0.22.0, so this deletes them.

GONE, frontend: page.tsx (the fullscreen route), settings.tsx (a seven-page sidebar over the
whole store), hostmgmt.tsx (add/edit/forget), library.tsx (the games picker), ui.tsx (row
primitives only the page used).

GONE, backend: get/set_settings, list/refresh_devices, library, get/set_pins, list_hosts,
add/edit/forget_host, probe_host, reset_config, wake, the avahi browse and its TXT parser, and
the direct reads of client-known-hosts.json.

WHAT REPLACES THE BACKEND is four shells, each about fifteen lines of build-argv-run-parse:

  discover()    -> punktfunk discover --json
  hosts()       -> punktfunk hosts list --probe --json
  pair()        -> punktfunk pair <addr:port> --pin N --name LABEL
  trust_host()  -> punktfunk hosts add <addr:port> --fp HEX --name LABEL

trust_host is the ONLY write this backend makes to the client's store, and it goes through the
CLI — which writes temp+rename into a user-owned directory, so a root backend driving it
cannot lock the desktop client out of its own files. Nothing here opens client-known-hosts.json
or client-profiles.json any more; `hosts list --json` returns profile bindings and pinned cards
already resolved against the catalog.

_cli_argv mirrors the deleted _session_argv exactly, pointed at `punktfunk`: the flatpak app id
stays LAST, because flatpak treats everything after it as the app's own argv. The
LD_LIBRARY_PATH repair applies unchanged — Decky's PyInstaller leak breaks the flatpak's
libcurl whichever binary inside the sandbox is being started.

A client too old for a verb now announces itself DETERMINISTICALLY: exit 5 plus
`unknown command "<verb>"`, mapped to `client-outdated`, which the panel renders as one
explanatory row plus the update button that fixes it. That replaces guessing from GTK-init
noise, which survives only where the update check still drives `punktfunk-client` directly.

KEPT unchanged in mechanism, because only a Decky plugin can do them: runner_info,
shortcut_art, apply_controller_config, check_update/update_client, kill_stream.

The settings screen is not lost, it moved: console home -> Settings has the same rows over the
same store, is gamepad-navigable, and is one tap from this same panel. Per-game pins have no
shared equivalent yet — decky-pinned.json is deliberately left ON DISK, untouched, so a later
migration can read it.

test-backend.py is rewritten against what is left — argv shape, the exit-code mapping, and the
Steam configset editor, which was untested until now and is the riskiest thing that survived:
it edits a file holding hundreds of other games' bindings, in place.
2026-08-04 20:41:07 +02:00
..

Punktfunk — Steam Deck plugin (Decky)

Stream to your Steam Deck without ever leaving Gaming Mode. This Decky Loader plugin adds a Punktfunk panel to the Quick Access Menu (the button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable.

The video itself is the native GTK4 Linux client (the io.unom.Punktfunk flatpak); the plugin discovers, pairs, configures, and launches it the right way so gamescope fullscreens it — the same Steam-shortcut trick MoonDeck uses. Because it's built from real Steam UI primitives (@decky/ui), the panel looks and feels native to Gaming Mode.

What it does

  1. Discover — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a fullscreen page; each host row opens a details view (address, pairing policy, certificate fingerprint to cross-check against the host's log).
  2. Pair — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing ceremony headlessly, then remembers the host so future streams connect silently.
  3. Stream — launches fullscreen via a branded "Punktfunk" Steam shortcut so gamescope focuses it.
  4. Games — each host row has a games button that opens its library picker: pin titles as one-tap "Stream " rows in the QAM (jump straight into e.g. Playnite on the host), or "Open library on screen" to launch the client's controller-driven, console-style library browser (aurora backdrop + poster coverflow; A plays, B returns to Gaming Mode). Pins survive plugin reinstalls (stored next to the client's config) and follow a host across IP changes (matched by certificate fingerprint).
  5. Settings — the client's whole settings store, written to its config. Laid out like SteamOS's own Settings: a left rail of categories (SidebarNavigation), one page each, so no page needs scrolling. The categories and their order are the console settings screen's — Stream (resolution / refresh / render scale / bitrate / compositor), Video (codec / decoder / GPU / HDR / 4:4:4), Presentation (prioritize / smoothness buffer / V-Sync / VRR), Audio (channels / output + mic device / echo cancellation), Controllers, Touch & mouse, Interface (stats overlay / auto-wake / library / fullscreen). The device pickers are populated from the session binary (--list-adapters / --list-audio); the GPU row appears only where there is more than one adapter.
  6. About — plugin version, an explicit "Check for updates" button, the setup-guide link, and a force-stop for a wedged stream client.

To leave a stream: the in-client controller chord (L1 + R1 + Start + Select), or close the "game" from the Steam overlay — either returns you to Gaming Mode.

Install on the Deck

You need Decky Loader and the io.unom.Punktfunk flatpak (packaging/flatpak) installed on the Deck — SteamOS /usr is read-only, so the flatpak (which bundles libadwaita/SDL3) is the canonical client. Discovery uses avahi-browse, which ships on SteamOS/Bazzite.

Recommended — install from URL (published by CI): in Decky → Settings → Developer ModeInstall Plugin from URL, paste:

https://unom.io/pf-decky

(short link for https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip; for a pinned version use https://git.unom.io/api/packages/unom/generic/punktfunk-decky/<version>/punktfunk.zip directly). The plugin then self-updates without the Decky store — when a newer build exists, an Update button appears and drives Decky Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed before the actual download proceeds.

Updating the client

The plugin also reports — and where it can, installs — updates for the client it launches. What is possible depends on how that client was installed, and the About tab names the install kind so the answer is never a mystery:

Install Update
Flatpak (the usual Deck client) One tap. flatpak update --user io.unom.Punktfunk — a per-user install, which is why sudo flatpak update never touches it.
.deb / .rpm (and rpm-ostree, which stages for the next reboot) One tap, after an explicit opt-in: sudo usermod -aG punktfunk-update $USER. The tap starts a fixed, parameterless root oneshot (punktfunk-client-update.service) through polkit — nothing about the request is attacker-influenceable, and the payload comes from your distro's own signed repositories.
pacman Same, plus the root-owned PACMAN_FULL_SYSUPGRADE=1 in /etc/punktfunk/update.conf — a partial upgrade is against Arch doctrine, so the only thing the helper will run is a full pacman -Syu.
sysext, nix, a source build The plugin shows the command and stops. There is no feed behind those installs, and a button that can only fail is worse than one honest line.

Whether a newer client exists is the client's own answer (punktfunk-client --check-update), read from the Ed25519-signed per-channel manifest the host's update check already trusts — PUNKTFUNK_UPDATE_CHECK=0 disables the check, PUNKTFUNK_UPDATE_APPLY=0 keeps the check but never offers to install. A client too old to have that mode is reported as such rather than as up to date.

Build & sideload (development)

cd clients/decky
pnpm install
pnpm build                             # rollup → dist/index.js
pnpm run package                       # → out/punktfunk/ + out/punktfunk-v<ver>.zip
DECK=deck@<deck-ip> pnpm run deploy    # rsync → /tmp, sudo-install into the root-owned plugins dir, restart loader

~/homebrew/plugins/ is root-owned (the loader runs as root), so deploy.sh stages to a temp dir then sudo-installs and restarts the loader — set DECKPASS=… to run it non-interactively. A loader restart is required for an out-of-band install to appear.

Architecture

File Role
src/index.tsx Plugin entry: the QAM panel + route registration.
src/page.tsx The /punktfunk fullscreen page — Hosts (with per-host details) / Settings / About tabs.
src/settings.tsx · src/pair.tsx The settings screen (a SidebarNavigation of seven category pages over one shared settings object); the gamepad-navigable PIN-pairing modal.
src/library.tsx The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper.
src/hostmgmt.tsx Add / edit host dialogs — mutate the shared known-hosts store (client-known-hosts.json) via the flatpak client's headless modes, so a host saved here shows up in the desktop client too.
src/ui.tsx Shared UI primitives for the fullscreen page + modals (right-aligned row actions, consistent Field layout).
src/hooks.ts · src/boundary.tsx Shared discovery/update/pins hooks + actions; the render error boundary.
src/steam.ts Steam-shortcut launch (AddShortcut / SetAppLaunchOptions / RunGame) — the focus-correct stream start. The shortcut's exe is /bin/sh with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). Launch extras ride env-prefix tokens: PF_LAUNCH=<id> (pinned game) / PF_BROWSE=1 + PF_MGMT=<port> (on-screen library); ids are validated space/quote-free at pin AND launch time.
src/backend.ts Typed callable bridges to main.py.
bin/punktfunkrun.sh The launch wrapper the Steam shortcut runs (so the window is focusable); maps PF_LAUNCH/PF_BROWSE/PF_MGMT to --launch/--browse/--mgmt. An older flatpak ignores the flags harmlessly (plain stream / hosts page).
main.py Backend: discover (via avahi-browse) / pair / library (headless flatpak --library, TSV) / pins store (decky-pinned.json) / settings / kill_stream / check_update (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS).
scripts/test-backend.py Stdlib-only checks for the backend's pure parsers (TSV, error classes, avahi TXT) + the pins round trip.
plugin.json · update.json Decky manifest; CI-baked update channel.

Limitations / next steps

  • No manual "add host by IP" entry yet (discovery is mDNS-only).
  • No in-stream overlay inside the plugin — the client owns the session once launched.
  • Pairing needs the operator to arm pairing on the host so it shows the PIN; the plugin can't arm it remotely.