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. +
+ )} +
+ ); +};