From 15d3d423fa972152209bfda91452d0e844e22691 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 17 Jun 2026 09:17:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(decky):=20full-featured=20Gaming-Mode=20cl?= =?UTF-8?q?ient=20=E2=80=94=20fullscreen=20page,=20pairing,=20focus-correc?= =?UTF-8?q?t=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin was a QAM launcher whose stream never appeared, with no pairing. Three fixes, plus a headless --pair mode on the GTK client: - Stream actually starts (MoonDeck's proven mechanism): gamescope only focuses the process tree Steam launched via reaper, so a flatpak spawned from the (root) backend is invisible. The frontend now registers ONE hidden non-Steam shortcut pointing at bin/punktfunkrun.sh, passes the host as the shortcut's Steam launch options, and starts it with SteamClient.Apps.RunGame — gamescope then fullscreen-focuses it. The wrapper execs `flatpak run io.unom.Punktfunk --connect `. - Fullscreen page: routerHook.addRoute("/punktfunk") — host list, per-host Pair/Stream, and a settings section (resolution/refresh/ bitrate/gamepad/mic, written to client-gtk-settings.json). - Pairing: a gamepad-navigable PIN keypad. The host shows the PIN; the backend runs the SPAKE2 ceremony headlessly via the client's new `--pair --connect host` CLI mode (app.rs), persisting the host as paired so the stream then connects silently. Same flatpak => shared identity store, verified live (ceremony against a real host). - Backend (main.py): discover / pair / runner_info / get_settings / set_settings / kill_stream; uses DECKY_USER_HOME so paths resolve to the deck user's flatpak install regardless of the plugin's root flag. CI (decky.yml) and the sideload packager now ship bin/punktfunkrun.sh. The Steam-shortcut launch and headless-pairing env follow MoonDeck exactly but need a Deck in Gaming Mode to fully confirm. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/decky.yml | 6 +- clients/decky/README.md | 56 ++- clients/decky/bin/punktfunkrun.sh | 34 ++ clients/decky/main.py | 376 ++++++++++--------- clients/decky/scripts/package.sh | 5 +- clients/decky/src/backend.ts | 45 +++ clients/decky/src/index.tsx | 454 +++++++++++++++++------ clients/decky/src/steam.ts | 114 ++++++ crates/punktfunk-client-linux/src/app.rs | 65 ++++ 9 files changed, 840 insertions(+), 315 deletions(-) create mode 100755 clients/decky/bin/punktfunkrun.sh create mode 100644 clients/decky/src/backend.ts create mode 100644 clients/decky/src/steam.ts diff --git a/.gitea/workflows/decky.yml b/.gitea/workflows/decky.yml index 842a5b2..0dc60f4 100644 --- a/.gitea/workflows/decky.yml +++ b/.gitea/workflows/decky.yml @@ -76,12 +76,16 @@ jobs: apt-get update && apt-get install -y --no-install-recommends zip >/dev/null STAGE="$RUNNER_TEMP/decky" DEST="$STAGE/$PLUGIN" - rm -rf "$STAGE"; mkdir -p "$DEST/dist" + rm -rf "$STAGE"; mkdir -p "$DEST/dist" "$DEST/bin" cp clients/decky/plugin.json "$DEST/" cp clients/decky/package.json "$DEST/" cp clients/decky/main.py "$DEST/" cp clients/decky/dist/index.js "$DEST/dist/" cp clients/decky/README.md "$DEST/" + # The stream-launch wrapper (target of the Steam shortcut); keep it executable + # (runner_info() also re-chmods at runtime in case the zip/extract drops the bit). + cp clients/decky/bin/punktfunkrun.sh "$DEST/bin/" + chmod 0755 "$DEST/bin/punktfunkrun.sh" # Store requires a LICENSE in the plugin root; the project is MIT OR Apache-2.0. cp LICENSE-MIT "$DEST/LICENSE" ( cd "$STAGE" && zip -r "$RUNNER_TEMP/punktfunk.zip" "$PLUGIN" ) diff --git a/clients/decky/README.md b/clients/decky/README.md index 78d6474..6a9b9e8 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -8,28 +8,44 @@ Because Decky plugins run inside Steam's CEF, the panel is built from real Steam primitives (`@decky/ui`: `PanelSection`, `PanelSectionRow`, `ButtonItem`, `Field`, `Spinner`) — so it looks and feels native to Gaming Mode. -> **Spike / launcher only.** This is a minimal but functional first cut: discover hosts, -> connect, disconnect. It launches the existing native GTK4 client -> (`punktfunk-client`) over the top of Gaming Mode. An in-stream overlay (latency / bitrate -> HUD, mid-session controls) and a fuller real-Steam-components UI are the next steps. -> Runtime behavior on a real Deck is **untested** — only the build is verified here. +> **Full Gaming-Mode client.** Discovery, a fullscreen page, in-UI SPAKE2 PIN pairing, +> stream settings, and a stream that actually launches fullscreen under gamescope (via a +> Steam shortcut, MoonDeck-style). The video itself is the existing GTK4 flatpak client +> (`io.unom.Punktfunk`) — the plugin discovers, pairs, configures, and *launches it the +> right way* so gamescope focuses it. The Steam-shortcut launch + pairing need a real Deck +> in Gaming Mode to fully confirm. ## What it does -1. **Refresh** — browses the LAN over mDNS for punktfunk/1 hosts (the `_punktfunk._udp` - service) via the backend `discover()`. -2. **Lists discovered hosts** — name, `ip:port`, and a lock icon for whether pairing is - required (`pair=required` in the host's TXT record). -3. **Connect** — selecting a host calls `connect(host, port)`, which launches - `punktfunk-client --connect host:port`; a toast and the status line reflect the result. -4. **Disconnect** — `disconnect()` terminates the launched client. +1. **Discover** — browses the LAN over mDNS for punktfunk/1 hosts (`_punktfunk._udp`, + backend `discover()` via `avahi-browse`). Shown in both the QAM panel and a **fullscreen + page** (Decky route `/punktfunk`, via `routerHook.addRoute`). +2. **Pair** — for a `pair=required` host: a gamepad-navigable PIN keypad. The operator arms + pairing on the host (it shows a 4-digit PIN), the user enters it on the Deck, and the + backend runs the SPAKE2 ceremony headlessly via the flatpak client's `--pair` mode + (`pair()`), persisting the host as paired so the stream then connects silently. +3. **Stream** — launches fullscreen in Gaming Mode. The plugin registers ONE hidden + non-Steam shortcut pointing at `bin/punktfunkrun.sh`, passes `PF_HOST` as the shortcut's + Steam launch options, and starts it with `SteamClient.Apps.RunGame` — so gamescope + focuses + fullscreens it. (A flatpak launched directly from the backend is invisible: + gamescope only focuses the process tree Steam launched via `reaper` — gamescope#484.) + The wrapper then execs `flatpak run io.unom.Punktfunk --connect `. +4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's + `client-gtk-settings.json` (`get_settings`/`set_settings`), which the launched client reads. + +To leave the stream: the in-client controller chord (**L1+R1+Start+Select**) or close the +"game" from the Steam overlay — exiting the client ends the Steam game and returns to +Gaming Mode automatically. ## Architecture | File | Role | | --- | --- | -| `src/index.tsx` | Frontend QAM panel (`@decky/ui` + `@decky/api`). | -| `main.py` | Backend `Plugin` class: `discover` / `connect` / `disconnect` / `status` exposed over the Decky bridge. | +| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad modal, settings). | +| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. | +| `src/backend.ts` | Typed `callable` bridges to `main.py`. | +| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). | +| `main.py` | Backend: `discover` / `pair` / `runner_info` / `get_settings` / `set_settings` / `kill_stream`. | | `plugin.json` | Decky plugin manifest. | | `decky.pyi` | Type stub for the injected `decky` module (vendored from the template). | @@ -126,7 +142,13 @@ shows up in the Quick Access Menu. ## Limitations / next steps -- Launcher only — no in-stream overlay yet; the client owns the full session once launched. +- **Needs on-Deck validation in Gaming Mode**: the Steam-shortcut launch (`AddShortcut` / + `RunGame` / the `gameId` encoding) and the headless pairing env are coded to MoonDeck's + proven pattern but verified only at build time here. - mDNS discovery depends on `avahi-browse`; no manual "add host by IP" entry yet. -- Pairing (PIN ceremony) is handled by the launched client, not the panel. -- Not yet tested on real Deck hardware. +- No in-stream overlay (latency/bitrate HUD) inside the plugin — the client owns the session + once launched; leave it with the L1+R1+Start+Select chord. +- Pairing requires the operator to **arm pairing on the host** (so it shows the PIN); the + plugin can't arm it remotely (no host mgmt token on the Deck). +- Settings are written to the flatpak's sandbox config path; if the client ever moves its + config location, that path mapping must follow. diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh new file mode 100755 index 0000000..08dff2f --- /dev/null +++ b/clients/decky/bin/punktfunkrun.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates. +# +# WHY A WRAPPER SCRIPT (load-bearing, from MoonDeck's hard-won knowledge): the stream client +# must be a descendant of the process Steam launches via `reaper`, or gamescope never gives +# its window focus/fullscreen in Gaming Mode (gamescope detects the "current app" by AppID, +# which only attaches to reaper's descendants — see gamescope#484). So the Decky plugin +# launches THIS script through SteamClient.Apps.RunGame; the script then execs the flatpak +# client, which inherits the shortcut's AppID and is focused. Launching the flatpak directly +# from the (root) Decky backend produces an unfocused, invisible window. +# +# Per-session parameters arrive as environment variables, set as the shortcut's Steam launch +# options by the plugin (SteamClient.Apps.SetAppLaunchOptions), so ONE generic shortcut serves +# every host: +# PF_HOST host[:port] to connect to (required) +# PF_APPID flatpak app id (default io.unom.Punktfunk) +# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) +# +# 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. +set -u + +APPID="${PF_APPID:-io.unom.Punktfunk}" +FLATPAK="${PF_FLATPAK:-flatpak}" + +if [ -z "${PF_HOST:-}" ]; then + echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2 + exit 2 +fi + +echo "punktfunkrun: streaming $APPID --connect $PF_HOST" >&2 +# exec so the flatpak client IS the game process — when it exits, Steam ends the "game" and +# Gaming Mode reclaims focus automatically (no manual refocus needed). +exec "$FLATPAK" run --arch=x86_64 "$APPID" --connect "$PF_HOST" diff --git a/clients/decky/main.py b/clients/decky/main.py index 56001c8..5c19c45 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -1,160 +1,84 @@ """ punktfunk Decky plugin — backend. -Bridges the Gaming-Mode Quick Access panel (``src/index.tsx``) to two host-side -operations: +The Gaming-Mode UI (``src/index.tsx``) calls these methods over the Decky bridge. The actual +STREAM is NOT launched here — it is launched by the frontend through Steam +(SteamClient.Apps.RunGame on a hidden non-Steam shortcut that points at ``bin/punktfunkrun.sh``), +because gamescope only focuses/fullscreens windows in the process tree Steam launched via +``reaper``. A flatpak spawned from this backend would be invisible/unfocused (gamescope#484). +The backend's jobs are the things Steam can't do: -* **discover()** — browse the LAN over mDNS for punktfunk/1 hosts advertising the - ``_punktfunk._udp`` service, returning name / ip:port / pairing-requirement / cert - fingerprint for each. Implemented by shelling out to ``avahi-browse`` (SteamOS, Bazzite - and most Linux distros ship ``avahi-daemon``); see :func:`Plugin.discover`. -* **connect(host, port)** / **disconnect()** — launch / kill the native GTK4 client - (``punktfunk-client --connect host:port``). The child PID is tracked so a later - :func:`Plugin.disconnect` (or plugin unload) can terminate it. +* **discover()** — browse the LAN over mDNS (``avahi-browse``) for ``_punktfunk._udp`` hosts. +* **pair(host, port, pin, name)** — run the SPAKE2 PIN ceremony headlessly via the flatpak + client's ``--pair`` mode, capturing the result. Pairing uses the SAME flatpak (so the same + identity store the stream uses), so once paired the stream connects silently. +* **runner_info()** — the absolute path to the launch wrapper + the flatpak app id, handed to + the frontend so it can create/point the Steam shortcut. +* **get_settings() / set_settings()** — read/write the flatpak client's stream settings JSON + (resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads. +* **kill_stream()** — force-stop a wedged stream (``flatpak kill``). -The TXT-record keys parsed here (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the -host advert in ``crates/punktfunk-host/src/discovery.rs``. +The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id``) are defined by the host +advert in ``crates/punktfunk-host/src/discovery.rs``. """ import asyncio +import json +import os import shutil +import stat from pathlib import Path import decky -# The native punktfunk/1 client binary (the GTK4/libadwaita Linux client, crate -# ``punktfunk-client-linux``). It is resolved at runtime from PATH and a handful of common -# install locations (see :func:`_resolve_client`). If none exist we fall back to this bare -# name and let the spawn fail loudly — install the client on the Deck (.deb / RPM / flatpak) -# or symlink it into ~/.local/bin. -# -# On SteamOS (read-only /usr, image-based) the settled install path is the flatpak -# ``io.unom.Punktfunk`` (packaging/flatpak/), launched via ``flatpak run`` — see the flatpak -# fallback in :func:`_resolve_client`. -CLIENT_BINARY = "punktfunk-client" +# Flatpak application id of the GTK client (packaging/flatpak/io.unom.Punktfunk.yml). +APP_ID = "io.unom.Punktfunk" # Service type advertised by punktfunk/1 hosts (matches NATIVE_SERVICE in the Rust host). SERVICE_TYPE = "_punktfunk._udp" -# Candidate locations probed (in order) when the binary is not on PATH. ``$HOME`` is the -# effective user's home as provided by decky. -_CLIENT_CANDIDATES = [ - "/usr/bin/punktfunk-client", - "/usr/local/bin/punktfunk-client", - str(Path(decky.HOME) / ".local" / "bin" / "punktfunk-client"), - # Flatpak: launched via `flatpak run` rather than a path — handled in _resolve_client. -] +# The flatpak client persists identity / known-hosts / settings under HOME/.config/punktfunk; +# inside the flatpak sandbox HOME is ~/.var/app/, so the real on-disk location is this. +# The backend writes settings here so the (sandboxed) client reads them. +def _client_config_dir() -> Path: + return Path(decky.DECKY_USER_HOME) / ".var" / "app" / APP_ID / ".config" / "punktfunk" -def _resolve_client() -> list[str]: - """Return the argv prefix used to launch the native client. +def _settings_path() -> Path: + return _client_config_dir() / "client-gtk-settings.json" - Resolution order: PATH → well-known absolute paths → flatpak (if the app id is - installed) → bare binary name (so the eventual spawn fails with a clear error). - """ - on_path = shutil.which(CLIENT_BINARY) - if on_path: - return [on_path] - for candidate in _CLIENT_CANDIDATES: - if Path(candidate).exists(): - return [candidate] +def _runner_path() -> str: + """Absolute path to the launch wrapper shipped with the plugin (bin/punktfunkrun.sh).""" + return str(Path(decky.DECKY_PLUGIN_DIR) / "bin" / "punktfunkrun.sh") - # Flatpak fallback — the canonical install path on the Steam Deck (SteamOS /usr is - # read-only; the flatpak bundles the libadwaita + SDL3 the system lacks). The app id is - # the one the flatpak manifest publishes (packaging/flatpak/io.unom.Punktfunk.yml). If it - # is not installed, `flatpak run ` fails and surfaces as a spawn error the user can - # act on (install the bundle: `flatpak install --user punktfunk-client-*.flatpak`). - flatpak = shutil.which("flatpak") - if flatpak: - return [flatpak, "run", "io.unom.Punktfunk"] - decky.logger.warning( - "punktfunk-client not found on PATH or in %s; falling back to bare name", - _CLIENT_CANDIDATES, +def _flatpak() -> str | None: + return shutil.which("flatpak") or ( + "/usr/bin/flatpak" if Path("/usr/bin/flatpak").exists() else None ) - return [CLIENT_BINARY] -def _parse_avahi_browse(stdout: str) -> list[dict]: - """Parse ``avahi-browse -rpt`` output into a list of host dicts. - - ``avahi-browse -r`` resolves services; ``-p`` makes the output parseable (one record - per line, semicolon-separated, fields escaped with ``\\``); ``-t`` terminates after the - initial cache dump instead of running forever. - - Resolved records start with ``=`` and have the columns:: - - =;iface;protocol;name;type;domain;hostname;address;port;txt - - where ``txt`` is a space-separated list of ``"key=value"`` tokens, each already wrapped - in double quotes by avahi, e.g. ``"proto=punktfunk/1" "fp=ab12..." "pair=required"``. - - We dedup on the host advert ``id`` TXT key (a host re-advertises across interfaces / - IPv4+IPv6, producing several ``=`` lines for one logical host); when ``id`` is absent we - fall back to ``host:port``. - """ - out: dict[str, dict] = {} - for raw in stdout.splitlines(): - line = raw.strip() - if not line.startswith("="): - continue - # Split on unescaped ';'. avahi escapes literal ';' inside fields as '\;', so a - # simple replace-guard split is adequate for the fixed 10-column layout. - parts = line.replace("\\;", "\x00").split(";") - parts = [p.replace("\x00", ";") for p in parts] - if len(parts) < 9: - continue - - name = parts[3] - # parts[4] is the service type, parts[5] the domain. - address = parts[7] - port_str = parts[8] - txt = parts[9] if len(parts) > 9 else "" - - try: - port = int(port_str) - except ValueError: - port = 0 - - # Parse TXT tokens: each is a quoted "key=value". - props: dict[str, str] = {} - for token in _split_txt(txt): - if "=" in token: - k, v = token.split("=", 1) - props[k] = v - - # Only surface actual punktfunk/1 adverts. - if props.get("proto") and not props["proto"].startswith("punktfunk/"): - continue - - entry = { - "name": name, - "host": address, - "port": port, - "pair": props.get("pair", "optional"), - "fp": props.get("fp", ""), - "proto": props.get("proto", ""), - } - key = props.get("id") or f"{address}:{port}" - # Prefer an IPv4 record over IPv6 for the user-facing host string when both exist. - existing = out.get(key) - if existing is None or (":" in existing["host"] and ":" not in address): - out[key] = entry - - return list(out.values()) +def _flatpak_env() -> dict: + """Environment for a headless ``flatpak run`` from the backend (no display needed for + pairing). Reconstruct the user-session bits flatpak wants; the backend may not inherit + them. Harmless if some are already set.""" + env = dict(os.environ) + env.setdefault("HOME", decky.DECKY_USER_HOME) + uid = os.environ.get("PF_UID") or "1000" + env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}") + env.setdefault( + "DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus" + ) + # Ensure flatpak can find the user installation. + env.setdefault( + "PATH", "/usr/bin:/bin:" + env.get("PATH", "") + ) + return env def _split_txt(txt: str) -> list[str]: - """Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting. - - avahi prints each TXT item wrapped in double quotes and space-separated, e.g.:: - - "proto=punktfunk/1" "fp=ab12cd" "pair=required" "id=host-1" - - A value can legitimately contain spaces, so we split on the quote boundaries rather - than on whitespace. - """ + """Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting.""" tokens: list[str] = [] cur: list[str] = [] in_quote = False @@ -171,23 +95,64 @@ def _split_txt(txt: str) -> list[str]: return tokens -class Plugin: - # Tracks the launched native client so disconnect()/_unload can terminate it. - _client: asyncio.subprocess.Process | None = None - _connected_host: str | None = None +def _parse_avahi_browse(stdout: str) -> list[dict]: + """Parse ``avahi-browse -rpt`` output into a list of host dicts (deduped on the TXT ``id``).""" + out: dict[str, dict] = {} + for raw in stdout.splitlines(): + line = raw.strip() + if not line.startswith("="): + continue + parts = line.replace("\\;", "\x00").split(";") + parts = [p.replace("\x00", ";") for p in parts] + if len(parts) < 9: + continue + name = parts[3] + address = parts[7] + port_str = parts[8] + txt = parts[9] if len(parts) > 9 else "" + + try: + port = int(port_str) + except ValueError: + port = 0 + + props: dict[str, str] = {} + for token in _split_txt(txt): + if "=" in token: + k, v = token.split("=", 1) + props[k] = v + + if props.get("proto") and not props["proto"].startswith("punktfunk/"): + continue + + entry = { + "name": name, + "host": address, + "port": port, + "pair": props.get("pair", "optional"), + "fp": props.get("fp", ""), + "proto": props.get("proto", ""), + } + key = props.get("id") or f"{address}:{port}" + existing = out.get(key) + # Prefer IPv4 over IPv6 for the user-facing host string. + if existing is None or (":" in existing["host"] and ":" not in address): + out[key] = entry + + return list(out.values()) + + +class Plugin: async def discover(self) -> list[dict]: """Browse the LAN for punktfunk/1 hosts. Returns ``[{name, host, port, pair, fp}]``.""" avahi = shutil.which("avahi-browse") if not avahi: decky.logger.error("avahi-browse not found; install avahi for host discovery") return [] - try: proc = await asyncio.create_subprocess_exec( - avahi, - "-rpt", - SERVICE_TYPE, + avahi, "-rpt", SERVICE_TYPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) @@ -197,78 +162,119 @@ class Plugin: proc.kill() decky.logger.warning("avahi-browse timed out") return [] - except Exception: # noqa: BLE001 - surface any spawn failure as "no hosts" + except Exception: # noqa: BLE001 decky.logger.exception("avahi-browse failed") return [] - if stderr: decky.logger.debug("avahi-browse stderr: %s", stderr.decode(errors="replace")) - hosts = _parse_avahi_browse(stdout.decode(errors="replace")) decky.logger.info("discovered %d punktfunk host(s)", len(hosts)) return hosts - async def connect(self, host: str, port: int) -> dict: - """Launch the native client against ``host:port``. Returns ``{ok, host, error?}``.""" - # Tear down any prior session first. - await self.disconnect() + async def pair(self, host: str, port: int, pin: str, name: str = "Steam Deck") -> dict: + """Run the SPAKE2 PIN ceremony headlessly via the flatpak client's ``--pair`` mode. - argv = _resolve_client() + ["--connect", f"{host}:{port}"] - decky.logger.info("launching client: %s", " ".join(argv)) + The user arms pairing on the HOST (which displays a 4-digit PIN) and enters it here. + On success the flatpak persists the host to its known-hosts as paired, so a later + stream connects silently. Returns ``{ok, fp?, error?}``. + """ + flatpak = _flatpak() + if not flatpak: + return {"ok": False, "error": "flatpak-not-found"} + argv = [ + flatpak, "run", "--arch=x86_64", APP_ID, + "--pair", str(pin).strip(), + "--connect", f"{host}:{port}", + "--name", name, + "--host-label", host, + ] + decky.logger.info("pairing: %s", " ".join(argv[:6] + ["", "--connect", f"{host}:{port}"])) try: - self._client = await asyncio.create_subprocess_exec( + proc = await asyncio.create_subprocess_exec( *argv, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_flatpak_env(), ) - except FileNotFoundError: - decky.logger.error("client binary not found: %s", argv[0]) - return {"ok": False, "host": f"{host}:{port}", "error": "client-not-found"} + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=100.0) + except asyncio.TimeoutError: + return {"ok": False, "error": "pairing timed out"} except Exception as exc: # noqa: BLE001 - decky.logger.exception("failed to launch client") - return {"ok": False, "host": f"{host}:{port}", "error": str(exc)} + decky.logger.exception("pairing failed to launch") + return {"ok": False, "error": str(exc)} - self._connected_host = f"{host}:{port}" - decky.logger.info("client launched (pid %s) -> %s", self._client.pid, self._connected_host) - return {"ok": True, "host": self._connected_host} + out = stdout.decode(errors="replace") + err = stderr.decode(errors="replace") + if proc.returncode == 0 and "paired " in out: + fp = "" + for tok in out.split(): + if tok.startswith("fp="): + fp = tok[3:] + decky.logger.info("paired %s:%s", host, port) + return {"ok": True, "fp": fp} + decky.logger.warning("pairing failed (rc=%s): %s", proc.returncode, err.strip() or out.strip()) + # Surface the client's own one-line reason (wrong PIN / not armed) to the UI. + reason = (err.strip().splitlines() or out.strip().splitlines() or ["pairing failed"])[-1] + return {"ok": False, "error": reason} - async def disconnect(self) -> dict: - """Terminate the launched native client, if any.""" - proc = self._client - self._client = None - host = self._connected_host - self._connected_host = None - if proc is None or proc.returncode is not None: - return {"ok": True, "host": None} - - decky.logger.info("disconnecting client (pid %s)", proc.pid) + async def runner_info(self) -> dict: + """The wrapper-script path + flatpak app id the frontend needs to create the Steam + shortcut. Also (re)asserts the script's exec bit — packaging can drop it.""" + path = _runner_path() try: - proc.terminate() - try: - await asyncio.wait_for(proc.wait(), timeout=5.0) - except asyncio.TimeoutError: - decky.logger.warning("client did not exit; killing (pid %s)", proc.pid) - proc.kill() - await proc.wait() - except ProcessLookupError: - pass - except Exception: # noqa: BLE001 - decky.logger.exception("error terminating client") - return {"ok": True, "host": host} + st = os.stat(path) + os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + except OSError: + decky.logger.warning("could not chmod runner %s", path) + return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()} - async def status(self) -> dict: - """Return the current connection status for UI refresh on panel open.""" - connected = self._client is not None and self._client.returncode is None - return {"connected": connected, "host": self._connected_host if connected else None} + async def get_settings(self) -> dict: + """Read the flatpak client's stream settings (resolution/bitrate/gamepad…).""" + try: + return json.loads(_settings_path().read_text()) + except (OSError, json.JSONDecodeError): + # The client's own defaults (native display, host-default bitrate, auto pad). + return { + "width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0, + "gamepad": "auto", "compositor": "auto", + "inhibit_shortcuts": True, "mic_enabled": False, + } + + async def set_settings(self, settings: dict) -> dict: + """Write the stream settings JSON the (sandboxed) client reads on launch.""" + try: + d = _client_config_dir() + d.mkdir(parents=True, exist_ok=True) + _settings_path().write_text(json.dumps(settings, indent=2)) + return {"ok": True} + except OSError as exc: + decky.logger.exception("could not write settings") + return {"ok": False, "error": str(exc)} + + async def kill_stream(self) -> dict: + """Force-stop a wedged stream client (``flatpak kill``).""" + flatpak = _flatpak() + if not flatpak: + return {"ok": False, "error": "flatpak-not-found"} + try: + proc = await asyncio.create_subprocess_exec( + flatpak, "kill", APP_ID, + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, + env=_flatpak_env(), + ) + await asyncio.wait_for(proc.wait(), timeout=10.0) + except Exception: # noqa: BLE001 + decky.logger.exception("flatpak kill failed") + return {"ok": False} + return {"ok": True} # ---- Decky lifecycle ---- async def _main(self): - decky.logger.info("punktfunk plugin loaded") + decky.logger.info("punktfunk plugin loaded (runner=%s)", _runner_path()) async def _unload(self): - decky.logger.info("punktfunk plugin unloading; tearing down client") - await self.disconnect() + decky.logger.info("punktfunk plugin unloading") async def _uninstall(self): decky.logger.info("punktfunk plugin uninstalled") diff --git a/clients/decky/scripts/package.sh b/clients/decky/scripts/package.sh index 7c258e7..c5d0d83 100755 --- a/clients/decky/scripts/package.sh +++ b/clients/decky/scripts/package.sh @@ -20,9 +20,12 @@ VER="$(python3 -c 'import json;print(json.load(open("package.json"))["version"]) STAGE="$(mktemp -d)" DEST="$STAGE/$NAME" -mkdir -p "$DEST/dist" +mkdir -p "$DEST/dist" "$DEST/bin" cp dist/index.js "$DEST/dist/index.js" # ship the bundle only, not the sourcemap cp main.py plugin.json package.json LICENSE "$DEST/" +# The stream-launch wrapper (target of the Steam shortcut) — must stay executable. +cp bin/punktfunkrun.sh "$DEST/bin/punktfunkrun.sh" +chmod 0755 "$DEST/bin/punktfunkrun.sh" [ -f decky.pyi ] && cp decky.pyi "$DEST/" [ -f README.md ] && cp README.md "$DEST/" diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts new file mode 100644 index 0000000..2013fc5 --- /dev/null +++ b/clients/decky/src/backend.ts @@ -0,0 +1,45 @@ +// Bridge to the Python backend (main.py) + shared types. +import { callable } from "@decky/api"; + +export interface Host { + name: string; + host: string; + port: number; + pair: string; // "required" | "optional" + fp: string; +} + +export interface PairResult { + ok: boolean; + fp?: string; + error?: string; +} + +export interface RunnerInfo { + runner: string; // absolute path to bin/punktfunkrun.sh + app_id: string; // flatpak app id + exists: boolean; +} + +export interface StreamSettings { + width: number; // 0 = native + height: number; // 0 = native + refresh_hz: number; // 0 = native + bitrate_kbps: number; // 0 = host default + gamepad: string; // "auto" | "xbox360" | "dualsense" + compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" + inhibit_shortcuts: boolean; + mic_enabled: boolean; +} + +export const discover = callable<[], Host[]>("discover"); +export const pair = callable< + [host: string, port: number, pin: string, name: string], + PairResult +>("pair"); +export const runnerInfo = callable<[], RunnerInfo>("runner_info"); +export const getSettings = callable<[], StreamSettings>("get_settings"); +export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( + "set_settings", +); +export const killStream = callable<[], { ok: boolean }>("kill_stream"); diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index f4682a7..6db0160 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -1,131 +1,364 @@ import { ButtonItem, + Dropdown, Field, + Focusable, + DialogButton, + ModalRoot, + Navigation, PanelSection, PanelSectionRow, + SliderField, Spinner, + ToggleField, + showModal, + staticClasses, } from "@decky/ui"; +import { definePlugin, routerHook, toaster } from "@decky/api"; +import { FC, useCallback, useEffect, useState } from "react"; import { - callable, - definePlugin, - toaster, -} from "@decky/api"; -import { useEffect, useState } from "react"; -import { FaTv, FaSyncAlt, FaStop, FaLock, FaLockOpen } from "react-icons/fa"; + FaTv, + FaSyncAlt, + FaLock, + FaLockOpen, + FaPlay, + FaArrowLeft, +} from "react-icons/fa"; +import { + discover, + getSettings, + pair, + setSettings, + Host, + StreamSettings, +} from "./backend"; +import { launchStream } from "./steam"; -// ---- Backend bridge (see main.py) ---- +const ROUTE = "/punktfunk"; -interface Host { - name: string; - host: string; - port: number; - pair: string; // "required" | "optional" - fp: string; -} - -interface ConnectResult { - ok: boolean; - host: string | null; - error?: string; -} - -interface Status { - connected: boolean; - host: string | null; -} - -const discover = callable<[], Host[]>("discover"); -const connect = callable<[host: string, port: number], ConnectResult>("connect"); -const disconnect = callable<[], { ok: boolean; host: string | null }>("disconnect"); -const getStatus = callable<[], Status>("status"); - -function Content() { +// ---------------------------------------------------------------------------------------- +// Discovery hook — shared by the QAM panel and the full page. +// ---------------------------------------------------------------------------------------- +function useHosts() { const [hosts, setHosts] = useState([]); const [scanning, setScanning] = useState(false); - const [busyHost, setBusyHost] = useState(null); - const [connectedHost, setConnectedHost] = useState(null); - const refresh = async () => { + const refresh = useCallback(async () => { setScanning(true); try { - const found = await discover(); - setHosts(found); - toaster.toast({ - title: "punktfunk", - body: - found.length === 0 - ? "No hosts found on the LAN" - : `Found ${found.length} host${found.length === 1 ? "" : "s"}`, - }); + setHosts(await discover()); } catch (e) { toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` }); } finally { setScanning(false); } - }; + }, []); - const onConnect = async (h: Host) => { - const target = `${h.host}:${h.port}`; - setBusyHost(target); + useEffect(() => { + void refresh(); + }, [refresh]); + + return { hosts, scanning, refresh }; +} + +async function startStream(h: Host) { + try { + await launchStream(h.host, h.port); + Navigation.CloseSideMenus(); + toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` }); + } catch (e) { + toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` }); + } +} + +// ---------------------------------------------------------------------------------------- +// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode). +// The host displays the PIN after the operator arms pairing; the user enters it here. +// ---------------------------------------------------------------------------------------- +const PairModal: FC<{ + host: Host; + closeModal?: () => void; + onPaired: () => void; +}> = ({ host, closeModal, onPaired }) => { + const [pin, setPin] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d)); + const back = () => setPin((p) => p.slice(0, -1)); + + const submit = async () => { + setBusy(true); + setError(null); try { - const res = await connect(h.host, h.port); + const res = await pair(host.host, host.port, pin, "Steam Deck"); if (res.ok) { - setConnectedHost(res.host); - toaster.toast({ title: "punktfunk", body: `Connecting to ${h.name}` }); + toaster.toast({ title: "punktfunk", body: `Paired with ${host.name}` }); + onPaired(); + closeModal?.(); } else { - toaster.toast({ - title: "punktfunk", - body: - res.error === "client-not-found" - ? "punktfunk-client is not installed" - : `Connect failed: ${res.error ?? "unknown"}`, - }); + setError(res.error ?? "pairing failed"); + setPin(""); } } catch (e) { - toaster.toast({ title: "punktfunk", body: `Connect failed: ${e}` }); + setError(String(e)); } finally { - setBusyHost(null); + setBusy(false); } }; - const onDisconnect = async () => { - try { - await disconnect(); - setConnectedHost(null); - toaster.toast({ title: "punktfunk", body: "Disconnected" }); - } catch (e) { - toaster.toast({ title: "punktfunk", body: `Disconnect failed: ${e}` }); - } - }; + return ( + +
+ Pair with {host.name} +
+
+ Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows. +
+ +
+ {pin.padEnd(4, "•")} +
+ {error && ( +
+ {error} +
+ )} + + + {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => ( + press(d)}> + {d} + + ))} + + ⌫ + + press("0")}> + 0 + + + {busy ? : "Pair"} + + +
+ ); +}; + +// ---------------------------------------------------------------------------------------- +// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON. +// ---------------------------------------------------------------------------------------- +const RESOLUTIONS: [number, number, string][] = [ + [0, 0, "Native display"], + [1280, 720, "1280 × 720"], + [1920, 1080, "1920 × 1080"], + [2560, 1440, "2560 × 1440"], +]; +const REFRESH = [0, 30, 60, 90, 120]; +const GAMEPADS = ["auto", "xbox360", "dualsense"]; + +const SettingsSection: FC = () => { + const [s, setS] = useState(null); - // On panel open: sync the current connection status and do an initial scan. useEffect(() => { - getStatus() - .then((s) => setConnectedHost(s.connected ? s.host : null)) - .catch(() => {}); - void refresh(); - // eslint-disable-next-line react-hooks/exhaustive-deps + void getSettings().then(setS); }, []); + const patch = (p: Partial) => { + setS((cur) => { + if (!cur) return cur; + const next = { ...cur, ...p }; + void setSettings(next); + return next; + }); + }; + + if (!s) return ; + + const resIdx = Math.max( + 0, + RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height), + ); + return ( <> - - - - {connectedHost ? `Connected — ${connectedHost}` : "Idle"} - - - {connectedHost && ( - - - - Disconnect - - - )} - + + ({ data: i, label }))} + selectedOption={resIdx} + onChange={(o) => { + const [w, h] = RESOLUTIONS[o.data as number]; + patch({ width: w, height: h }); + }} + /> + + + ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} + selectedOption={s.refresh_hz} + onChange={(o) => patch({ refresh_hz: o.data as number })} + /> + + patch({ bitrate_kbps: v * 1000 })} + /> + + ({ + data: g, + label: g === "auto" ? "Automatic" : g === "xbox360" ? "Xbox 360" : "DualSense", + }))} + selectedOption={s.gamepad} + onChange={(o) => patch({ gamepad: o.data as string })} + /> + + patch({ mic_enabled: v })} + /> + + ); +}; - +// ---------------------------------------------------------------------------------------- +// One host row on the full page. +// ---------------------------------------------------------------------------------------- +const HostRow: FC<{ host: Host }> = ({ host }) => { + const pairRequired = host.pair === "required"; + return ( + + {pairRequired ? : } + {host.name} + + } + description={`${host.host}:${host.port}${pairRequired ? " · pairing required" : ""}`} + childrenContainerWidth="max" + > + + {pairRequired && ( + + showModal( {}} />) + } + > + Pair + + )} + startStream(host)}> + + Stream + + + + ); +}; + +// ---------------------------------------------------------------------------------------- +// The fullscreen page (registered as the /punktfunk route). +// ---------------------------------------------------------------------------------------- +const PunktfunkPage: FC = () => { + const { hosts, scanning, refresh } = useHosts(); + + return ( +
+ + Navigation.NavigateBack()} + > + + +
+ punktfunk +
+ + {scanning ? ( + + ) : ( + + )} + {scanning ? "Scanning…" : "Refresh"} + +
+ +
Hosts
+ {hosts.length === 0 && !scanning && ( + No hosts discovered on the LAN. + )} + {hosts.map((h) => ( + + ))} + +
+ Stream settings +
+ +
+ ); +}; + +// ---------------------------------------------------------------------------------------- +// QAM panel — quick status + entry into the full page + one-tap stream for known hosts. +// ---------------------------------------------------------------------------------------- +const QamPanel: FC = () => { + const { hosts, scanning, refresh } = useHosts(); + + return ( + <> + + + { + Navigation.Navigate(ROUTE); + Navigation.CloseSideMenus(); + }} + > + + Open punktfunk + + {scanning ? ( @@ -133,39 +366,37 @@ function Content() { ) : ( )} - {scanning ? "Scanning…" : "Refresh"} + {scanning ? "Scanning…" : "Refresh hosts"} + + {hosts.length === 0 && !scanning && ( - No hosts discovered yet. + No hosts found. )} - {hosts.map((h) => { - const target = `${h.host}:${h.port}`; - const isBusy = busyHost === target; const pairRequired = h.pair === "required"; return ( - + onConnect(h)} + onClick={() => + pairRequired + ? showModal( startStream(h)} />) + : startStream(h) + } label={ - - {pairRequired ? ( - - ) : ( - - )} + + {pairRequired ? : } {h.name} } - description={`${target}${pairRequired ? " · pairing required" : ""}`} + description={`${h.host}:${h.port}`} > - {isBusy ? "Connecting…" : "Connect"} + {pairRequired ? "Pair & Stream" : "Stream"} ); @@ -173,16 +404,17 @@ function Content() { ); -} +}; export default definePlugin(() => { + routerHook.addRoute(ROUTE, PunktfunkPage, { exact: true }); return { name: "punktfunk", - titleView:
punktfunk
, - content: , + titleView:
punktfunk
, + content: , icon: , onDismount() { - // The backend tears the client down on _unload; nothing frontend-side to clean up. + routerHook.removeRoute(ROUTE); }, }; }); diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts new file mode 100644 index 0000000..bea3d05 --- /dev/null +++ b/clients/decky/src/steam.ts @@ -0,0 +1,114 @@ +// Launch the stream as a Steam game so gamescope focuses + fullscreens it. +// +// THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to +// the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see +// gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE +// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the +// per-session host as the shortcut's Steam launch options, and start it with RunGame. The +// wrapper then execs `flatpak run io.unom.Punktfunk --connect ` as a reaper descendant. + +import { runnerInfo } from "./backend"; + +// SteamClient is a Steam-internal global injected into the CEF context; it is not fully typed +// by @decky/ui, so declare the surface we use. Signatures verified against MoonDeck + the +// decky-frontend-lib SteamClient.Apps typings. +declare const SteamClient: { + Apps: { + AddShortcut( + name: string, + exePath: string, + startDir: string, + launchOptions: string, + ): Promise; + SetShortcutName(appId: number, name: string): void; + SetShortcutExe(appId: number, exe: string): void; + SetShortcutStartDir(appId: number, dir: string): void; + SetAppLaunchOptions(appId: number, options: string): void; + SetAppHidden(appId: number, hidden: boolean): void; + RunGame(gameId: string, _unused: string, _i: number, _j: number): void; + TerminateApp(gameId: string, _b: boolean): void; + }; +}; + +const SHORTCUT_NAME = "punktfunk"; + +// The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the +// standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this. +function gameIdFromAppId(appId: number): string { + return ((BigInt(appId) << 32n) | 0x02000000n).toString(); +} + +// Persist our shortcut appId across reloads so we reuse ONE shortcut instead of churning the +// library (the appId is stable for the life of the shortcut). +const STORAGE_KEY = "punktfunk:shortcutAppId"; + +function rememberAppId(appId: number) { + try { + localStorage.setItem(STORAGE_KEY, String(appId)); + } catch { + /* ignore */ + } +} +function recallAppId(): number | null { + try { + const v = localStorage.getItem(STORAGE_KEY); + return v ? Number(v) : null; + } catch { + return null; + } +} + +/** + * Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and + * return its appId. Reuses the remembered one when its exe still matches the current runner + * path (the plugin dir can change across reinstalls). + */ +async function ensureShortcut(): Promise { + const info = await runnerInfo(); + if (!info.exists) { + throw new Error(`launch wrapper missing at ${info.runner}`); + } + + const remembered = recallAppId(); + if (remembered != null) { + // Re-point the existing shortcut at the current runner path (cheap + idempotent). + SteamClient.Apps.SetShortcutExe(remembered, info.runner); + SteamClient.Apps.SetShortcutStartDir( + remembered, + info.runner.replace(/\/[^/]*$/, ""), + ); + return remembered; + } + + const appId = await SteamClient.Apps.AddShortcut( + SHORTCUT_NAME, + info.runner, + info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir + "", + ); + SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); + // Hide it from the library — it's an implementation detail, launched programmatically. + SteamClient.Apps.SetAppHidden(appId, true); + rememberAppId(appId); + return appId; +} + +/** + * Launch a stream to `host:port` fullscreen in Gaming Mode. Encodes the target into the + * shortcut's launch options (so one generic shortcut serves every host), then RunGame. + */ +export async function launchStream(host: string, port: number): Promise { + const appId = await ensureShortcut(); + const target = port && port !== 9777 ? `${host}:${port}` : host; + // KEY=value ... %command% — the wrapper reads PF_HOST from the environment. + SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`); + SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); +} + +/** Stop the running stream shortcut (best-effort; the in-stream chord/back also works). */ +export function stopStream(): void { + const appId = recallAppId(); + if (appId != null) { + SteamClient.Apps.TerminateApp(gameIdFromAppId(appId), false); + } +} diff --git a/crates/punktfunk-client-linux/src/app.rs b/crates/punktfunk-client-linux/src/app.rs index fc2bb21..bcea2d6 100644 --- a/crates/punktfunk-client-linux/src/app.rs +++ b/crates/punktfunk-client-linux/src/app.rs @@ -36,6 +36,11 @@ pub fn run() -> glib::ExitCode { tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); + // Headless pairing path (no GTK window): `--pair --connect host[:port] [--name N]`. + // Used by the Decky plugin (a GTK dialog can't pop under gamescope) and for scripting. + if let Some(pin) = arg_value("--pair") { + return headless_pair(&pin); + } let app = adw::Application::builder().application_id(APP_ID).build(); app.connect_activate(build_ui); // GTK doesn't see our argv (`--connect` is handled in `build_ui`); an empty argv also @@ -43,6 +48,66 @@ pub fn run() -> glib::ExitCode { app.run_with_args(&[] as &[&str]) } +/// The value following `flag` in argv, if present (`--flag value`). +fn arg_value(flag: &str) -> Option { + std::env::args() + .skip_while(|a| a != flag) + .nth(1) + .filter(|v| !v.starts_with("--")) +} + +/// Run the SPAKE2 PIN ceremony without a GTK window and persist the verified host to the +/// known-hosts store as paired, so a later `--connect` connects silently. Same identity +/// store the streaming path uses (same binary), so pairing here makes the stream work. +/// Prints a one-line `paired : fp=` on success; exits non-zero on failure. +fn headless_pair(pin: &str) -> glib::ExitCode { + let Some(target) = arg_value("--connect") else { + eprintln!("--pair requires --connect host[:port]"); + return glib::ExitCode::FAILURE; + }; + let (addr, port) = match target.rsplit_once(':') { + Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)), + None => (target.clone(), 9777), + }; + // The label the HOST stores this client under (its paired-devices list). + let name = arg_value("--name").unwrap_or_else(|| "Steam Deck".to_string()); + + let identity = match crate::trust::load_or_create_identity() { + Ok(i) => i, + Err(e) => { + eprintln!("client identity: {e:#}"); + return glib::ExitCode::FAILURE; + } + }; + match NativeClient::pair( + &addr, + port, + (&identity.0, &identity.1), + pin.trim(), + &name, + std::time::Duration::from_secs(90), + ) { + Ok(fp) => { + let fp_hex = crate::trust::hex(&fp); + let mut known = KnownHosts::load(); + known.upsert(KnownHost { + name: arg_value("--host-label").unwrap_or_else(|| addr.clone()), + addr: addr.clone(), + port, + fp_hex: fp_hex.clone(), + paired: true, + }); + let _ = known.save(); + println!("paired {addr}:{port} fp={fp_hex}"); + glib::ExitCode::SUCCESS + } + Err(e) => { + eprintln!("pairing failed: {e:?} (wrong PIN, or pairing not armed on the host?)"); + glib::ExitCode::FAILURE + } + } +} + /// `--connect host[:port]` — skip the hosts page and start a session immediately /// (scripting + headless testing). Trust follows the same rules as a manual entry: a host /// already pinned at this address connects silently on its stored pin; an unknown host is