From 84704194333dcdfc75266c80032671b4eccb862a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 3 Jul 2026 21:25:07 +0000 Subject: [PATCH] feat(decky): pinned-games library + self-update robustness; fix gamepad tab-nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- clients/decky/README.md | 20 ++- clients/decky/bin/punktfunkrun.sh | 26 ++- clients/decky/main.py | 165 ++++++++++++++++++- clients/decky/scripts/test-backend.py | 151 ++++++++++++++++++ clients/decky/src/backend.ts | 47 ++++++ clients/decky/src/hooks.ts | 161 ++++++++++++++++++- clients/decky/src/index.tsx | 43 ++++- clients/decky/src/library.tsx | 219 ++++++++++++++++++++++++++ clients/decky/src/page.tsx | 85 +++++++++- clients/decky/src/steam.ts | 53 ++++++- 10 files changed, 942 insertions(+), 28 deletions(-) create mode 100644 clients/decky/scripts/test-backend.py create mode 100644 clients/decky/src/library.tsx diff --git a/clients/decky/README.md b/clients/decky/README.md index 76454a9..ce3b37c 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -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 " 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=` (pinned game) / `PF_BROWSE=1` + `PF_MGMT=` (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 diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index 3acab0a..1f2ef2b 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -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 diff --git a/clients/decky/main.py b/clients/decky/main.py index 205f7ea..eea131e 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -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: `` 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: , 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 diff --git a/clients/decky/scripts/test-backend.py b/clients/decky/scripts/test-backend.py new file mode 100644 index 0000000..f1f20cc --- /dev/null +++ b/clients/decky/scripts/test-backend.py @@ -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") diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 88c71c9..029f20b 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -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: / custom:) 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"); diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 04f5eb2..60a8e3b 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -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 { +export async function startStream( + h: Host, + opts: LaunchOpts = {}, + label?: string, +): Promise { 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 { + 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; +} + +export function usePins(): PinsApi { + const [pins, setPins] = useState([]); + + 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, + }; +} diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index f9cafdd..11d44b3 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -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 = () => { + {/* 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 && ( + + {pins.pins.map((pin) => { + const { online } = resolvePinHost(pin, hosts); + return ( + + streamPin(pin, hosts, pins)} + label={pin.title} + description={`${pin.host_name}${online ? "" : " · offline?"}${ + pin.paired ? "" : " · pairing required" + }`} + > + + Stream + + + ); + })} + + )} + diff --git a/clients/decky/src/library.tsx b/clients/decky/src/library.tsx new file mode 100644 index 0000000..52e089a --- /dev/null +++ b/clients/decky/src/library.tsx @@ -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( + { + 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(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 ( + +
+ {host.name} — Games +
+ + + { + closeModal?.(); + void startBrowse(host); + }} + > + + Open + + + + {clientUpdatePending && ( + + )} + + {result === null && ( + + + Fetching the library… + + } + description="This starts the client headlessly — a cold start can take a few seconds." + /> + )} + + {result !== null && !result.ok && ( + + + {result.error === "not-paired" && ( + + showModal( setAttempt((n) => n + 1)} />) + } + > + Pair + + )} + setAttempt((n) => n + 1)}> + Retry + + + + )} + + {result?.ok && sorted.length === 0 && ( + + )} + + {sorted.length > 0 && ( +
+ {sorted.map((g: GameEntry) => { + const pinned = pins.isPinned(host.fp, g.id); + const safe = isSafeLaunchId(g.id); + return ( + + + pinned ? pins.removePin(host.fp, g.id) : pins.addPin(host, g) + } + > + + {pinned ? "Unpin" : "Pin"} + + + ); + })} +
+ )} +
+ ); +}; diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx index 8af3c99..b0777c5 100644 --- a/clients/decky/src/page.tsx +++ b/clients/decky/src/page.tsx @@ -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 }) = > + + + {needsPair && ( void; -}> = ({ hosts, scanning, refresh }) => ( + pins: PinsApi; + clientUpdatePending: boolean; +}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
)} {hosts.map((h) => ( - + + showModal( + , + ) + } + /> ))} + + {/* Pinned games — also the cleanup surface for pins whose host is gone from the scan. */} + {pins.pins.length > 0 && ( + <> + + {pins.pins.map((pin) => { + const { online } = resolvePinHost(pin, hosts); + return ( + + + streamPin(pin, hosts, pins)}> + + Play + + pins.removePin(pin.host_fp, pin.game_id)} + > + Remove + + + + ); + })} + + )}
); @@ -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 = () => { -
+ {/* 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. */} +
setTab(id)} @@ -334,7 +401,15 @@ const PunktfunkPage: FC = () => { { id: "hosts", title: "Hosts", - content: , + content: ( + + ), }, { id: "settings", diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 0212333..0acaf4e 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -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=`), so they +// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise. +// Real ids are `steam:` / `custom:`, 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 { +export async function launchStream( + host: string, + port: number, + opts: LaunchOpts = {}, +): Promise { 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); }