Two bodies of work in one commit (the rename moved files the fixes also touched). Naming/structure cleanup (pre-launch): - Host modules m3.rs->punktfunk1.rs, m0.rs->spike.rs; CLI m3-host->punktfunk1-host, m0->spike; bare `punktfunk-host` now prints help. Types M3Options/M3Source-> Punktfunk1Options/Punktfunk1Source. - Clients consolidated out of crates/ into clients/: punktfunk-client-rs-> clients/probe (crate punktfunk-probe), client-linux->clients/linux, client-windows->clients/windows, punktfunk-android->clients/android/native (crate punktfunk-client-android; kept [lib] name=punktfunk_android so the JNI contract is unchanged). crates/ now holds only core + host. - Milestone codes M0-M4 purged from code/CLI/CLAUDE.md/README/docs/docs-site, kept only in docs/implementation-plan.md. docs/m2-plan.md-> docs/gamestream-host-plan.md. CI/gradle/flatpak paths updated. Client loss-recovery (video froze and never recovered after a brief drop): - Export punktfunk_connection_frames_dropped through the C ABI (the core already tracked it for the client keyframe-recovery loop; it was never reachable from the ABI clients). Regenerated punktfunk_core.h. - Apple (StreamPump + Stage2Pipeline) and Android (decode.rs) now poll frames_dropped and request a keyframe when it climbs -- the same loss-driven recovery Linux/Windows already had. Under infinite GOP the decoder silently conceals reference-missing frames, so the decode-error trigger rarely fires. Apple rumble robustness (worked then went spotty -- DualSense + Xbox): - Add CHHapticEngine stopped/reset handlers (rebuild on app background / audio interruption / server reset) and drop the permanent `broken` latch on a transient drive failure; latch only when the controller truly has no haptics. - Surface swallowed SDL set_rumble errors on Linux/Windows + diagnostic logging. Verified: cargo build/clippy/fmt --workspace, C-ABI harness, header drift. Not runnable on this box (verify in CI): Gitea workflows, gradle/Android, flatpak, Swift/decky. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
punktfunk Decky plugin (SteamOS / Steam Deck)
A Decky Loader plugin that adds a punktfunk panel to the Steam
Deck's Quick Access Menu (the QAM, opened with the … button), so you can launch the
punktfunk streaming client from Gaming Mode without dropping to the desktop.
Because Decky plugins run inside Steam's CEF, the panel is built from real Steam UI
primitives (@decky/ui: PanelSection, PanelSectionRow, ButtonItem, Field,
Spinner) — so it looks and feels native to Gaming Mode.
Full Gaming-Mode client. Discovery, a fullscreen page, in-UI SPAKE2 PIN pairing, stream settings, and a stream that actually launches fullscreen under gamescope (via a Steam shortcut, MoonDeck-style). The video itself is the existing GTK4 flatpak client (
io.unom.Punktfunk) — the plugin discovers, pairs, configures, and launches it the right way so gamescope focuses it. The Steam-shortcut launch + pairing need a real Deck in Gaming Mode to fully confirm.
What it does
- Discover — browses the LAN over mDNS for punktfunk/1 hosts (
_punktfunk._udp, backenddiscover()viaavahi-browse). Shown in both the QAM panel and a fullscreen page (Decky route/punktfunk, viarouterHook.addRoute). - Pair — for a
pair=requiredhost: a gamepad-navigable PIN keypad. The operator arms pairing on the host (it shows a 4-digit PIN), the user enters it on the Deck, and the backend runs the SPAKE2 ceremony headlessly via the flatpak client's--pairmode (pair()), persisting the host as paired so the stream then connects silently. - Stream — launches fullscreen in Gaming Mode. The plugin registers ONE hidden
non-Steam shortcut pointing at
bin/punktfunkrun.sh, passesPF_HOSTas the shortcut's Steam launch options, and starts it withSteamClient.Apps.RunGame— so gamescope focuses + fullscreens it. (A flatpak launched directly from the backend is invisible: gamescope only focuses the process tree Steam launched viareaper— gamescope#484.) The wrapper then execsflatpak run io.unom.Punktfunk --connect <host>. - Settings — resolution / refresh / bitrate / gamepad / mic, written to the client's
client-gtk-settings.json(get_settings/set_settings), which the launched client reads.
To leave the stream: the in-client controller chord (L1+R1+Start+Select) or close the "game" from the Steam overlay — exiting the client ends the Steam game and returns to Gaming Mode automatically.
Architecture
| File | Role |
|---|---|
src/index.tsx |
Frontend: QAM panel + the /punktfunk fullscreen page (host list, PIN keypad modal, settings). |
src/steam.ts |
Steam-shortcut launch (AddShortcut / SetAppLaunchOptions / RunGame) — the focus-correct stream start. |
src/backend.ts |
Typed callable bridges to main.py. |
bin/punktfunkrun.sh |
The launch wrapper the Steam shortcut targets (so the window is focusable). |
main.py |
Backend: discover / pair / runner_info / get_settings / set_settings / kill_stream. |
plugin.json |
Decky plugin manifest. |
decky.pyi |
Type stub for the injected decky module (vendored from the template). |
Discovery (discover())
Shells out to avahi-browse -rpt _punktfunk._udp (SteamOS and Bazzite ship
avahi-daemon; this avoids bundling python-zeroconf):
-rresolve services,-pparseable output,-tterminate after the cache dump.- Resolved records start with
=and are semicolon-separated:=;iface;protocol;name;type;domain;hostname;address;port;txt. - The
txtcolumn is space-separated, quoted"key=value"tokens. We read the keys the host advertises (crates/punktfunk-host/src/discovery.rs):proto,fp,pair,id. - Records are deduped on the
idTXT key (a host re-advertises per interface and across IPv4/IPv6), preferring the IPv4 address for the user-facing host string.
Client launch (connect())
The client binary punktfunk-client is resolved in order: PATH → /usr/bin →
/usr/local/bin → ~/.local/bin → a flatpak run io.unom.Punktfunk fallback. The resolved
argv and a clear client-not-found error surface to the UI. The child PID is tracked so
disconnect() (and plugin _unload) can terminate it.
On the Steam Deck the client install is the flatpak
io.unom.Punktfunk(packaging/flatpak/) — SteamOS/usris read-only and lackslibadwaita/libSDL3, so the flatpak (which bundles them) is the canonical path; the resolver's flatpak fallback launches exactly that.
Prerequisites
- Decky Loader installed on the Deck (https://decky.xyz/).
punktfunk-client(the GTK4/libadwaita Linux client, cratepunktfunk-client-linux) installed and runnable on the Deck — via.deb/RPM/flatpak, or symlinked into~/.local/bin.- avahi (
avahi-daemon+avahi-browse) for discovery — present on SteamOS/Bazzite. - A punktfunk/1 host on the LAN (
punktfunk-host serve --nativeorpunktfunk1-host).
Build
pnpm install
pnpm build # rollup → dist/index.js
(npm install && npm run build also works.)
Install on the Deck
Option A — Decky "install from URL" (recommended; published by CI)
CI (.gitea/workflows/decky.yml) builds the plugin into a store-layout zip and publishes it to
Gitea's generic package registry on every push to main and on v* tags, exposing a stable
URL. In Decky's settings → Developer Mode → Install Plugin from URL, paste:
https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.zip
(or a pinned version: .../punktfunk-decky/<version>/punktfunk.zip). On tags the same zip is
also attached to the Gitea release. The zip's layout is the store-required one — a single
top-level punktfunk/ dir holding plugin.json, package.json, main.py, dist/index.js,
README.md, and LICENSE.
Option B — manual dev copy (sideload)
Decky's ~/homebrew/plugins/ is root-owned (PluginLoader runs as root and manages it), so a
plain rsync into it fails — stage to a writable temp dir, then sudo-install and restart the
loader. The two helper scripts do exactly this:
cd clients/decky
pnpm install
pnpm run package # → out/punktfunk/ + out/punktfunk-v<ver>.zip
DECK=deck@<deck-ip> pnpm run deploy # rsync → /tmp, sudo cp into plugins/, chown root, restart
deploy.sh prompts for the Deck's sudo password interactively (via ssh -t); set DECKPASS=…
to run it non-interactively. Equivalent by hand:
cd clients/decky && pnpm build && bash scripts/package.sh
rsync -azp --delete out/punktfunk/ deck@<deck-ip>:/tmp/punktfunk/
ssh -t deck@<deck-ip> 'sudo sh -c "rm -rf ~deck/homebrew/plugins/punktfunk && \
cp -r /tmp/punktfunk ~deck/homebrew/plugins/punktfunk && \
chown -R root:root ~deck/homebrew/plugins/punktfunk && systemctl restart plugin_loader"'
A loader restart is required for an out-of-band install to appear. The punktfunk panel then shows up in the Quick Access Menu.
The plugin launches the client via the flatpak
io.unom.Punktfunk(see../../packaging/flatpak/README.md) — install that on the Deck too, or the panel's Connect surfaces aclient-not-founderror.
Limitations / next steps
- Needs on-Deck validation in Gaming Mode: the Steam-shortcut launch (
AddShortcut/RunGame/ thegameIdencoding) and the headless pairing env are coded to MoonDeck's proven pattern but verified only at build time here. - mDNS discovery depends on
avahi-browse; no manual "add host by IP" entry yet. - No in-stream overlay (latency/bitrate HUD) inside the plugin — the client owns the session once launched; leave it with the L1+R1+Start+Select chord.
- Pairing requires the operator to arm pairing on the host (so it shows the PIN); the plugin can't arm it remotely (no host mgmt token on the Deck).
- Settings are written to the flatpak's sandbox config path; if the client ever moves its config location, that path mapping must follow.