feat(decky): pinned-games library + self-update robustness; fix gamepad tab-nav

Decky client batch:
- Pinned games / library picker: per-host game grid (GamePickerModal),
  pin/unpin, one-tap streams surfaced on the Hosts tab and QAM
  (usePins/streamPin/resolvePinHost, new src/library.tsx).
- Self-update + client-update plumbing (main.py check_update, hooks.ts
  applyUpdate) with a CA-bundle-resolving SSL context and per-channel
  manifest polling; steam.ts / punktfunkrun.sh launch tweaks.
- scripts/test-backend.py harness for the backend RPCs; README refresh.

Fix: the fullscreen page wrapped <Tabs> in an overflow-visible box, so
Valve's L1/R1 tab slide + autoFocusContents scrollIntoView panned
#GamepadUI itself — the whole Steam UI slid left until a tab was clicked.
Clip the Tabs wrapper (overflow:hidden), matching Valve's own Tabs
containers. (On-glass verification pending — Deck offline this session.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 21:25:07 +00:00
parent 449a67ce8d
commit 8470419433
10 changed files with 942 additions and 28 deletions
+14 -6
View File
@@ -18,9 +18,15 @@ the panel looks and feels native to Gaming Mode.
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. **Settings**resolution / refresh / bitrate / gamepad type / host compositor / mic, written
4. **Games**each host row has a games button that opens its **library picker**: pin titles as
one-tap "Stream <Game>" 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** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written
to the client's config.
5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and
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
@@ -67,11 +73,13 @@ restart is required for an out-of-band install to appear.
| `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` | Stream-settings section; the gamepad-navigable PIN-pairing modal. |
| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update 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). |
| `src/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `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). |
| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). |
| `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
+24 -2
View File
@@ -11,11 +11,19 @@
#
# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch
# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves
# every host:
# every host (and every pinned game):
# PF_HOST host[:port] to connect to (required)
# PF_LAUNCH library id to launch on connect (optional, e.g. steam:570 — pinned games)
# PF_BROWSE non-empty = open the gamepad library (optional; --browse instead of --connect)
# PF_MGMT management-API port for --browse (optional; client defaults to 47990)
# PF_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH)
#
# Values are plain tokens (the plugin validates launch ids to space/quote-free ASCII before
# they ever reach Steam launch options). An older flatpak without --launch/--browse ignores
# the unknown flags harmlessly (hand-scanned argv): PF_LAUNCH degrades to the plain desktop
# session, PF_BROWSE to the client's hosts page.
#
# Runs as the `deck` user (Steam launched it), so the --user flatpak install is visible and
# WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
#
@@ -33,9 +41,23 @@ if [ -z "${PF_HOST:-}" ]; then
exit 2
fi
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and
# Gaming Mode reclaims focus automatically (no manual refocus needed).
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the
# Deck/gamescope env, and ignores the flag harmlessly on older builds that predate it).
if [ -n "${PF_BROWSE:-}" ]; then
# The gamepad library launcher: browse the host's games on-screen, A streams one,
# session end returns to the launcher, B quits back to Gaming Mode.
echo "punktfunkrun: library $APPID --browse $PF_HOST" >&2
if [ -n "${PF_MGMT:-}" ]; then
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen
fi
exec "$FLATPAK" run --arch=x86_64 "$APPID" --browse "$PF_HOST" --fullscreen
fi
if [ -n "${PF_LAUNCH:-}" ]; then
# A pinned game: the id rides the session Hello and the host launches that title.
echo "punktfunkrun: streaming $APPID --connect $PF_HOST --launch $PF_LAUNCH" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --launch "$PF_LAUNCH" --fullscreen
fi
echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2
exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" --fullscreen
+163 -2
View File
@@ -12,6 +12,11 @@ The backend's jobs are the things Steam can't do:
* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak
client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same
identity store the stream uses), so once paired the stream connects silently.
* **library(host, mgmt_port, fp)** — fetch a paired host's game library headlessly via the
flatpak client's ``--library`` mode (mTLS with the client's own identity; TSV on stdout),
so the picker UI can offer games to pin.
* **get_pins() / set_pins()** — the pinned-games store (``decky-pinned.json`` next to the
client's config, so pins survive plugin reinstalls), annotated with live pairing state.
* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to
the frontend so it can create/point the Steam shortcut.
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON
@@ -20,8 +25,8 @@ The backend's jobs are the things Steam can't do:
* **check_update()** — poll the registry's per-channel ``manifest.json`` and report whether a
newer build is available (the frontend then drives Decky's own install RPC to apply it).
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host
advert in ``crates/punktfunk-host/src/discovery.rs``.
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
"""
import asyncio
@@ -77,6 +82,46 @@ def _runner_path() -> str:
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh")
def _pins_path() -> Path:
"""The pinned-games store — plugin-owned, but deliberately in the CLIENT's config dir
(like everything else we persist): the plugins dir is root-owned and wiped on
reinstall, while ``~/.config/punktfunk`` survives both."""
return _client_config_dir() / "decky-pinned.json"
def _parse_library_tsv(stdout: str) -> list[dict]:
"""Parse the flatpak client's ``--library`` output: one ``id\\tstore\\ttitle`` line per
game plus a trailing ``N game(s)`` count line (no tabs — it self-skips here). A title
may itself contain tabs, so split at most twice."""
games: list[dict] = []
for line in stdout.splitlines():
parts = line.split("\t", 2)
if len(parts) == 3:
games.append({"id": parts[0], "store": parts[1], "title": parts[2]})
return games
def _classify_library_error(stderr: str) -> str:
"""Map the client's ``library: <LibraryError Display>`` stderr line to a stable error
code for the UI. Substring-matched against the Display strings in
``clients/linux/src/library.rs`` — a wording change degrades to ``client-error``
(generic copy), never a crash."""
s = stderr.lower()
if "didn't recognize this device" in s:
return "not-paired"
if "pinned fingerprint" in s:
return "pin-mismatch"
if "couldn't reach the host" in s:
return "unreachable"
if "management api returned http" in s:
return "http"
if "display" in s or "gtk" in s:
# A flatpak so old it predates --library falls through to GTK init, which fails
# headless from this backend.
return "client-outdated"
return "client-error"
# ----------------------------------------------------------------------------------------
# Self-update check (no Decky store). The plugin is distributed via "Install Plugin from
# URL" pointing at our Gitea generic registry, so the official store never sees it and
@@ -339,6 +384,11 @@ def _parse_avahi_browse(stdout: str) -> list[dict]:
if props.get("proto") and not props["proto"].startswith("punktfunk/"):
continue
try:
mgmt = int(props.get("mgmt", ""))
except ValueError:
mgmt = 0 # not advertised (standalone punktfunk1-host) — callers default 47990
entry = {
"name": name,
"host": address,
@@ -346,6 +396,8 @@ def _parse_avahi_browse(stdout: str) -> list[dict]:
"pair": props.get("pair", "optional"),
"fp": props.get("fp", ""),
"proto": props.get("proto", ""),
"id": props.get("id", ""),
"mgmt": mgmt,
}
key = props.get("id") or f"{address}:{port}"
existing = out.get(key)
@@ -437,6 +489,115 @@ class Plugin:
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
return {"ok": False, "error": reason}
async def library(self, host: str, mgmt_port: int = 0, fp: str = "") -> dict:
"""Fetch a paired host's game library via the flatpak client's headless
``--library`` mode (the client's own mTLS identity + pinned-fingerprint transport —
no trust logic reimplemented here). ``fp`` is passed through whenever the caller
knows the host's cert fingerprint so an IP change can never degrade the pin to a
TOFU accept. Returns ``{ok, games: [{id, store, title}]}`` or
``{ok: False, error: <code>, detail}`` (codes: ``flatpak-not-found`` / ``timeout`` /
``not-paired`` / ``pin-mismatch`` / ``unreachable`` / ``http`` /
``client-outdated`` / ``client-error``)."""
flatpak = _flatpak()
if not flatpak:
return {"ok": False, "error": "flatpak-not-found", "detail": ""}
target = f"{host}:{int(mgmt_port) or 47990}"
argv = [flatpak, "run", "--arch=x86_64", APP_ID, "--library", target]
if fp:
argv += ["--fp", fp]
decky.logger.info("library: fetching %s", target)
proc = None
try:
# Separate pipes (unlike _flatpak_capture): the TSV comes on stdout, the
# client's one-line error reason on stderr. Cold flatpak start on a Deck can
# take seconds — generous timeout, spinner in the UI.
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=45.0)
except asyncio.TimeoutError:
if proc:
try:
proc.kill()
except ProcessLookupError:
pass
return {"ok": False, "error": "timeout", "detail": ""}
except Exception as exc: # noqa: BLE001
decky.logger.exception("library fetch failed to launch")
return {"ok": False, "error": "client-error", "detail": str(exc)}
err = stderr.decode(errors="replace")
if proc.returncode != 0:
detail = (err.strip().splitlines() or ["library fetch failed"])[-1]
code = _classify_library_error(err)
decky.logger.warning("library fetch failed (%s): %s", code, detail)
return {"ok": False, "error": code, "detail": detail}
games = _parse_library_tsv(stdout.decode(errors="replace"))
decky.logger.info("library: %d game(s) from %s", len(games), target)
return {"ok": True, "games": games}
async def get_pins(self) -> dict:
"""The pinned games, each annotated with the LIVE ``paired`` state of its host (by
cert fingerprint — an unpaired-since host renders "pairing required" in the QAM)."""
try:
data = json.loads(_pins_path().read_text())
except (OSError, json.JSONDecodeError):
return {"pins": []}
pins = data.get("pins", []) if isinstance(data, dict) else []
paired = _paired_fingerprints()
out = []
for p in pins:
if not isinstance(p, dict) or not p.get("game_id"):
continue
p = dict(p)
p["paired"] = str(p.get("host_fp", "")).lower() in paired
out.append(p)
return {"pins": out}
async def set_pins(self, pins: list) -> dict:
"""Persist the pinned-games list (the frontend sends the whole list — add, remove,
and address-refresh all funnel through here). Validated + deduped on
``(host_fp, game_id)``; written atomically (tmp + rename) — pins are long-lived
user data."""
clean: list[dict] = []
seen: set[tuple[str, str]] = set()
for p in pins if isinstance(pins, list) else []:
if not isinstance(p, dict):
continue
game_id = str(p.get("game_id", ""))
host_fp = str(p.get("host_fp", ""))
if not game_id or not (host_fp or p.get("host")):
continue
key = (host_fp, game_id)
if key in seen:
continue
seen.add(key)
clean.append({
"game_id": game_id,
"title": str(p.get("title", game_id)),
"store": str(p.get("store", "")),
"host_fp": host_fp,
"host_id": str(p.get("host_id", "")),
"host_name": str(p.get("host_name", p.get("host", ""))),
"host": str(p.get("host", "")),
"port": int(p.get("port", 9777) or 9777),
"mgmt": int(p.get("mgmt", 0) or 0),
"added_at": int(p.get("added_at", 0) or 0),
})
try:
d = _client_config_dir()
d.mkdir(parents=True, exist_ok=True)
tmp = _pins_path().with_suffix(".json.tmp")
tmp.write_text(json.dumps({"version": 1, "pins": clean}, indent=2))
os.replace(tmp, _pins_path())
return {"ok": True}
except OSError as exc:
decky.logger.exception("could not write pins")
return {"ok": False, "error": str(exc)}
async def shortcut_art(self) -> dict:
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Unit checks for main.py's pure helpers — stdlib only, no Decky runtime needed.
Stubs the ``decky`` module (main.py imports it at module level), then asserts the
avahi/TSV/error parsers against fixture strings. The LibraryError fixtures are pinned to
the REAL Display strings in clients/linux/src/library.rs — if those are reworded, the
classifier degrades to ``client-error`` and the matching assertion here fails on purpose.
python3 clients/decky/scripts/test-backend.py
"""
import sys
import types
from pathlib import Path
# ---- stub the decky module before importing main.py ------------------------------------
decky = types.ModuleType("decky")
decky.DECKY_USER_HOME = "/tmp/pf-test-home"
decky.DECKY_PLUGIN_DIR = "/tmp/pf-test-plugin"
class _Log:
def __getattr__(self, _name):
return lambda *a, **k: None
decky.logger = _Log()
sys.modules["decky"] = decky
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import main # noqa: E402 (the plugin backend)
failures = 0
def check(name: str, cond: bool):
global failures
print(("ok " if cond else "FAIL") + " " + name)
if not cond:
failures += 1
# ---- _parse_library_tsv -----------------------------------------------------------------
tsv = (
"steam:570\tsteam\tDota 2\n"
"custom:abc\tcustom\tTabs\tin\ttitle\n" # tabs inside the title survive (split max 2)
"2 game(s)\n" # the count trailer has no tabs — self-skips
)
games = main._parse_library_tsv(tsv)
check("tsv: two games parsed", len(games) == 2)
check("tsv: fields", games[0] == {"id": "steam:570", "store": "steam", "title": "Dota 2"})
check("tsv: tabs in title preserved", games[1]["title"] == "Tabs\tin\ttitle")
check("tsv: empty input", main._parse_library_tsv("0 game(s)\n") == [])
# ---- _classify_library_error (fixtures = library.rs Display strings) --------------------
check(
"err: not-paired",
main._classify_library_error(
"library: The host didn't recognize this device. Pair with the host first — the "
"library is authorized by this device's certificate (no token needed)."
)
== "not-paired",
)
check(
"err: pin-mismatch",
main._classify_library_error(
"library: The host's certificate doesn't match the pinned fingerprint. "
"Re-pair with a PIN to re-establish trust."
)
== "pin-mismatch",
)
check(
"err: unreachable",
main._classify_library_error(
"library: Couldn't reach the host's management API: connection refused. Check the "
"host is updated and reachable."
)
== "unreachable",
)
check(
"err: http",
main._classify_library_error("library: The management API returned HTTP 500.") == "http",
)
check(
"err: outdated client (GTK init noise)",
main._classify_library_error("cannot open display: \nGtk-WARNING: init failed")
== "client-outdated",
)
check("err: generic fallback", main._classify_library_error("boom") == "client-error")
# ---- _parse_avahi_browse (incl. the new id/mgmt TXT keys) --------------------------------
avahi = (
"+;eth0;IPv4;living-room;_punktfunk._udp;local\n"
"=;eth0;IPv4;living-room;_punktfunk._udp;local;lr.local;192.168.1.42;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv6;living-room;_punktfunk._udp;local;lr.local;fe80::1;9777;"
'"proto=punktfunk/1" "fp=aabbcc" "pair=required" "id=abc123" "mgmt=47990"\n'
"=;eth0;IPv4;bare-host;_punktfunk._udp;local;bh.local;192.168.1.77;9777;"
'"proto=punktfunk/1" "fp=ddeeff" "pair=optional"\n'
)
hosts = main._parse_avahi_browse(avahi)
check("avahi: two hosts (id-dedup, IPv4 preferred)", len(hosts) == 2)
lr = next(h for h in hosts if h["name"] == "living-room")
check("avahi: ipv4 wins", lr["host"] == "192.168.1.42")
check("avahi: mgmt parsed", lr["mgmt"] == 47990)
check("avahi: id parsed", lr["id"] == "abc123")
bare = next(h for h in hosts if h["name"] == "bare-host")
check("avahi: mgmt absent -> 0", bare["mgmt"] == 0)
check("avahi: id absent -> empty", bare["id"] == "")
# ---- pins store (round-trip through the real methods, isolated HOME) --------------------
import asyncio # noqa: E402
import shutil # noqa: E402
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
plugin = main.Plugin()
pin = {
"game_id": "steam:570",
"title": "Dota 2",
"store": "steam",
"host_fp": "AABBCC",
"host_id": "abc123",
"host_name": "living-room",
"host": "192.168.1.42",
"port": 9777,
"mgmt": 47990,
"added_at": 1780000000,
}
dupe = dict(pin, title="Dota 2 again")
junk = {"title": "no game id"}
res = asyncio.run(plugin.set_pins([pin, dupe, junk]))
check("pins: write ok", res.get("ok") is True)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: dedup + junk dropped", len(got) == 1)
check("pins: unpaired without known-hosts", got[0]["paired"] is False)
# Mark the host paired in the client's known-hosts store — get_pins must pick it up.
cfg = main._client_config_dir()
cfg.mkdir(parents=True, exist_ok=True)
(cfg / "client-known-hosts.json").write_text(
'{"hosts": [{"name": "living-room", "addr": "192.168.1.42", "port": 9777, '
'"fp_hex": "aabbcc", "paired": true}]}'
)
got = asyncio.run(plugin.get_pins())["pins"]
check("pins: paired via known-hosts fp (case-insensitive)", got[0]["paired"] is True)
shutil.rmtree(decky.DECKY_USER_HOME, ignore_errors=True)
print()
if failures:
print(f"{failures} check(s) FAILED")
sys.exit(1)
print("all checks passed")
+47
View File
@@ -9,6 +9,43 @@ export interface Host {
fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
proto: string; // advertised protocol, e.g. "punktfunk/1"
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint)
id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised)
mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990)
}
// One title from a host's game library (the flatpak client's --library TSV, parsed by the
// backend). `id` is store-qualified (steam:<appid> / custom:<id>) and doubles as the
// launch handle (PF_LAUNCH → the session Hello).
export interface GameEntry {
id: string;
store: string; // "steam" | "custom" | "heroic" | "lutris" | …
title: string;
}
export interface LibraryResult {
ok: boolean;
games?: GameEntry[];
// "flatpak-not-found" | "timeout" | "not-paired" | "pin-mismatch" | "unreachable" |
// "http" | "client-outdated" | "client-error"
error?: string;
detail?: string; // the client's own one-line reason, for the generic error copy
}
// A pinned game — a one-tap stream row in the QAM. The host is identified primarily by
// cert fingerprint (survives IP changes; pairing is fp-keyed too), with the stored
// address as the launch fallback when the host isn't currently advertising.
export interface PinnedGame {
game_id: string;
title: string;
store: string;
host_fp: string;
host_id: string;
host_name: string;
host: string;
port: number;
mgmt: number;
added_at: number; // unix seconds
paired?: boolean; // annotated by get_pins from the client's known-hosts store
}
export interface PairResult {
@@ -68,6 +105,16 @@ export const pair = callable<
[host: string, port: number, pin: string, name: string],
PairResult
>("pair");
// Fetch a paired host's game library (headless flatpak --library; can take seconds on a
// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade.
export const library = callable<
[host: string, mgmt_port: number, fp: string],
LibraryResult
>("library");
export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins");
export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>(
"set_pins",
);
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
export const getSettings = callable<[], StreamSettings>("get_settings");
+156 -5
View File
@@ -2,8 +2,18 @@
import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useState } from "react";
import { checkUpdate, discover, Host, updateClient, UpdateInfo } from "./backend";
import { launchStream } from "./steam";
import {
checkUpdate,
discover,
GameEntry,
getPins,
Host,
PinnedGame,
setPins as setPinsBackend,
updateClient,
UpdateInfo,
} from "./backend";
import { LaunchOpts, launchStream } from "./steam";
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
@@ -169,12 +179,153 @@ export async function applyUpdate(
// ----------------------------------------------------------------------------------------
// Stream launch — via the hidden Steam shortcut (see steam.ts for why).
// ----------------------------------------------------------------------------------------
export async function startStream(h: Host): Promise<void> {
export async function startStream(
h: Host,
opts: LaunchOpts = {},
label?: string,
): Promise<void> {
try {
await launchStream(h.host, h.port);
await launchStream(h.host, h.port, opts);
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting stream${h.name}` });
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
export async function startBrowse(h: Host): Promise<void> {
try {
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
} catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
}
}
// ----------------------------------------------------------------------------------------
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
// client's config (survives plugin reinstalls).
// ----------------------------------------------------------------------------------------
export interface PinsApi {
pins: PinnedGame[];
addPin: (h: Host, g: GameEntry) => void;
removePin: (hostFp: string, gameId: string) => void;
isPinned: (hostFp: string, gameId: string) => boolean;
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
updatePinHost: (pin: PinnedGame, h: Host) => void;
refresh: () => Promise<void>;
}
export function usePins(): PinsApi {
const [pins, setPins] = useState<PinnedGame[]>([]);
const refresh = useCallback(async () => {
try {
setPins((await getPins()).pins);
} catch {
/* backend unavailable — keep the current view */
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Optimistic local state; the backend validates/dedups and is re-read on failure.
const save = useCallback(
(next: PinnedGame[]) => {
setPins(next);
setPinsBackend(next).catch(() => void refresh());
},
[refresh],
);
const addPin = useCallback(
(h: Host, g: GameEntry) => {
const pin: PinnedGame = {
game_id: g.id,
title: g.title,
store: g.store,
host_fp: h.fp,
host_id: h.id,
host_name: h.name,
host: h.host,
port: h.port,
mgmt: h.mgmt,
added_at: Math.floor(Date.now() / 1000),
paired: h.paired,
};
save([
...pins.filter((p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id)),
pin,
]);
},
[pins, save],
);
const removePin = useCallback(
(hostFp: string, gameId: string) => {
save(pins.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
},
[pins, save],
);
const isPinned = useCallback(
(hostFp: string, gameId: string) =>
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
[pins],
);
const updatePinHost = useCallback(
(pin: PinnedGame, h: Host) => {
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
return;
}
save(
pins.map((p) =>
p.host_fp === pin.host_fp && p.game_id === pin.game_id
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
: p,
),
);
},
[pins, save],
);
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
}
/**
* The host a pin should launch against right now: match the live mDNS scan by cert
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
* id, else fall back to the stored address (host offline or scan flaky — still launch).
*/
export function resolvePinHost(
pin: PinnedGame,
live: Host[],
): { host: Host; online: boolean } {
const fp = pin.host_fp.toLowerCase();
const match =
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
undefined;
if (match) {
return { host: match, online: true };
}
return {
host: {
name: pin.host_name || pin.host,
host: pin.host,
port: pin.port,
pair: pin.paired ? "optional" : "required",
fp: pin.host_fp,
proto: "",
paired: !!pin.paired,
id: pin.host_id,
mgmt: pin.mgmt,
},
online: false,
};
}
+40 -3
View File
@@ -12,18 +12,30 @@ import {
} from "@decky/ui";
import { definePlugin, routerHook } from "@decky/api";
import { FC } from "react";
import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa";
import { FaDownload, FaLock, FaLockOpen, FaPlay, FaSyncAlt, FaTv } from "react-icons/fa";
import { PluginErrorBoundary } from "./boundary";
import { applyUpdate, checkForUpdatesNow, hasUpdate, startStream, useHosts, useUpdate } from "./hooks";
import {
applyUpdate,
checkForUpdatesNow,
hasUpdate,
resolvePinHost,
startStream,
useHosts,
usePins,
useUpdate,
} from "./hooks";
import { streamPin } from "./library";
import { PunktfunkRoute, ROUTE } from "./page";
import { PairModal } from "./pair";
// ----------------------------------------------------------------------------------------
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts.
// QAM panel — quick status + entry into the full page + one-tap stream for known hosts
// and pinned games.
// ----------------------------------------------------------------------------------------
const QamPanel: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
return (
<>
@@ -65,6 +77,31 @@ const QamPanel: FC = () => {
</PanelSectionRow>
</PanelSection>
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page → host row → games button). */}
{pins.pins.length > 0 && (
<PanelSection title="Games">
{pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts);
return (
<PanelSectionRow key={`${pin.host_fp}:${pin.game_id}`}>
<ButtonItem
layout="below"
onClick={() => streamPin(pin, hosts, pins)}
label={pin.title}
description={`${pin.host_name}${online ? "" : " · offline?"}${
pin.paired ? "" : " · pairing required"
}`}
>
<FaPlay style={{ marginRight: "0.5em" }} />
Stream
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
)}
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
+219
View File
@@ -0,0 +1,219 @@
// The per-host game picker + pinned-game launch helper. The picker fetches a paired
// host's library through the backend (headless flatpak --library — a cold client start
// 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
// library (`--browse`).
import { DialogButton, Field, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair";
/** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string {
switch (store) {
case "steam":
return "Steam";
case "custom":
return "Custom";
case "heroic":
return "Heroic";
case "lutris":
return "Lutris";
case "epic":
return "Epic";
case "gog":
return "GOG";
case "xbox":
return "Xbox";
default:
return "Game";
}
}
/**
* Stream a pinned game: resolve the host from the live scan (fp → id → stored address),
* opportunistically refresh a drifted stored address, and route through pairing first if
* this device is no longer paired with the host.
*/
export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
const { host, online } = resolvePinHost(pin, live);
if (online) {
pins.updatePinHost(pin, host); // no-op unless the address actually drifted
}
if (!pin.paired) {
showModal(
<PairModal
host={host}
onPaired={() => {
void pins.refresh(); // pick up the now-paired annotation
void startStream(host, { launchId: pin.game_id }, pin.title);
}}
/>,
);
return;
}
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.
function errorCopy(res: LibraryResult): string {
switch (res.error) {
case "not-paired":
return "This Deck isn't paired with the host — pair first, then browse its library.";
case "pin-mismatch":
return "The host's identity changed — re-pair to re-establish trust.";
case "unreachable":
return "Couldn't reach the host's management API. Is the host online and up to date?";
case "timeout":
return "Timed out talking to the host — try again.";
case "flatpak-not-found":
return "The Punktfunk client isn't installed (flatpak io.unom.Punktfunk).";
case "client-outdated":
return "The installed client is too old for library browsing — update it from the About tab.";
default:
return res.detail || "Couldn't fetch the library.";
}
}
// ----------------------------------------------------------------------------------------
// The picker modal: "open on screen" + a pin-toggle list of the host's games.
// ----------------------------------------------------------------------------------------
export const GamePickerModal: FC<{
host: Host;
pins: PinsApi;
clientUpdatePending?: boolean;
closeModal?: () => void;
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
const [result, setResult] = useState<LibraryResult | null>(null);
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
useEffect(() => {
let stale = false;
setResult(null);
library(host.host, host.mgmt, host.fp)
.then((res) => {
if (!stale) setResult(res);
})
.catch((e) => {
if (!stale) setResult({ ok: false, error: "client-error", detail: String(e) });
});
return () => {
stale = true;
};
}, [host.host, host.mgmt, host.fp, attempt]);
const games = (result?.ok && result.games) || [];
const sorted = [...games].sort((a, b) => a.title.localeCompare(b.title));
return (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.4em" }}>
{host.name} Games
</div>
<Field
label="Open library on screen"
description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max"
>
<DialogButton
style={pickButton}
onClick={() => {
closeModal?.();
void startBrowse(host);
}}
>
<FaTv style={{ marginRight: "0.4em" }} />
Open
</DialogButton>
</Field>
{clientUpdatePending && (
<Field
focusable={false}
description="A client update is available — direct game launch and on-screen browsing need the latest client."
/>
)}
{result === null && (
<Field
focusable={false}
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.6em" }}>
<Spinner style={{ height: "1em" }} />
Fetching the library
</span>
}
description="This starts the client headlessly — a cold start can take a few seconds."
/>
)}
{result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
{result.error === "not-paired" && (
<DialogButton
style={pickButton}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}>
Retry
</DialogButton>
</Focusable>
</Field>
)}
{result?.ok && sorted.length === 0 && (
<Field
focusable={false}
label="No games found"
description="Install Steam titles or add custom entries in the host's web console."
/>
)}
{sorted.length > 0 && (
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
{sorted.map((g: GameEntry) => {
const pinned = pins.isPinned(host.fp, g.id);
const safe = isSafeLaunchId(g.id);
return (
<Field
key={g.id}
label={g.title}
description={
storeLabel(g.store) + (safe ? "" : " · unsupported id — can't be pinned")
}
childrenContainerWidth="max"
>
<DialogButton
style={pickButton}
disabled={!safe}
onClick={() =>
pinned ? pins.removePin(host.fp, g.id) : pins.addPin(host, g)
}
>
<FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"}
</DialogButton>
</Field>
);
})}
</div>
)}
</ModalRoot>
);
};
+80 -5
View File
@@ -21,18 +21,23 @@ import {
FaLockOpen,
FaPlay,
FaSyncAlt,
FaThLarge,
} from "react-icons/fa";
import { Host, UpdateInfo, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary";
import {
DOCS_URL,
PinsApi,
applyUpdate,
checkForUpdatesNow,
hasUpdate,
resolvePinHost,
startStream,
useHosts,
usePins,
useUpdate,
} from "./hooks";
import { GamePickerModal, storeLabel, streamPin } from "./library";
import { PairModal } from "./pair";
import { SettingsSection } from "./settings";
import { stopStream } from "./steam";
@@ -118,7 +123,11 @@ const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({
// ----------------------------------------------------------------------------------------
// One host row: status icon + address, details / pair / stream actions.
// ----------------------------------------------------------------------------------------
const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) => {
const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ({
host,
onPaired,
onGames,
}) => {
// The host's policy is `pair=required`, but if THIS device is already paired we don't need to
// pair again — show it as trusted and go straight to Stream.
const needsPair = host.pair === "required" && !host.paired;
@@ -142,6 +151,9 @@ const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) =
>
<FaInfoCircle />
</DialogButton>
<DialogButton style={iconButton} onClick={onGames}>
<FaThLarge />
</DialogButton>
{needsPair && (
<DialogButton
style={{ ...actionButton, minWidth: "5em" }}
@@ -163,7 +175,9 @@ const HostsTab: FC<{
hosts: Host[];
scanning: boolean;
refresh: () => void;
}> = ({ hosts, scanning, refresh }) => (
pins: PinsApi;
clientUpdatePending: boolean;
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
<div style={tabScroll}>
<Field
label="Discover"
@@ -193,8 +207,55 @@ const HostsTab: FC<{
/>
)}
{hosts.map((h) => (
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} onPaired={refresh} />
<HostRow
key={h.fp || `${h.host}:${h.port}`}
host={h}
onPaired={refresh}
onGames={() =>
showModal(
<GamePickerModal host={h} pins={pins} clientUpdatePending={clientUpdatePending} />,
)
}
/>
))}
{/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */}
{pins.pins.length > 0 && (
<>
<Field
focusable={false}
label="Pinned games"
description="One-tap streams — they also live in the quick-access menu"
bottomSeparator="standard"
/>
{pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts);
return (
<Field
key={`${pin.host_fp}:${pin.game_id}`}
label={pin.title}
description={`${storeLabel(pin.store)} · ${pin.host_name}${
online ? "" : " · offline?"
}${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}>
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Play
</DialogButton>
<DialogButton
style={{ ...actionButton, minWidth: "5em" }}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
>
Remove
</DialogButton>
</Focusable>
</Field>
);
})}
</>
)}
</div>
);
@@ -295,6 +356,7 @@ const AboutTab: FC<{
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const { info: update, checking, check } = useUpdate();
const pins = usePins();
const [tab, setTab] = useState("hosts");
return (
@@ -325,7 +387,12 @@ const PunktfunkPage: FC = () => {
</div>
</Focusable>
<div style={{ flex: 1, minHeight: 0 }}>
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the
right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that
still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole
Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always
live in a clipped flex box; match that. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
@@ -334,7 +401,15 @@ const PunktfunkPage: FC = () => {
{
id: "hosts",
title: "Hosts",
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
content: (
<HostsTab
hosts={hosts}
scanning={scanning}
refresh={refresh}
pins={pins}
clientUpdatePending={!!update?.client_update_available}
/>
),
},
{
id: "settings",
+48 -5
View File
@@ -184,19 +184,62 @@ function disableSteamInputForShortcut(appId: number): void {
}
}
/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */
export interface LaunchOpts {
/** Library id to launch on connect (a pinned game) — rides PF_LAUNCH → `--launch`. */
launchId?: string;
/** Open the gamepad library launcher instead of streaming (PF_BROWSE → `--browse`). */
browse?: boolean;
/** Management-API port for the launcher's library fetch (PF_MGMT; 0/absent = default). */
mgmt?: number;
}
// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=<id>`), so they
// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise.
// Real ids are `steam:<digits>` / `custom:<slug>`, so this rejects nothing in practice;
// it's VALIDATION, never encoding (the host must match the opaque token verbatim).
const UNSAFE_LAUNCH_ID = /["'\\$`\s]/;
export function isSafeLaunchId(id: string): boolean {
return (
id.length > 0 &&
id.length <= 128 &&
UNSAFE_LAUNCH_ID.exec(id) === null &&
/^[\x21-\x7e]+$/.test(id)
);
}
/**
* Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the
* shortcut's launch options (so one generic shortcut serves every host), then RunGame.
* Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
* library title, or into the gamepad library launcher). Encodes the target into the
* shortcut's launch options (so one generic shortcut serves every host and every pinned
* game), then RunGame.
*/
export async function launchStream(host: string, port: number): Promise<void> {
export async function launchStream(
host: string,
port: number,
opts: LaunchOpts = {},
): Promise<void> {
const { appId, runner } = await ensureShortcut();
// Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user
// disables Steam Input manually — see the Settings instruction).
disableSteamInputForShortcut(appId);
const target = port && port !== 9777 ? `${host}:${port}` : host;
const env = [`PF_HOST=${target}`];
if (opts.browse) {
env.push("PF_BROWSE=1");
if (opts.mgmt) {
env.push(`PF_MGMT=${Math.floor(opts.mgmt)}`);
}
} else if (opts.launchId) {
if (!isSafeLaunchId(opts.launchId)) {
// Enforced at pin time too (the picker disables Pin) — this is the backstop.
throw new Error(`unsupported launch id: ${opts.launchId}`);
}
env.push(`PF_LAUNCH=${opts.launchId}`);
}
// KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper
// script rides behind it as an argument and reads PF_HOST from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command% "${runner}"`);
// script rides behind it as an argument and reads PF_* from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
}