From 017c37b78af72cdbabda4fef04540cb698db7ce4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 4 Aug 2026 20:41:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(decky):=20rebuild=20the=20panel=20as=20a?= =?UTF-8?q?=20launcher=20=E2=80=94=20nested=20cards=20and=20request=20acce?= =?UTF-8?q?ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access panel, four sections, no route. HOSTS. One `useHosts()` calls discover and hosts-list together and merges them by fingerprint first, address second — so a host that moved DHCP lease still matches its record, and a different box that inherited the old address does not inherit its pairing. The CLI annotates `saved`/`paired` by that same rule, so the two surfaces cannot disagree. Rows sort online first, then most recently used, then by name: the host you streamed last night is the first thing under your thumb, and a host that is off right now never is. `needsPair` is now ONE rule: no pinned fingerprint. The session binary refuses a pinless connect, so a row without one can offer nothing but a button that fails. The old rule also consulted the advertised policy for unsaved hosts, which made the same box read differently before and after being saved. PINNED CARDS render NESTED under their host as `▸ `, not in a section of their own — a card IS a (host, profile) pair, and a row floating free of its host is exactly the "a pinned tile reads as a duplicate host" problem the desktop shells still have. The host's own BOUND profile is deliberately not drawn as a card: it applies silently on the plain row, and showing it twice would suggest the two do different things. This plugin creates, edits and deletes no profile and no card — pin creation belongs where profiles are edited. TRUST SHEET (new, trust.tsx). Request access (default) / Use a PIN instead… / Cancel, in the GTK dialog's order and wording. Request access is not a second ceremony — it saves the host with the fingerprint it ADVERTISED, then launches; the host parks that connect until its operator approves this Deck, admits it, and the stream starts by itself. No fingerprint, no request access. A host typed in by address advertises none, so the sheet offers the PIN path only and says why, rather than showing a button that could only fail. The sheet never TOFUs past a missing fingerprint: that pin is the only thing standing between a 185 s wait and an impostor answering for the host. The sheet is a `showModal` portal, so it captures its callbacks once and never re-renders from panel state — everything it acts on later is read through a ref. Reading a captured value is precisely what made pinning a second game compute from a stale base and clobber the first. LAUNCH PATH. The wrapper's contract becomes PF_REF / PF_PROFILE / PF_REQUEST_ACCESS / PF_BROWSE; PF_HOST, PF_LAUNCH, PF_MGMT and PF_CONNECT_TIMEOUT are gone. A stream is now `punktfunk launch [--profile ] --exec --fullscreen`, and a reference is all that ever rides Steam's launch options — no resolution, bitrate or codec, the same rule the deep-link grammar enforces. Request-access launches run SUPERVISED, without `--exec`: under --exec the CLI becomes the session, so no process survives to see the stream come up and record the approval. Safe for gamescope because focus follows reaper's descendant tree, not a single process, and flatpak-run/bwrap already sit in that tree on every other path. Wake-on-LAN comes out entirely. The plugin used to fire a magic packet itself and then stretch the connect budget to 75 s to cover the host's resume — a workaround for the CLI-less era. `punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers, which is strictly better and deletes a backend method, a frontend call and a shell branch. The console-home branch of the wrapper is untouched on purpose: the shell binary already execs the session for `--browse`, so there is nothing to repoint and no reason to spend a diff there. Everything else in steam.ts — two shortcuts sharing one name (and so one Steam Input configset key), artwork versioning, appId verification, controller config, stopStream — is unchanged. --- clients/decky/bin/punktfunkrun.sh | 109 +++---- clients/decky/src/backend.ts | 310 ++++++------------ clients/decky/src/hooks.ts | 508 ++++++++++-------------------- clients/decky/src/index.tsx | 257 ++++++++------- clients/decky/src/pair.tsx | 30 +- clients/decky/src/steam.ts | 94 +++--- clients/decky/src/trust.tsx | 140 ++++++++ 7 files changed, 656 insertions(+), 792 deletions(-) create mode 100644 clients/decky/src/trust.tsx diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index 3154b04d..349eda41 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -1,33 +1,32 @@ #!/usr/bin/env bash -# punktfunk stream runner — the target of the hidden non-Steam shortcut the plugin creates. +# punktfunk stream runner — the target of the non-Steam shortcuts 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. +# launches THIS script through SteamClient.Apps.RunGame; the script then runs the client, +# which inherits the shortcut's AppID and is focused. Launching the client 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 (and every pinned game): -# PF_HOST host[:port] to connect to (required for streaming; optional for browse) -# 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_CONNECT_TIMEOUT connect budget in seconds (optional; the plugin stretches it after -# firing Wake-on-LAN so the connect survives the host's resume) -# PF_APPID flatpak app id (default io.unom.Punktfunk) -# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) +# every host: +# PF_REF host reference — a saved host's stable id, or addr[:port] (required to stream) +# PF_PROFILE settings-profile id for a pinned card (optional) +# PF_REQUEST_ACCESS non-empty = ask the host's operator to admit this device instead of +# pairing with a PIN. The connect PARKS until somebody approves it. +# PF_BROWSE non-empty = open the client's console home instead of streaming +# PF_APPID flatpak app id (default io.unom.Punktfunk) +# PF_FLATPAK override the flatpak binary path (default: `flatpak` on PATH) # PF_CLIENT_BIN absolute path of a NATIVE client (optional; set by the plugin when it -# resolved a non-flatpak install — then the client is exec'd directly and +# resolved a non-flatpak install — then the client is run directly and # PF_APPID/PF_FLATPAK are unused) # -# 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. +# A REFERENCE, NEVER A VALUE. Host refs and profile ids are the only things that ride this +# channel; no resolution, bitrate or codec ever does. The client resolves both against its own +# stores, which is what keeps a Steam launch option from becoming a second settings surface. +# The plugin validates them to space/quote-free ASCII before they reach Steam's tokenizer. # # 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. @@ -42,13 +41,22 @@ APPID="${PF_APPID:-io.unom.Punktfunk}" FLATPAK="${PF_FLATPAK:-flatpak}" # The client is not always the flatpak: a sysext, a .deb/.rpm, an AUR build or a nix profile -# installs a native `punktfunk-client`, and the plugin passes its absolute path here when that -# is what it resolved. Both kinds take the same argv and share ~/.config/punktfunk, so the only -# difference is the prefix in front of it. +# installs a native `punktfunk-client` with the CLI as its sibling, and the plugin passes the +# client's absolute path here when that is what it resolved. # -# exec so the client IS the game process — when it exits, Steam ends the "game" and Gaming Mode -# reclaims focus automatically (no manual refocus needed). -run_client() { +# run_cli execs the HEADLESS CLI (`punktfunk`); run_session execs the GTK/console shell +# (`punktfunk-client`). Both live in the same place in both install kinds — /app/bin inside the +# flatpak, reachable with `--command=`, and one bindir natively. +run_cli() { + if [ -n "${PF_CLIENT_BIN:-}" ]; then + # `${VAR%/*}` rather than `dirname`: pure parameter expansion, so this works with no + # PATH at all — which is the environment a Steam launch option can leave us in. + exec "${PF_CLIENT_BIN%/*}/punktfunk" "$@" + fi + exec "$FLATPAK" run --arch=x86_64 --command=punktfunk "$APPID" "$@" +} + +run_session() { if [ -n "${PF_CLIENT_BIN:-}" ]; then exec "$PF_CLIENT_BIN" "$@" fi @@ -58,40 +66,35 @@ run_client() { # What we are about to run, for the log line each branch prints. CLIENT_LABEL="${PF_CLIENT_BIN:-$APPID}" -# --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). +# The console home: the client's own gamepad UI (host picker, pairing, add-host by address, the +# library browser and the full settings screen). UNCHANGED from before this rework — the shell +# binary already execs the session for `--browse`, so there is nothing to repoint here. if [ -n "${PF_BROWSE:-}" ]; then - # The gamepad UI. BARE `--browse` (no PF_HOST) opens the console home — the self-contained - # host picker + pairing + settings, gamepad-navigable — which is what the stateless, visible - # library shortcut launches. `--browse ` opens straight into that host's library (the - # per-host "open on screen" action). A streams a game, session end returns here, B quits. - if [ -z "${PF_HOST:-}" ]; then - echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2 - run_client --browse --fullscreen - fi - echo "punktfunkrun: library $CLIENT_LABEL --browse $PF_HOST" >&2 - if [ -n "${PF_MGMT:-}" ]; then - run_client --browse "$PF_HOST" --mgmt "$PF_MGMT" --fullscreen - fi - run_client --browse "$PF_HOST" --fullscreen + echo "punktfunkrun: gamepad UI $CLIENT_LABEL --browse (console home)" >&2 + run_session --browse --fullscreen fi -# Streaming modes need a host (browse above is the only host-less path). -if [ -z "${PF_HOST:-}" ]; then - echo "punktfunkrun: PF_HOST is not set (the plugin sets it as a launch option)" >&2 +if [ -z "${PF_REF:-}" ]; then + echo "punktfunkrun: PF_REF is not set (the plugin sets it as a launch option)" >&2 exit 2 fi -# Trailing args shared by both streaming execs. A stretched connect budget rides along when the -# plugin set one (it just fired Wake-on-LAN, so the host may still be resuming); an older flatpak -# without --connect-timeout ignores the flag harmlessly (hand-scanned argv). + set -- --fullscreen -if [ -n "${PF_CONNECT_TIMEOUT:-}" ]; then - set -- --connect-timeout "$PF_CONNECT_TIMEOUT" "$@" +if [ -n "${PF_PROFILE:-}" ]; then + set -- --profile "$PF_PROFILE" "$@" fi -if [ -n "${PF_LAUNCH:-}" ]; then - # A pinned game: the id rides the session Hello and the host launches that title. - echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST --launch $PF_LAUNCH" >&2 - run_client --connect "$PF_HOST" --launch "$PF_LAUNCH" "$@" + +# REQUEST ACCESS RUNS SUPERVISED — no `--exec`. Under --exec the CLI BECOMES the session, so no +# process survives to see the stream come up and record the host as paired; the CLI refuses the +# combination outright rather than downgrading silently. This is safe for gamescope because +# focus follows reaper's DESCENDANT TREE, not a single process, and `flatpak run`/`bwrap` +# already sit between reaper and the client on every other path. +if [ -n "${PF_REQUEST_ACCESS:-}" ]; then + echo "punktfunkrun: request access $CLIENT_LABEL launch $PF_REF (waiting for approval)" >&2 + run_cli launch "$PF_REF" --request-access "$@" fi -echo "punktfunkrun: streaming $CLIENT_LABEL --connect $PF_HOST" >&2 -run_client --connect "$PF_HOST" "$@" + +# The ordinary stream. `--exec` is the documented gamescope-wrapper mode: the CLI becomes the +# session, so the process tree stays flat and Steam's "game" ends exactly when the stream does. +echo "punktfunkrun: streaming $CLIENT_LABEL launch $PF_REF" >&2 +run_cli launch "$PF_REF" --exec "$@" diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index d7d21f24..db65398b 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -1,95 +1,94 @@ // Bridge to the Python backend (main.py) + shared types. +// +// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the +// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin +// used to model the client's stores itself and drifted from them with every field the client +// added. + import { callable } from "@decky/api"; -export interface Host { - name: string; - host: string; - port: number; - pair: string; // "required" | "optional" — the HOST's policy - fp: string; // host cert SHA-256 fingerprint (lowercase hex) from the mDNS advert - proto: string; // advertised protocol, e.g. "punktfunk/1" - paired: boolean; // whether THIS device has already PIN-paired this host (by fingerprint) - id: string; // the host's stable instance id (mDNS TXT `id`; "" when not advertised) - mgmt: number; // management-API port (mDNS TXT `mgmt`; 0 = not advertised → default 47990) - os: string; // OS-identity chain (mDNS TXT `os`, e.g. "linux/fedora/bazzite"); "" on older hosts -} - -// One title from a host's game library (the flatpak client's --library TSV, parsed by the -// backend). `id` is store-qualified (steam: / custom:) and doubles as the -// launch handle (PF_LAUNCH → the session Hello). -export interface GameEntry { +/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */ +export interface Profile { id: string; - store: string; // "steam" | "custom" | "heroic" | "lutris" | … - title: string; + name: 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 { - ok: boolean; - fp?: string; - error?: string; -} - -// A host in the SHARED saved-hosts store (client-known-hosts.json) — the same file the desktop -// client reads/writes, so add/rename/pair in either surface shows up in both. `online` comes -// from a mDNS-INDEPENDENT reachability probe (a Tailscale/VPN host isn't shown offline just -// because it doesn't advertise); `null` means reachability is unknown (probe skipped or a client -// too old for `--list-hosts`, which then also can't probe). -export interface SavedHost { +/** + * A host answering on mDNS right now (`punktfunk discover --json`). + * + * `saved`/`paired` are annotated BY THE CLI against the saved-hosts store — fingerprint first, + * address second. The plugin does not join the two lists itself; that rule living in one place + * is what stops this surface disagreeing with the desktop client about the same box. + */ +export interface DiscoveredHost { name: string; addr: string; port: number; - fp_hex: string; // host cert fingerprint (lowercase hex); "" for a not-yet-paired manual entry + fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised + pair: string; // the HOST's policy: "required" | "optional" + id: string; // the host's advertised stable id; "" when not advertised + mgmt: number; // management-API port; 0 = not advertised + os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts + saved: boolean; + paired: boolean; +} + +/** + * A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) — the same + * `client-known-hosts.json` the desktop client owns. + * + * `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not + * shown offline merely because it never advertises; `null` means the probe was skipped. + * + * `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not + * the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come + * back already resolved against the profile catalog, so this plugin never opens it. + */ +export interface SavedHost { + id: string | null; // the record's stable id — the reference a launch should use + name: string; + addr: string; + port: number; + fp_hex: string; // "" for a placeholder saved by address with no pin yet paired: boolean; mac: string[]; - // OS-identity chain learned by the desktop client; optional because the installed - // flatpak client may predate the field. - os?: string; + os: string; last_used: number | null; + clipboard_sync: boolean; + profile: Profile | null; + pinned_profiles: Profile[]; online: boolean | null; } -export interface HostsResult { - ok: boolean; - hosts: SavedHost[]; - probed: boolean; - fallback?: boolean; // true when read straight off disk (client too old for --list-hosts) -} - -// The result of a host-store mutation (add/edit/forget). `error` is a stable code: -// "client-unavailable" (flatpak missing) | "client-outdated" (client predates the mode) | -// "unreachable"/"http"/… (from the client) | "client-error" (generic; see `detail`). -export interface MutationResult { +/** + * Every backend call answers in this shape. `error` is a stable code, never prose: + * + * - `client-unavailable` — no client is installed, or the call never ran + * - `client-outdated` — the installed client predates the verb (exit 5 + `unknown command`) + * - `unreachable` — the host did not answer + * - `refused` — trust rejected: a wrong PIN, or a fingerprint that already differs + * - `needs-pairing` — the CLI refused because it needs a person + * - `unresolved` — nothing matched what was named + * - `client-error` — anything else; `detail` carries the CLI's own last line + */ +export interface CliResult { ok: boolean; error?: string; detail?: string; } +export interface DiscoverResult extends CliResult { + hosts?: DiscoveredHost[]; +} + +export interface HostsResult extends CliResult { + hosts?: SavedHost[]; +} + +export interface PairResult extends CliResult { + fp?: string; +} + export interface RunnerInfo { runner: string; // absolute path to bin/punktfunkrun.sh app_id: string; // flatpak app id @@ -101,99 +100,6 @@ export interface RunnerInfo { client_bin?: string; } -// The flatpak client's settings JSON — the SAME `client-gtk-settings.json` the desktop client -// and the console's settings screen own, so a value changed in any of them shows in the others. -// -// Every field the client's `Settings` struct persists is modelled here EXCEPT the ones that -// cannot be answered from a plugin backend or aren't settings at all: -// • `forward_pad` — which physical pad is player 1. Needs SDL's live device list, which only -// the client process has; there is no CLI that enumerates pads. -// • `last_window_w/h` — the session's remembered window size, written BY the client, not a -// preference anyone sets. -// Both round-trip untouched: get_settings returns the whole parsed file, patches are object -// spreads, and set_settings merges onto what's on disk. -// -// Optional (`?`) marks a key the client writes with a serde `default`, so a store written before -// that key existed simply lacks it. Read those through the same fallback the client uses — -// `?? true` for the default-on ones, never `!!` — or a pre-existing file reads as "off" here -// while the stream runs with it on. -export interface StreamSettings { - // ---- Stream mode ---- - width: number; // 0 = native - height: number; // 0 = native - refresh_hz: number; // 0 = native - render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files) - bitrate_kbps: number; // 0 = host default - compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" - // Stream mode follows the session window instead of width/height, renegotiating on resize. - // Overrides width/height while on; degenerates to the display's native mode on fullscreen. - match_window?: boolean; - - // ---- Video ---- - codec?: string; // "auto" | "hevc" | "h264" | "av1" | "pyrowave" (absent in pre-codec files) - decoder?: string; // "auto" | "vulkan" | "vaapi" | "software" - hdr_enabled?: boolean; // default ON — advertise 10-bit/HDR10 - enable_444?: boolean; // default off — ask for full chroma - adapter?: string; // decode/present GPU by marketing name; "" = automatic - - // ---- Presentation ---- - // What the client optimises for when a decoded frame is ready: "latency" | "smooth". Shared - // with the Apple and Android clients under this name, so one profile reads the same everywhere. - present_priority?: string; - smooth_buffer?: number; // frames held back under "smooth"; 0 = Automatic (resolves to 2), else 1–3 - vsync?: boolean; // default ON — tear-free; off asks for a tearing present mode (best-effort) - allow_vrr?: boolean; // default ON — let a VRR panel refresh in step with the stream - - // ---- Audio ---- - audio_channels?: number; // 2 (stereo) | 6 (5.1) | 8 (7.1) - speaker_device?: string; // PipeWire node.name for playback; "" = system default - mic_enabled: boolean; - mic_device?: string; // PipeWire node.name for capture; "" = system default - echo_cancel?: boolean; // default ON; only meaningful while mic_enabled - - // ---- Controllers ---- - gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck" - // Forward this device's controllers at all. Absent in pre-forwarding files, where the - // client's own serde default (true) applies — so `?? true` at every read, never `!!`. - gamepad_forwarding?: boolean; - - // ---- Touchscreen, mouse & keyboard ---- - touch_mode?: string; // "trackpad" | "pointer" | "touch" - mouse_mode?: string; // "capture" | "desktop" - invert_scroll?: boolean; - // Whether the session grabs the keyboard so Alt+Tab/Super reach the host. - inhibit_shortcuts: boolean; - - // ---- Interface & behaviour ---- - // Stats-overlay tier: "off" | "compact" | "normal" | "detailed". Absent in a pre-tier file, - // which resolves through `show_stats` — read both the way the client's - // `Settings::stats_verbosity` does, and write both the way `set_stats_verbosity` does. - stats_verbosity?: string; - // The legacy on/off the tier supersedes; kept written in sync so a client that predates the - // tiers still honours an Off chosen here. - show_stats?: boolean; - fullscreen_on_stream?: boolean; - auto_wake?: boolean; // default ON — Wake-on-LAN a sleeping host before connecting - library_enabled?: boolean; // the CLIENT's own library browser (this plugin has its own) -} - -// One audio endpoint from the client's enumeration: the stable id that gets stored, plus the -// human name to show. -export interface AudioDevice { - name: string; // PipeWire node.name — what `speaker_device` / `mic_device` store - description: string; // human label ("Steam Deck Speakers") -} - -// What the device pickers need, read from the session binary (`--list-adapters` / `--list-audio`). -// `ok: false` = the session binary couldn't be run or failed; every list is then empty and the -// pickers stay on their stored value rather than pretending the device is gone. -export interface DeviceLists { - ok: boolean; - adapters: string[]; // Vulkan physical devices, discrete first - sinks: AudioDevice[]; // playback endpoints - sources: AudioDevice[]; // capture endpoints -} - export interface UpdateInfo { current: string; // installed PLUGIN version (package.json) latest: string; // newest plugin version in our registry for this channel @@ -229,21 +135,30 @@ export interface ShortcutArt { icon_path: string; } -export const discover = callable<[], Host[]>("discover"); +// ---- The four CLI shells -------------------------------------------------------------- + +/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */ +export const discover = callable<[], DiscoverResult>("discover"); +/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */ +export const hosts = callable<[], HostsResult>("hosts"); +/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */ export const pair = callable< - [host: string, port: number, pin: string, name: string], + [addr: string, port: number, pin: string, name: string], PairResult >("pair"); -// Fetch a paired host's game library (headless flatpak --library; can take seconds on a -// cold client start — show a spinner). Pass fp whenever known so the pin can't degrade. -export const library = callable< - [host: string, mgmt_port: number, fp: string], - LibraryResult ->("library"); -export const getPins = callable<[], { pins: PinnedGame[] }>("get_pins"); -export const setPins = callable<[pins: PinnedGame[]], { ok: boolean; error?: string }>( - "set_pins", -); +/** + * Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired. + * The launch that follows pins the same fingerprint, which is the only thing standing between a + * 185 s wait for approval and an impostor answering for the host. Idempotent; a host already + * saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten. + */ +export const trustHost = callable< + [addr: string, port: number, fp: string, name: string], + CliResult +>("trust_host"); + +// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------ + export const runnerInfo = callable<[], RunnerInfo>("runner_info"); export const shortcutArt = callable<[], ShortcutArt>("shortcut_art"); // Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our @@ -254,48 +169,7 @@ export const applyControllerConfig = callable< [name: string], { ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string } >("apply_controller_config"); -export const getSettings = callable<[], StreamSettings>("get_settings"); -export const setSettings = callable<[settings: StreamSettings], { ok: boolean }>( - "set_settings", -); -// GPUs + audio endpoints for the device pickers. Costs a subprocess that initialises Vulkan and -// PipeWire, so it is called ONCE when the settings tab mounts and never on the launch path. -export const listDevices = callable<[], DeviceLists>("list_devices"); -// The same, bypassing the backend's cache — for the user who just plugged in a headset. -export const refreshDevices = callable<[], DeviceLists>("refresh_devices"); export const killStream = callable<[], { ok: boolean }>("kill_stream"); -// Send a Wake-on-LAN magic packet to a saved host (headless flatpak --wake) so a sleeping host is -// up by the time the stream connects. The MAC is looked up from the flatpak client's own -// known-hosts store; `ok: false` (no-op) when none has been learned yet. Fire before launching. -export const wake = callable<[host: string, port: number], { ok: boolean; error?: string }>( - "wake", -); -// ---- Shared saved-hosts store (the SAME client-known-hosts.json the desktop client owns) ---- -// The saved hosts, each annotated with a live (mDNS-independent) `online` probe when `probe` is -// true. Falls back to a direct JSON read (no reachability) on a client too old for --list-hosts. -export const listHosts = callable<[probe: boolean], HostsResult>("list_hosts"); -// Save a host by address (survives mDNS-blind networks). `fp` empty = unpaired placeholder to -// pair next; a later pair replaces it with the fingerprinted entry. -export const addHost = callable<[target: string, name: string, fp: string], MutationResult>( - "add_host", -); -// Rename and/or re-point a saved host. `selector` = its fingerprint (survives IP change) or -// current addr[:port]; empty fields are left untouched. -export const editHost = callable< - [selector: string, name: string, addr: string, port: number], - MutationResult ->("edit_host"); -// Remove a saved host by fingerprint or addr[:port] (idempotent). -export const forgetHost = callable<[selector: string], MutationResult>("forget_host"); -// Reset this device's Punktfunk state (saved hosts + stream settings + pins); KEEPS the client -// identity so the box isn't seen as new everywhere (re-pairing re-adds hosts). -export const resetConfig = callable<[], { ok: boolean; error?: string }>("reset_config"); -// Reachability of one host[:port] via the client's mDNS-independent QUIC probe (a "test address" -// check). `{ ok: true, online }` when determined, else `{ ok: false, error }`. -export const probeHost = callable< - [target: string], - { ok: boolean; online?: boolean; error?: string } ->("probe_host"); export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update"); // Update the client by whichever route its install supports: `flatpak update --user` for the // flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 75e9501a..f0735487 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -1,18 +1,14 @@ -// Shared state hooks + user actions for the QAM panel and the fullscreen page. +// Shared state hooks + user actions for the QAM panel. import { toaster } from "@decky/api"; import { Navigation } from "@decky/ui"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { checkUpdate, discover, - GameEntry, - getPins, - Host, - listHosts, - PinnedGame, - resetConfig, + DiscoveredHost, + hosts as listHosts, + Profile, SavedHost, - setPins as setPinsBackend, updateClient, UpdateInfo, } from "./backend"; @@ -37,19 +33,158 @@ declare global { // PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…). const INSTALL_TYPE_UPDATE = 2; +/** + * How far this device has got with a host. The three states are what the row says under the + * name, and which of them a host is in decides whether pressing it streams or opens the trust + * sheet. + * + * - `paired` — the host approved this device (a PIN ceremony, or request access). + * - `trusted` — its fingerprint is pinned but nobody has approved us yet. Streams work if + * the host's policy is `optional`; under `required` the connect parks. + * - `needs-access` — no pinned fingerprint. Not streamable until the trust sheet runs. + */ +export type TrustState = "paired" | "trusted" | "needs-access"; + +/** + * One host as the panel shows it — the union of the saved store and the live mDNS browse. + * + * A saved host is ONLINE when it either advertises or answers the reachability probe, so a box + * reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are + * appended as extra rows. + */ +export interface HostView { + name: string; + addr: string; + port: number; + /** Pinned cert fingerprint. "" = nothing pinned, which is what makes a host unstreamable. */ + fp: string; + paired: boolean; + online: boolean; + saved: boolean; + /** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */ + pairPolicy: string; + /** OS-identity chain (live advert preferred, else the stored one); "" unknown. */ + os: string; + /** + * What a launch should NAME this host by: the record's stable id, which survives renames and + * DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host + * the trust sheet is about to save, or a client too old to have minted ids). + */ + ref: string; + /** The host's default profile binding — applied silently by a plain connect, not a card. */ + profile: Profile | null; + /** The cards to render nested under this host; already resolved against the catalog. */ + pinnedProfiles: Profile[]; + lastUsed: number | null; +} + +export function trustState(v: HostView): TrustState { + if (v.paired) return "paired"; + return v.fp ? "trusted" : "needs-access"; +} + +/** + * Must this host go through the trust sheet before it can stream? + * + * A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row + * without one can offer nothing but a button that fails; with one, the connect is verified and + * the host either admits it or parks it for an operator. The old rule also consulted the + * advertised policy for unsaved hosts, which made the answer depend on which of two lists a row + * came from — the same box could read differently before and after being saved. + */ +export function needsPair(v: HostView): boolean { + return v.fp === ""; +} + +function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean { + return ( + (!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) || + (s.addr === a.addr && s.port === a.port) + ); +} + +/** + * Join the saved store and the live browse into the rows the panel draws. + * + * Fingerprint first, address second — a host that moved DHCP lease still matches its record, + * and a different box that inherited the old address does not inherit its pairing. The CLI's + * `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree. + */ +export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] { + const views: HostView[] = saved.map((s) => { + // Prefer a live advert's address: the host may have moved since it was last saved. + const advert = discovered.find((a) => advertMatchesSaved(a, s)); + return { + name: s.name || s.addr, + addr: advert?.addr ?? s.addr, + port: advert?.port ?? s.port, + fp: s.fp_hex || advert?.fp || "", + paired: s.paired, + online: !!advert || s.online === true, + saved: true, + pairPolicy: advert?.pair ?? "", + os: advert?.os || s.os || "", + ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`, + profile: s.profile, + pinnedProfiles: s.pinned_profiles ?? [], + lastUsed: s.last_used, + }; + }); + for (const a of discovered) { + if (saved.some((s) => advertMatchesSaved(a, s))) { + continue; // already rendered as its saved row, with a live pip + } + views.push({ + name: a.name, + addr: a.addr, + port: a.port, + fp: a.fp, + paired: a.paired, + online: true, + saved: false, + pairPolicy: a.pair, + os: a.os, + ref: `${a.addr}:${a.port}`, + profile: null, + pinnedProfiles: [], + lastUsed: null, + }); + } + return views.sort(sortRows); +} + +/** + * Online first, then most recently used, then by name. The host you streamed last night should + * be the first thing under your thumb; a host that is off right now should never be. + */ +function sortRows(a: HostView, b: HostView): number { + if (a.online !== b.online) return a.online ? -1 : 1; + if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0); + return a.name.localeCompare(b.name); +} + // ---------------------------------------------------------------------------------------- -// Discovery — mDNS scan state shared by the QAM panel and the full page. +// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two +// views mounting them independently; the panel is the only view now, and merging them means +// the "scanning" state covers the whole row set rather than half of it flickering in first. // ---------------------------------------------------------------------------------------- export function useHosts() { - const [hosts, setHosts] = useState([]); + const [views, setViews] = useState([]); const [scanning, setScanning] = useState(false); + // A client too old for `punktfunk discover`. Rendered as one explanatory row plus the update + // button that fixes it — never as an empty list, which would read as "no hosts on your LAN". + const [outdated, setOutdated] = useState(false); const refresh = useCallback(async () => { setScanning(true); try { - setHosts(await discover()); + // Both in flight at once: the browse is time-bounded and the probe is network-bound, so + // running them in sequence would cost the sum of two waits for no benefit. + const [d, s] = await Promise.all([discover(), listHosts()]); + setOutdated(d.error === "client-outdated" || s.error === "client-outdated"); + setViews(mergeHosts(s.hosts ?? [], d.hosts ?? [])); } catch (e) { - toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` }); + toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` }); } finally { setScanning(false); } @@ -59,157 +194,7 @@ export function useHosts() { void refresh(); }, [refresh]); - return { hosts, scanning, refresh }; -} - -// ---------------------------------------------------------------------------------------- -// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the -// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a -// routed network (Tailscale/VPN) reports online without ever appearing on mDNS. -// ---------------------------------------------------------------------------------------- -export function useSavedHosts() { - const [saved, setSaved] = useState([]); - const [loading, setLoading] = useState(false); - - const refresh = useCallback(async () => { - setLoading(true); - try { - const r = await listHosts(true); - setSaved(r.hosts ?? []); - } catch { - /* backend unavailable — keep the current view */ - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - return { saved, loading, refresh }; -} - -/** - * One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved - * host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so - * mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store - * are appended as unsaved rows. - */ -export interface HostView { - name: string; - addr: string; - port: number; - fp: string; // "" for a saved-but-unpaired placeholder - paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false) - online: boolean; - saved: boolean; // present in the known-hosts store - pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising - mgmt: number; // advertised mgmt-API port (0 = not advertised → default) - id: string; // advertised stable host id ("" when not advertising) - os: string; // OS-identity chain (live advert preferred, else the stored one); "" unknown -} - -function advertMatchesSaved(a: Host, s: SavedHost): boolean { - return ( - (!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) || - (s.addr === a.host && s.port === a.port) - ); -} - -export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] { - const views: HostView[] = saved.map((s) => { - // Prefer a live advert's address (a host may have moved DHCP leases since it was saved). - const advert = discovered.find((a) => advertMatchesSaved(a, s)); - return { - name: s.name || s.addr, - addr: advert?.host ?? s.addr, - port: advert?.port ?? s.port, - fp: s.fp_hex || advert?.fp || "", - paired: s.paired, - online: !!advert || s.online === true, - saved: true, - pairPolicy: advert?.pair ?? "", - mgmt: advert?.mgmt ?? 0, - id: advert?.id ?? "", - os: advert?.os || s.os || "", - }; - }); - for (const a of discovered) { - if (saved.some((s) => advertMatchesSaved(a, s))) { - continue; // already rendered as its saved card (with a live pip) - } - views.push({ - name: a.name, - addr: a.host, - port: a.port, - fp: a.fp, - paired: a.paired, - online: true, - saved: false, - pairPolicy: a.pair, - mgmt: a.mgmt, - id: a.id, - os: a.os, - }); - } - return views; -} - -/** - * True when this host must be paired before it can stream. A saved host is streamable once it - * has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be - * paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used. - */ -export function needsPair(v: HostView): boolean { - return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired; -} - -/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */ -export function toHost(v: HostView): Host { - return { - name: v.name, - host: v.addr, - port: v.port, - pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"), - fp: v.fp, - proto: "", - paired: v.paired, - id: v.id, - mgmt: v.mgmt, - os: v.os, - }; -} - -/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */ -export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean { - const fp = pin.host_fp.toLowerCase(); - return views.some( - (v) => - v.online && - ((!!fp && v.fp.toLowerCase() === fp) || - (!!pin.host_id && v.id === pin.host_id) || - (v.addr === pin.host && v.port === pin.port)), - ); -} - -/** - * Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity. - * Refreshes whatever views are passed so the UI clears immediately. Ends in a toast. - */ -export async function resetAll(refreshers: Array<() => void | Promise>): Promise { - try { - const r = await resetConfig(); - for (const fn of refreshers) void fn(); - toaster.toast({ - title: "Punktfunk", - body: r.ok - ? "Reset — saved hosts, settings, and pins cleared." - : `Reset failed${r.error ? ` (${r.error})` : ""}.`, - }); - } catch { - toaster.toast({ title: "Punktfunk", body: "Reset failed." }); - } + return { views, scanning, outdated, refresh }; } // ---------------------------------------------------------------------------------------- @@ -260,36 +245,6 @@ export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boole ); } -/** - * How the client got onto this box, in words a Deck user recognises. The raw kind comes from - * the client's own detector (`pf_update_check::detect`); anything unmapped falls through as - * itself rather than as "unknown", because the raw word is still more useful than a shrug. - */ -export function clientInstallLabel(kind: string): string { - switch (kind) { - case "flatpak": - return "Flatpak (per-user)"; - case "apt": - return "System package (apt)"; - case "dnf": - return "System package (dnf)"; - case "rpm-ostree": - return "Layered package (rpm-ostree)"; - case "pacman": - return "System package (pacman)"; - case "sysext": - return "System extension (sysext)"; - case "nix": - return "Nix profile"; - case "steamos-source": - return "On-device build"; - case "source": - return "Built from source"; - default: - return kind; - } -} - /** True when the only pending update is one this Deck can't apply itself. */ export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean { return !!info && info.client_update_available && !clientUpdateIsOneTap(info); @@ -427,167 +382,26 @@ 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 it can't be direct). // ---------------------------------------------------------------------------------------- + +/** + * Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess` + * runs the supervised launch that waits for the host's operator to approve this Deck. + * + * The host is named by REFERENCE (`v.ref`), never by value — no resolution, bitrate or codec + * ever rides the launch path, which is the same rule the deep-link grammar enforces. + */ export async function startStream( - h: Host, + v: HostView, opts: LaunchOpts = {}, label?: string, ): Promise { try { - await launchStream(h.host, h.port, opts); + await launchStream(v.ref, opts); Navigation.CloseSideMenus(); - toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${h.name}` }); + toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` }); } catch (e) { toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` }); } } - -/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */ -export async function startBrowse(h: Host): Promise { - try { - await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt }); - Navigation.CloseSideMenus(); - toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` }); - } catch (e) { - toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` }); - } -} - -// ---------------------------------------------------------------------------------------- -// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the -// client's config (survives plugin reinstalls). -// ---------------------------------------------------------------------------------------- -export interface PinsApi { - pins: PinnedGame[]; - addPin: (h: Host, g: GameEntry) => void; - removePin: (hostFp: string, gameId: string) => void; - isPinned: (hostFp: string, gameId: string) => boolean; - /** Refresh a pin's stored address from a live advert (hosts change IPs). */ - updatePinHost: (pin: PinnedGame, h: Host) => void; - refresh: () => Promise; -} - -export function usePins(): PinsApi { - const [pins, setPins] = useState([]); - // A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a - // detached portal that captures this hook's callbacks ONCE and never re-renders with fresh - // props, so a mutator closing over the `pins` array reads a frozen base — pinning a second - // game in the same session would compute from the stale `[]` and clobber the first (silent - // data loss). Reading the ref keeps every mutation based on the current set, and lets the - // callbacks keep a stable identity (deps free of `pins`). - const pinsRef = useRef([]); - pinsRef.current = pins; - - 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[]) => { - pinsRef.current = next; - 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([ - ...pinsRef.current.filter( - (p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id), - ), - pin, - ]); - }, - [save], - ); - - const removePin = useCallback( - (hostFp: string, gameId: string) => { - save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId))); - }, - [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( - pinsRef.current.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, - ), - ); - }, - [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, - os: "", // pins don't store the chain; the icon is a hosts-tab affordance - }, - online: false, - }; -} diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 9d1c5a2a..6f795da5 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -1,46 +1,47 @@ -// Plugin entry: the Quick Access Menu panel + route registration. The fullscreen page lives -// in page.tsx; shared hooks/actions in hooks.ts; the Steam-shortcut launch in steam.ts. +// Plugin entry: the Quick Access Menu panel. That is the whole plugin now — the fullscreen +// route, the settings screen, the host editor and the games picker are gone, because the +// client's own console home does all four one shortcut away (and is gamepad-navigable, which +// a QAM panel re-implementing them never quite was). +// +// What is left is what only a Decky plugin can do: start a stream through Steam so gamescope +// focuses it (see steam.ts), and stand in front of the trust decision that gates it. import { ButtonItem, Field, - Navigation, PanelSection, PanelSectionRow, Spinner, showModal, staticClasses, } from "@decky/ui"; -import { definePlugin, routerHook, toaster } from "@decky/api"; +import { definePlugin, toaster } from "@decky/api"; import { FC } from "react"; import { FaDownload, FaLock, - FaLockOpen, FaPlay, FaPlus, + FaStopCircle, FaSyncAlt, FaTv, } from "react-icons/fa"; +import { killStream } from "./backend"; import { PluginErrorBoundary } from "./boundary"; import { applyUpdate, checkForUpdatesNow, clientUpdateIsManualOnly, hasUpdate, - mergeHosts, + HostView, needsPair, - pinIsOnline, startStream, - toHost, + trustState, useHosts, - usePins, - useSavedHosts, useUpdate, } from "./hooks"; -import { streamPin } from "./library"; -import { PunktfunkRoute, ROUTE } from "./page"; -import { PairModal } from "./pair"; -import { ensureGamepadUiShortcut, recreateShortcuts } from "./steam"; +import { OsMark } from "./os-icon"; +import { ensureGamepadUiShortcut, launchGamepadUi, recreateShortcuts, stopStream } from "./steam"; +import { TrustSheet } from "./trust"; // Recovery action for "the Punktfunk library entry vanished" — recreates the visible shortcut. // Deleting the shortcut (optionally + reinstalling the plugin) leaves a stale appId in Steam's @@ -54,22 +55,78 @@ async function recreatePunktfunkShortcut(): Promise { }); } -// ---------------------------------------------------------------------------------------- -// QAM panel — quick status + entry into the full page + one-tap stream for known hosts -// and pinned games. -// ---------------------------------------------------------------------------------------- -const QamPanel: FC = () => { - const { hosts: discovered, scanning, refresh: refreshDiscovered } = useHosts(); - const { saved, loading: loadingSaved, refresh: refreshSaved } = useSavedHosts(); - const { info: update, checking, check } = useUpdate(); - const pins = usePins(); +/** Force-stop a wedged stream: end Steam's "game", then make sure the client itself is gone. */ +async function forceStop(): Promise { + stopStream(); + try { + await killStream(); + } catch { + /* best-effort — the TerminateApp above is usually enough */ + } + toaster.toast({ title: "Punktfunk", body: "Stopped the stream" }); +} - const hosts = mergeHosts(saved, discovered); - const busy = scanning || loadingSaved; - const refresh = () => { - void refreshDiscovered(); - void refreshSaved(); - }; +/** The line under a host's name: where it is, whether it's up, and how far trust has got. */ +function hostDescription(v: HostView): string { + const trust = { + paired: "paired", + trusted: "trusted", + "needs-access": "needs access", + }[trustState(v)]; + return `${v.addr}:${v.port} · ${v.online ? "online" : "offline"} · ${trust}`; +} + +const HostRow: FC<{ host: HostView; refresh: () => void }> = ({ host, refresh }) => { + const gated = needsPair(host); + const stream = (opts: { requestAccess?: boolean } = {}) => void startStream(host, opts); + return ( + <> + + + gated + ? showModal( + , + ) + : stream() + } + label={ + + {gated ? : } + {host.name} + + } + description={hostDescription(host)} + > + {gated ? "Connect…" : "Stream"} + + + {/* Pinned cards, nested under their host rather than in a section of their own: a card + IS a (host, profile) pair, and a row that floats free of its host is the "a pinned + tile reads as a duplicate host" problem the desktop shells still have. The host's + own BOUND profile is deliberately not a card — it applies silently on the plain row + above, and showing it twice would suggest they do different things. */} + {!gated && + host.pinnedProfiles.map((p) => ( + + void startStream(host, { profileId: p.id }, `“${p.name}”`)} + label={`▸ ${p.name}`} + > + + Stream + + + ))} + + ); +}; + +const QamPanel: FC = () => { + const { views, scanning, outdated, refresh } = useHosts(); + const { info: update, checking, check } = useUpdate(); return ( <> @@ -110,15 +167,54 @@ const QamPanel: FC = () => { ))} + + + void refresh()} disabled={scanning}> + {scanning ? ( + + ) : ( + + )} + {scanning ? "Scanning…" : "Refresh"} + + + {/* A client too old for `punktfunk discover` explains itself rather than rendering an + empty list — "no hosts on your LAN" would be a lie, and the button that fixes it is + in this same panel. Saved hosts still list: that path is an older verb. */} + {outdated && ( + + + + )} + {views.length === 0 && scanning && ( + + + + )} + {views.length === 0 && !scanning && ( + + + + )} + {views.map((v) => ( + + ))} + + { - Navigation.Navigate(ROUTE); - Navigation.CloseSideMenus(); - }} + description="Settings, adding a host by address, and browsing a host's games all live here." + onClick={() => void launchGamepadUi()} > Open Punktfunk @@ -126,85 +222,6 @@ const QamPanel: FC = () => { - {/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's - picker (fullscreen page → host row → games button). */} - {pins.pins.length > 0 && ( - - {pins.pins.map((pin) => { - const online = pinIsOnline(pin, hosts); - return ( - - streamPin(pin, hosts.map(toHost), pins)} - label={pin.title} - description={`${pin.host_name}${online ? "" : " · offline?"}${ - pin.paired ? "" : " · pairing required" - }`} - > - - Stream - - - ); - })} - - )} - - - - - {busy ? ( - - ) : ( - - )} - {busy ? "Scanning…" : "Refresh"} - - - {hosts.length === 0 && busy && ( - - - - )} - {hosts.length === 0 && !busy && ( - - - - )} - {hosts.map((v) => { - const pair = needsPair(v); - const h = toHost(v); - return ( - - - pair - ? showModal( startStream(h)} />) - : startStream(h) - } - label={ - - {pair ? : } - {v.name} - - } - description={`${v.addr}:${v.port} · ${v.online ? "online" : "offline"}${ - pair ? " · pairing required" : v.paired ? " · paired" : "" - }`} - > - {pair ? "Pair & Stream" : "Stream"} - - - ); - })} - - { Recreate library shortcut + + void forceStop()} + > + + Force-stop + + ); }; export default definePlugin(() => { - routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); // Ensure the visible, stateless "Punktfunk" library entry (opens the gamepad UI / console // home) exists and is repointed to the current plugin dir — also installs the native-touch // controller config. Fire-and-forget: cosmetic library upkeep must never block plugin load. @@ -260,8 +286,5 @@ export default definePlugin(() => { ), icon: , - onDismount() { - routerHook.removeRoute(ROUTE); - }, }; }); diff --git a/clients/decky/src/pair.tsx b/clients/decky/src/pair.tsx index db40b1ed..9cdca655 100644 --- a/clients/decky/src/pair.tsx +++ b/clients/decky/src/pair.tsx @@ -3,10 +3,32 @@ import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui"; import { toaster } from "@decky/api"; import { FC, useState } from "react"; -import { Host, pair } from "./backend"; +import { pair } from "./backend"; +import { HostView } from "./hooks"; + +/** + * User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was, + * so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a + * mistyped PIN or a host nobody armed, and telling someone to check their network for that + * would send them the wrong way entirely. + */ +function pairErrorBody(error: string | undefined, name: string): string { + switch (error) { + case "refused": + return "Wrong PIN, or the host isn’t showing one. Arm pairing again and retry."; + case "unreachable": + return `Couldn’t reach ${name}.`; + case "client-outdated": + return "Update the Punktfunk client to pair from here."; + case "client-unavailable": + return "Couldn’t reach the Punktfunk client — is it still installed?"; + default: + return "Pairing failed."; + } +} export const PairModal: FC<{ - host: Host; + host: HostView; closeModal?: () => void; onPaired: () => void; }> = ({ host, closeModal, onPaired }) => { @@ -21,13 +43,13 @@ export const PairModal: FC<{ setBusy(true); setError(null); try { - const res = await pair(host.host, host.port, pin, "Steam Deck"); + const res = await pair(host.addr, host.port, pin, "Steam Deck"); if (res.ok) { toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` }); onPaired(); closeModal?.(); } else { - setError(res.error ?? "pairing failed"); + setError(pairErrorBody(res.error, host.name)); setPin(""); } } catch (e) { diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index 42e2e665..84fe5c77 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -8,16 +8,16 @@ // // TWO shortcuts, both named "Punktfunk" (so they share ONE Steam Input controller-config key — // see applyControllerConfig): -// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host / -// pinned game (PF_HOST/PF_LAUNCH/PF_BROWSE), rewritten per launch, so one shortcut serves -// every host. Driven by the QAM/pins/host-library actions. Hidden — an implementation detail. +// • STREAM — hidden, stateful: the per-session launcher. Its launch options carry the host +// reference and the card's profile (PF_REF/PF_PROFILE/PF_REQUEST_ACCESS), rewritten per +// launch, so one shortcut serves every host. Hidden — an implementation detail. // • GAMEPAD UI — visible, stateless: fixed launch options = bare `--browse` (PF_BROWSE, no // host) → the client's console home (host picker + pairing + settings, gamepad-navigable). // This is the library-visible "Punktfunk" app the user opens directly. // // Both get the shipped artwork and the native-touch controller config. -import { applyControllerConfig, runnerInfo, shortcutArt, wake } from "./backend"; +import { applyControllerConfig, runnerInfo, shortcutArt } 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 @@ -319,77 +319,65 @@ export async function launchGamepadUi(): Promise { } } -/** Per-launch extras beyond the host target (all optional — {} is the plain stream). */ +/** Per-launch extras beyond the host reference (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; + /** A pinned card: stream with this settings profile, one-off (PF_PROFILE → `--profile`). */ + profileId?: string; + /** + * Ask the host's operator to admit this Deck rather than typing a PIN (PF_REQUEST_ACCESS). + * The connect PARKS until somebody approves it, and the launch runs SUPERVISED — see the + * wrapper for why `--exec` is dropped on this path alone. + */ + requestAccess?: boolean; } -// Launch ids ride Steam launch options as an env-prefix token (`PF_LAUNCH=`), so they -// must be space/quote-free — Steam's tokenizer and the wrapper's env both break otherwise. -// Real ids are `steam:` / `custom:`, so this rejects nothing in practice; -// it's VALIDATION, never encoding (the host must match the opaque token verbatim). -const UNSAFE_LAUNCH_ID = /["'\\$`\s]/; +// Host refs and profile ids ride Steam launch options as env-prefix tokens (`PF_REF=`), +// so they must be space/quote-free — Steam's tokenizer and the wrapper's env both break +// otherwise. Real values are UUIDs or `addr:port`, so this rejects nothing in practice; it is +// VALIDATION, never encoding (the client must receive the opaque token verbatim). +const UNSAFE_TOKEN = /["'\\$`\s]/; export function isSafeLaunchId(id: string): boolean { return ( id.length > 0 && id.length <= 128 && - UNSAFE_LAUNCH_ID.exec(id) === null && + UNSAFE_TOKEN.exec(id) === null && /^[\x21-\x7e]+$/.test(id) ); } /** - * Launch a stream to `host:port` fullscreen in Gaming Mode (optionally straight into a - * library title, or into a host's gamepad library). Encodes the target into the STREAM - * shortcut's launch options (so one hidden shortcut serves every host and every pinned game), + * Stream `ref` fullscreen in Gaming Mode, optionally with a pinned card's profile. Encodes the + * target into the STREAM shortcut's launch options — one hidden shortcut serves every host — * then RunGame. + * + * No Wake-on-LAN here any more. The plugin used to fire a magic packet itself and then stretch + * the connect budget to 75 s to cover the host's resume, which was a workaround for the era + * before the CLI existed. `punktfunk launch` now runs the real wake-and-wait loop (packet at + * t=0, re-sent every 6 s, presence polled every second) and only dials once the host answers — + * strictly better, and it deletes a backend method, a frontend call and a shell branch. */ -export async function launchStream( - host: string, - port: number, - opts: LaunchOpts = {}, -): Promise { - // Wake-on-LAN: if this host is asleep, nudge it awake before the stream connects. Kicked off now - // so it races with the shortcut setup (near-zero added latency); its outcome is needed below - // (the connect budget), and RunGame follows the await either way, so nothing is slower for it. - // Best-effort — the flatpak client's --wake looks up the host's learned MAC (a no-op if none is - // known), and the connect that follows has its own retry window, so a failure never blocks launch. - const waking = wake(host, port).catch(() => ({ ok: false })); - const [{ appId, runner, clientBin }, woke] = await Promise.all([ensureStreamShortcut(), waking]); - const target = port && port !== 9777 ? `${host}:${port}` : host; - const env = [`PF_HOST=${target}`]; +export async function launchStream(ref: string, opts: LaunchOpts = {}): Promise { + if (!isSafeLaunchId(ref)) { + throw new Error(`unsupported host reference: ${ref}`); + } + if (opts.profileId && !isSafeLaunchId(opts.profileId)) { + throw new Error(`unsupported profile id: ${opts.profileId}`); + } + const { appId, runner, clientBin } = await ensureStreamShortcut(); + const env = [`PF_REF=${ref}`]; // Set only for a NATIVE client install; absent, the wrapper takes its flatpak default, so every // existing Deck install produces byte-identical launch options to before. if (clientBin) { env.push(`PF_CLIENT_BIN=${clientBin}`); } - // A magic packet actually went out (a MAC was known), so the host may be mid-resume from - // suspend — that takes far longer than the client's default 15 s connect budget. Stretch the - // budget so the client's wake-tolerant dial keeps retrying across the resume; against an - // already-awake host the connect still lands in under a second, so this costs nothing. - if (woke.ok) { - env.push("PF_CONNECT_TIMEOUT=75"); + if (opts.profileId) { + env.push(`PF_PROFILE=${opts.profileId}`); } - 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}`); + if (opts.requestAccess) { + env.push("PF_REQUEST_ACCESS=1"); } // KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper - // script rides behind it as an argument and reads PF_* from the environment. The wake was - // awaited above, so the magic packet is out before the connect attempt. + // script rides behind it as an argument and reads PF_* from the environment. SteamClient.Apps.SetAppLaunchOptions(appId, `${env.join(" ")} %command% "${runner}"`); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); } diff --git a/clients/decky/src/trust.tsx b/clients/decky/src/trust.tsx new file mode 100644 index 00000000..132b864e --- /dev/null +++ b/clients/decky/src/trust.tsx @@ -0,0 +1,140 @@ +// The trust sheet — the step between "I can see a host" and "I can stream it". +// +// Two ways in, in the order the GTK dialog and the console's pair screen offer them: +// +// • REQUEST ACCESS (default) — no PIN. Save the host with the fingerprint it ADVERTISED, +// then launch. The host parks that connect until its operator approves this Deck in the +// console or web UI, admits it, and the stream starts by itself. It is not a second +// pairing ceremony; it is an ordinary identified connect with a stretched budget, which +// is why it costs no ceremony surface here at all. +// • USE A PIN INSTEAD — the existing gamepad-navigable keypad (pair.tsx). +// +// NO FINGERPRINT, NO REQUEST ACCESS. The parked connect pins the advertised fingerprint, and +// that pin is the only thing standing between a 185 s wait and an impostor answering for the +// host. A host typed in by address advertises nothing, so it gets the PIN path only — and is +// told why, rather than being shown a button that could only fail. Under no circumstances does +// this sheet trust-on-first-use its way past a missing fingerprint. +import { DialogButton, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui"; +import { toaster } from "@decky/api"; +import { FC, useRef, useState } from "react"; +import { trustHost } from "./backend"; +import { HostView } from "./hooks"; +import { PairModal } from "./pair"; + +/** User-facing copy for a `trustHost` failure code. */ +function trustErrorBody(error: string | undefined, name: string): string { + switch (error) { + case "refused": + return `${name} is already saved under a different identity. Forget it in the Punktfunk app before trusting it again.`; + case "client-outdated": + return "Update the Punktfunk client to use request access."; + case "client-unavailable": + return "Couldn’t reach the Punktfunk client — is it still installed?"; + default: + return `Couldn’t save ${name}.`; + } +} + +export const TrustSheet: FC<{ + host: HostView; + closeModal?: () => void; + /** Stream this host, having just been let in. */ + onStream: (opts: { requestAccess?: boolean }) => void; + /** Re-read the host list — the record changed underneath the panel. */ + onChanged: () => void; +}> = ({ host, closeModal, onStream, onChanged }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // ⚠ This sheet is a `showModal` PORTAL: it captures its callbacks ONCE and never re-renders + // from panel state. Anything it needs to act on later must be read through a ref, not out of + // a captured value — reading a captured array is exactly what made pinning a second game + // compute from a stale base and clobber the first. + const props = useRef({ host, onStream, onChanged }); + props.current = { host, onStream, onChanged }; + + const canRequestAccess = host.fp !== ""; + + const requestAccess = async () => { + setBusy(true); + setError(null); + const { host: h, onStream: stream, onChanged: changed } = props.current; + try { + // Step 1: save it with the ADVERTISED fingerprint, pinned but unpaired ("trusted"). + // Idempotent, so a retry after a declined approval is free. + const r = await trustHost(h.addr, h.port, h.fp, h.name); + if (!r.ok) { + setError(trustErrorBody(r.error, h.name)); + setBusy(false); + return; + } + changed(); + // Step 2: the launch itself waits for the approval. The session's plain connecting screen + // looks identical whether it is parked or hanging, so say what is about to happen BEFORE + // it starts — this toast is a patch over that, and the real fix belongs in the session. + toaster.toast({ + title: "Punktfunk", + body: `Approve this Deck in ${h.name}’s console — the stream starts by itself`, + duration: 10_000, + }); + stream({ requestAccess: true }); + closeModal?.(); + } catch (e) { + setError(String(e)); + setBusy(false); + } + }; + + const usePin = () => { + // Hand off to the keypad. Closing first keeps one modal on screen at a time, which is what + // the gamepad focus model expects. + const { host: h, onStream: stream, onChanged: changed } = props.current; + closeModal?.(); + showModal( + { + changed(); + stream({}); + }} + />, + ); + }; + + return ( + +
+ Connect to {host.name} +
+
+ {canRequestAccess + ? `${host.name} needs to let this device in before it can stream.` + : "No advertised identity for this host — pair with a PIN instead."} +
+ {error && ( +
{error}
+ )} + + + {canRequestAccess && ( + + {busy ? : "Request access"} + + )} + + Use a PIN instead… + + closeModal?.()}> + Cancel + + + + {canRequestAccess && ( +
+ Request access asks {host.name}’s operator to approve this Deck in its console or web + UI. No PIN to type — the stream starts as soon as they do. +
+ )} +
+ ); +};