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:
+14
-6
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user