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 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. 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. 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. 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. a force-stop for a wedged stream client.
To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the 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/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/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/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/library.tsx` | The per-host game picker (pin/unpin, "Open library on screen") + the pinned-game launch helper. |
| `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/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`. | | `src/backend.ts` | Typed `callable` bridges to `main.py`. |
| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). | | `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` / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). | | `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. | | `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. |
## Limitations / next steps ## 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 # 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 # 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_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_APPID flatpak app id (default io.unom.Punktfunk)
# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) # 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 # 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. # WAYLAND_DISPLAY / XDG_RUNTIME_DIR are already correct for gamescope.
# #
@@ -33,9 +41,23 @@ if [ -z "${PF_HOST:-}" ]; then
exit 2 exit 2
fi 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 # 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). # Gaming Mode reclaims focus automatically (no manual refocus needed).
# --fullscreen: present the stream chrome-less and fullscreen (the client also auto-detects the # --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). # 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 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 * **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 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. 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 * **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. the frontend so it can create/point the Steam shortcut.
* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON * **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 * **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). 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 The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
advert in ``crates/punktfunk-host/src/discovery.rs``. the host advert in ``crates/punktfunk-host/src/discovery.rs``.
""" """
import asyncio import asyncio
@@ -77,6 +82,46 @@ def _runner_path() -> str:
return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh") 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 # 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 # 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/"): if props.get("proto") and not props["proto"].startswith("punktfunk/"):
continue continue
try:
mgmt = int(props.get("mgmt", ""))
except ValueError:
mgmt = 0 # not advertised (standalone punktfunk1-host) — callers default 47990
entry = { entry = {
"name": name, "name": name,
"host": address, "host": address,
@@ -346,6 +396,8 @@ def _parse_avahi_browse(stdout: str) -> list[dict]:
"pair": props.get("pair", "optional"), "pair": props.get("pair", "optional"),
"fp": props.get("fp", ""), "fp": props.get("fp", ""),
"proto": props.get("proto", ""), "proto": props.get("proto", ""),
"id": props.get("id", ""),
"mgmt": mgmt,
} }
key = props.get("id") or f"{address}:{port}" key = props.get("id") or f"{address}:{port}"
existing = out.get(key) existing = out.get(key)
@@ -437,6 +489,115 @@ class Plugin:
reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1] reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1]
return {"ok": False, "error": reason} 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: async def shortcut_art(self) -> dict:
"""The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by """The Steam-shortcut artwork shipped with the plugin (``assets/``, generated by
``scripts/gen-steam-art.py``): base64 PNGs for SetCustomArtworkForApp plus the ``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 fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert
proto: string; // advertised protocol, e.g. "punktfunk/1" proto: string; // advertised protocol, e.g. "punktfunk/1"
paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint) 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 { export interface PairResult {
@@ -68,6 +105,16 @@ export const pair = callable<
[host: string, port: number, pin: string, name: string], [host: string, port: number, pin: string, name: string],
PairResult PairResult
>("pair"); >("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 runnerInfo = callable<[], RunnerInfo>("runner_info");
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art"); export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
export const getSettings = callable<[], StreamSettings>("get_settings"); export const getSettings = callable<[], StreamSettings>("get_settings");
+156 -5
View File
@@ -2,8 +2,18 @@
import { toaster } from "@decky/api"; import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui"; import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { checkUpdate, discover, Host, updateClient, UpdateInfo } from "./backend"; import {
import { launchStream } from "./steam"; 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"; 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). // 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 { try {
await launchStream(h.host, h.port); await launchStream(h.host, h.port, opts);
Navigation.CloseSideMenus(); Navigation.CloseSideMenus();
toaster.toast({ title: "Punktfunk", body: `Starting stream${h.name}` }); toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"}${h.name}` });
} catch (e) { } catch (e) {
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${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"; } from "@decky/ui";
import { definePlugin, routerHook } from "@decky/api"; import { definePlugin, routerHook } from "@decky/api";
import { FC } from "react"; 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 { 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 { PunktfunkRoute, ROUTE } from "./page";
import { PairModal } from "./pair"; 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 QamPanel: FC = () => {
const { hosts, scanning, refresh } = useHosts(); const { hosts, scanning, refresh } = useHosts();
const { info: update, checking, check } = useUpdate(); const { info: update, checking, check } = useUpdate();
const pins = usePins();
return ( return (
<> <>
@@ -65,6 +77,31 @@ const QamPanel: FC = () => {
</PanelSectionRow> </PanelSectionRow>
</PanelSection> </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"> <PanelSection title="Hosts">
<PanelSectionRow> <PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}> <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, FaLockOpen,
FaPlay, FaPlay,
FaSyncAlt, FaSyncAlt,
FaThLarge,
} from "react-icons/fa"; } from "react-icons/fa";
import { Host, UpdateInfo, killStream } from "./backend"; import { Host, UpdateInfo, killStream } from "./backend";
import { PluginErrorBoundary } from "./boundary"; import { PluginErrorBoundary } from "./boundary";
import { import {
DOCS_URL, DOCS_URL,
PinsApi,
applyUpdate, applyUpdate,
checkForUpdatesNow, checkForUpdatesNow,
hasUpdate, hasUpdate,
resolvePinHost,
startStream, startStream,
useHosts, useHosts,
usePins,
useUpdate, useUpdate,
} from "./hooks"; } from "./hooks";
import { GamePickerModal, storeLabel, streamPin } from "./library";
import { PairModal } from "./pair"; import { PairModal } from "./pair";
import { SettingsSection } from "./settings"; import { SettingsSection } from "./settings";
import { stopStream } from "./steam"; 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. // 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 // 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. // pair again — show it as trusted and go straight to Stream.
const needsPair = host.pair === "required" && !host.paired; const needsPair = host.pair === "required" && !host.paired;
@@ -142,6 +151,9 @@ const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) =
> >
<FaInfoCircle /> <FaInfoCircle />
</DialogButton> </DialogButton>
<DialogButton style={iconButton} onClick={onGames}>
<FaThLarge />
</DialogButton>
{needsPair && ( {needsPair && (
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "5em" }} style={{ ...actionButton, minWidth: "5em" }}
@@ -163,7 +175,9 @@ const HostsTab: FC<{
hosts: Host[]; hosts: Host[];
scanning: boolean; scanning: boolean;
refresh: () => void; refresh: () => void;
}> = ({ hosts, scanning, refresh }) => ( pins: PinsApi;
clientUpdatePending: boolean;
}> = ({ hosts, scanning, refresh, pins, clientUpdatePending }) => (
<div style={tabScroll}> <div style={tabScroll}>
<Field <Field
label="Discover" label="Discover"
@@ -193,8 +207,55 @@ const HostsTab: FC<{
/> />
)} )}
{hosts.map((h) => ( {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> </div>
); );
@@ -295,6 +356,7 @@ const AboutTab: FC<{
const PunktfunkPage: FC = () => { const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts(); const { hosts, scanning, refresh } = useHosts();
const { info: update, checking, check } = useUpdate(); const { info: update, checking, check } = useUpdate();
const pins = usePins();
const [tab, setTab] = useState("hosts"); const [tab, setTab] = useState("hosts");
return ( return (
@@ -325,7 +387,12 @@ const PunktfunkPage: FC = () => {
</div> </div>
</Focusable> </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 <Tabs
activeTab={tab} activeTab={tab}
onShowTab={(id: string) => setTab(id)} onShowTab={(id: string) => setTab(id)}
@@ -334,7 +401,15 @@ const PunktfunkPage: FC = () => {
{ {
id: "hosts", id: "hosts",
title: "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", 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 * Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a
* shortcut's launch options (so one generic shortcut serves every host), then RunGame. * 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(); 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 // 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). // disables Steam Input manually — see the Settings instruction).
disableSteamInputForShortcut(appId); disableSteamInputForShortcut(appId);
const target = port && port !== 9777 ? `${host}:${port}` : host; 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 // 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. // script rides behind it as an argument and reads PF_* from the environment.
SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command% "${runner}"`); SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`);
SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100);
} }