diff --git a/clients/decky/README.md b/clients/decky/README.md index fa658f3..3967d26 100644 --- a/clients/decky/README.md +++ b/clients/decky/README.md @@ -1,7 +1,7 @@ -# punktfunk — Steam Deck plugin (Decky) +# Punktfunk — Steam Deck plugin (Decky) Stream to your **Steam Deck** without ever leaving Gaming Mode. This -**[Decky Loader](https://decky.xyz/)** plugin adds a **punktfunk** panel to the Quick Access Menu +**[Decky Loader](https://decky.xyz/)** plugin adds a **Punktfunk** panel to the Quick Access Menu (the `…` button): discover hosts on your network, pair with a PIN, tweak stream settings, and launch a fullscreen, gamescope-focused stream — all from the couch, gamepad-navigable. @@ -12,12 +12,16 @@ the panel looks and feels native to Gaming Mode. ## What it does -1. **Discover** — browses the LAN over mDNS for punktfunk hosts, in both the QAM panel and a - fullscreen page. +1. **Discover** — browses the LAN over mDNS for Punktfunk hosts, in both the QAM panel and a + fullscreen page; each host row opens a details view (address, pairing policy, certificate + fingerprint to cross-check against the host's log). 2. **Pair** — for a host that requires it, a gamepad-navigable PIN keypad runs the SPAKE2 pairing ceremony headlessly, then remembers the host so future streams connect silently. 3. **Stream** — launches fullscreen via a hidden Steam shortcut so gamescope focuses it. -4. **Settings** — resolution / refresh / bitrate / gamepad / mic, written to the client's config. +4. **Settings** — resolution / refresh / bitrate / gamepad type / host compositor / mic, written + to the client's config. +5. **About** — plugin version, an explicit "Check for updates" button, the setup-guide link, and + a force-stop for a wedged stream client. To leave a stream: the in-client controller chord (**L1 + R1 + Start + Select**), or close the "game" from the Steam overlay — either returns you to Gaming Mode. @@ -37,8 +41,10 @@ https://git.unom.io/api/packages/unom/generic/punktfunk-decky/latest/punktfunk.z ``` (or a pinned `.../punktfunk-decky//punktfunk.zip`). The plugin then **self-updates** without -the Decky store — when a newer build exists, an **Update to vX** button appears and drives Decky -Loader's own (SHA-256-verified) install. +the Decky store — when a newer build exists, an **Update** button appears and drives Decky +Loader's own (SHA-256-verified) install. Installs and updates can take a couple of minutes on some +networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed +before the actual download proceeds. ## Build & sideload (development) @@ -58,20 +64,18 @@ restart is required for an out-of-band install to appear. | File | Role | | --- | --- | -| `src/index.tsx` | Frontend: QAM panel + the `/punktfunk` fullscreen page (host list, PIN keypad, settings). | -| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. | +| `src/index.tsx` | Plugin entry: the QAM panel + route registration. | +| `src/page.tsx` | The `/punktfunk` fullscreen page — Hosts (with per-host details) / Settings / About tabs. | +| `src/settings.tsx` · `src/pair.tsx` | Stream-settings section; the gamepad-navigable PIN-pairing modal. | +| `src/hooks.ts` · `src/boundary.tsx` | Shared discovery/update hooks + actions; the render error boundary. | +| `src/steam.ts` | Steam-shortcut launch (`AddShortcut` / `SetAppLaunchOptions` / `RunGame`) — the focus-correct stream start. The shortcut's exe is `/bin/sh` with the wrapper passed as an argument, so the script never needs an exec bit (Decky's zip extraction drops it and the root-owned plugins dir can't be chmodded by the unprivileged backend). | | `src/backend.ts` | Typed `callable` bridges to `main.py`. | -| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut targets (so the window is focusable). | -| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update`. | +| `bin/punktfunkrun.sh` | The launch wrapper the Steam shortcut runs (so the window is focusable). | +| `main.py` | Backend: `discover` (via `avahi-browse`) / `pair` / settings / `kill_stream` / `check_update` (with an explicit CA-bundle search — Decky's embedded Python has no usable default TLS roots on SteamOS). | | `plugin.json` · `update.json` | Decky manifest; CI-baked update channel. | -The client binary is resolved `PATH` → `/usr/bin` → `/usr/local/bin` → `~/.local/bin` → a -`flatpak run io.unom.Punktfunk` fallback, so the flatpak install always works. - ## Limitations / next steps -- **Needs on-Deck validation in Gaming Mode** — the Steam-shortcut launch and headless pairing follow - MoonDeck's proven pattern but are verified only at build time here. - No manual "add host by IP" entry yet (discovery is mDNS-only). - No in-stream overlay inside the plugin — the client owns the session once launched. - Pairing needs the operator to **arm pairing on the host** so it shows the PIN; the plugin can't arm diff --git a/clients/decky/bin/punktfunkrun.sh b/clients/decky/bin/punktfunkrun.sh index 81b4474..3acab0a 100755 --- a/clients/decky/bin/punktfunkrun.sh +++ b/clients/decky/bin/punktfunkrun.sh @@ -18,6 +18,11 @@ # # 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. +# +# NO EXEC BIT REQUIRED: the Steam shortcut's exe is `/bin/sh` and this script rides behind +# `%command%` as an argument (see src/steam.ts). Decky extracts plugin zips without preserving +# permission bits and ~/homebrew/plugins is root-owned (the unprivileged plugin backend can't +# chmod), so the launch path must never depend on +x. Keep this script POSIX-sh clean. set -u APPID="${PF_APPID:-io.unom.Punktfunk}" diff --git a/clients/decky/main.py b/clients/decky/main.py index b4c33f8..edc687c 100644 --- a/clients/decky/main.py +++ b/clients/decky/main.py @@ -29,7 +29,6 @@ import json import os import shutil import ssl -import stat import time import urllib.request from pathlib import Path @@ -125,13 +124,68 @@ def _semver_tuple(v: str) -> tuple[int, int, int]: return (parts[0], parts[1], parts[2]) +# Decky Loader ships its own embedded (PyInstaller) Python whose compiled-in OpenSSL default +# verify paths don't exist on SteamOS — ``ssl.create_default_context()`` then trusts NOTHING +# and every HTTPS fetch dies with CERTIFICATE_VERIFY_FAILED (seen live on the Deck). Fix: find +# a real CA bundle on disk and load it explicitly. Verification is NEVER disabled — if no +# bundle exists the fetch just fails, and check_update() is non-fatal by design. +_CA_BUNDLES = ( + "/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian / Ubuntu + "/etc/ssl/cert.pem", # Arch/openssl compat symlink + "/etc/pki/tls/certs/ca-bundle.crt", # Fedora / Bazzite + "/etc/ssl/ca-bundle.pem", # openSUSE +) +_ssl_context_cache: ssl.SSLContext | None = None + + +def _build_ssl_context() -> ssl.SSLContext: + """A verifying SSLContext that actually has CA roots under Decky's embedded Python.""" + ctx = ssl.create_default_context() # honors SSL_CERT_FILE / SSL_CERT_DIR when set + if ctx.cert_store_stats().get("x509_ca", 0): + return ctx # the interpreter found its own roots (e.g. a system python) + + dvp = ssl.get_default_verify_paths() + candidates: list[str | None] = [dvp.cafile, dvp.openssl_cafile, *_CA_BUNDLES] + try: # not shipped by Decky's runtime, but honor it when importable + import certifi + + candidates.append(certifi.where()) + except ImportError: + pass + + tried: set[str] = set() + for cafile in candidates: + if not cafile or cafile in tried or not Path(cafile).is_file(): + continue + tried.add(cafile) + try: + ctx.load_verify_locations(cafile=cafile) + except (ssl.SSLError, OSError): + continue + if ctx.cert_store_stats().get("x509_ca", 0): + decky.logger.info("TLS roots loaded from %s", cafile) + return ctx + + decky.logger.warning( + "no CA bundle found — HTTPS update checks will fail certificate verification" + ) + return ctx + + +def _ssl_context() -> ssl.SSLContext: + """The (cached) context for registry fetches; building it scans disk, so do it once.""" + global _ssl_context_cache + if _ssl_context_cache is None: + _ssl_context_cache = _build_ssl_context() + return _ssl_context_cache + + def _fetch_json(url: str, timeout: float = 8.0) -> dict: """Blocking HTTPS GET of a small JSON document (run in an executor).""" req = urllib.request.Request( url, headers={"Accept": "application/json", "User-Agent": "punktfunk-decky"} ) - ctx = ssl.create_default_context() - with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp: return json.loads(resp.read().decode("utf-8", errors="replace")) @@ -319,13 +373,10 @@ class Plugin: async def runner_info(self) -> dict: """The wrapper-script path + flatpak app id the frontend needs to create the Steam - shortcut. Also (re)asserts the script's exec bit — packaging can drop it.""" + shortcut. The shortcut invokes the script through ``/bin/sh`` (see steam.ts), so no + exec bit is needed — Decky's zip extraction drops it, and the root-owned plugins dir + means this unprivileged backend couldn't chmod it back on anyway.""" path = _runner_path() - try: - st = os.stat(path) - os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - except OSError: - decky.logger.warning("could not chmod runner %s", path) return {"runner": path, "app_id": APP_ID, "exists": Path(path).exists()} async def get_settings(self) -> dict: diff --git a/clients/decky/package.json b/clients/decky/package.json index f7db3bb..2a72a53 100644 --- a/clients/decky/package.json +++ b/clients/decky/package.json @@ -1,14 +1,15 @@ { "name": "punktfunk-decky", "version": "0.0.1", - "description": "SteamOS / Steam Deck Gaming-Mode launcher for the punktfunk streaming client.", + "description": "SteamOS / Steam Deck Gaming-Mode launcher for the Punktfunk streaming client.", "type": "module", "scripts": { "build": "rollup -c", "watch": "rollup -c -w", + "typecheck": "tsc --noEmit --skipLibCheck", "package": "pnpm build && bash scripts/package.sh", "deploy": "bash scripts/deploy.sh", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "pnpm typecheck" }, "keywords": [ "decky", diff --git a/clients/decky/plugin.json b/clients/decky/plugin.json index 473278c..9f17972 100644 --- a/clients/decky/plugin.json +++ b/clients/decky/plugin.json @@ -5,7 +5,7 @@ "api_version": 1, "publish": { "tags": ["streaming", "game-streaming", "remote-play"], - "description": "Launch the punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS and connect to one.", + "description": "Launch the Punktfunk low-latency streaming client from Gaming Mode: discover hosts on the LAN over mDNS, pair with a PIN, and stream.", "image": "https://opengraph.githubassets.com/1/SteamDeckHomebrew/PluginLoader" } } diff --git a/clients/decky/src/backend.ts b/clients/decky/src/backend.ts index 34d066c..3e21645 100644 --- a/clients/decky/src/backend.ts +++ b/clients/decky/src/backend.ts @@ -6,7 +6,8 @@ export interface Host { host: string; port: number; pair: string; // "required" | "optional" — the HOST's policy - fp: string; + 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) } @@ -22,12 +23,15 @@ export interface RunnerInfo { exists: boolean; } +// The slice of the flatpak client's settings JSON this UI surfaces. The file can hold more +// keys (codec, decoder, … set from the desktop client's own UI) — they round-trip untouched +// because get_settings returns the whole parsed file and patches are object spreads. export interface StreamSettings { width: number; // 0 = native height: number; // 0 = native refresh_hz: number; // 0 = native bitrate_kbps: number; // 0 = host default - gamepad: string; // "auto" | "xbox360" | "dualsense" + gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck" compositor: string; // "auto" | "kwin" | "wlroots" | "mutter" | "gamescope" inhibit_shortcuts: boolean; mic_enabled: boolean; diff --git a/clients/decky/src/boundary.tsx b/clients/decky/src/boundary.tsx new file mode 100644 index 0000000..bcfdc7a --- /dev/null +++ b/clients/decky/src/boundary.tsx @@ -0,0 +1,51 @@ +// Error boundary — contains ANY render failure in our UI so a single bad render can never take +// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic +// "Something went wrong while displaying this content" for the entire tab when one plugin +// throws). The realistic trigger is a future Steam client update that makes a @decky/ui +// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback +// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a +// (possibly broken) Steam-internal component — it is guaranteed to render. +import { Component, ErrorInfo, ReactNode } from "react"; + +export class PluginErrorBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state: { error: Error | null } = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // Surface it for diagnosis, but never rethrow — containment is the whole point. + // eslint-disable-next-line no-console + console.error("[punktfunk] contained UI render error:", error, info?.componentStack); + } + + render() { + const { error } = this.state; + if (!error) return this.props.children; + return ( +
+
+ Punktfunk couldn’t draw this view +
+
+ The plugin hit a display error — your Steam Deck is fine. Reload Punktfunk from + Decky's plugin list, or update the plugin. +
+
+ {String(error?.message ?? error)} +
+
+ ); + } +} diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts new file mode 100644 index 0000000..eb089d0 --- /dev/null +++ b/clients/decky/src/hooks.ts @@ -0,0 +1,139 @@ +// Shared state hooks + user actions for the QAM panel and the fullscreen page. +import { toaster } from "@decky/api"; +import { Navigation } from "@decky/ui"; +import { useCallback, useEffect, useState } from "react"; +import { checkUpdate, discover, Host, UpdateInfo } from "./backend"; +import { launchStream } from "./steam"; + +export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck"; + +// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of +// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a +// loader without it we fall back to manual "Install Plugin from URL". We use it to drive +// Decky's own privileged install path (the root loader does the download + SHA-256 verify + +// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins +// is root-owned, so our unprivileged backend can't swap its own files. +declare global { + interface Window { + DeckyBackend?: { + callable: (route: string) => (...args: unknown[]) => Promise; + }; + } +} + +// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…). +const INSTALL_TYPE_UPDATE = 2; + +// ---------------------------------------------------------------------------------------- +// Discovery — mDNS scan state shared by the QAM panel and the full page. +// ---------------------------------------------------------------------------------------- +export function useHosts() { + const [hosts, setHosts] = useState([]); + const [scanning, setScanning] = useState(false); + + const refresh = useCallback(async () => { + setScanning(true); + try { + setHosts(await discover()); + } catch (e) { + toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` }); + } finally { + setScanning(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { hosts, scanning, refresh }; +} + +// ---------------------------------------------------------------------------------------- +// Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal +// offline); `check(true)` bypasses the cache for the explicit "Check for updates" button. +// ---------------------------------------------------------------------------------------- +export function useUpdate() { + const [info, setInfo] = useState(null); + const [checking, setChecking] = useState(false); + + const check = useCallback(async (force: boolean): Promise => { + setChecking(true); + try { + const res = await checkUpdate(force); + setInfo(res); + return res; + } catch { + return null; + } finally { + setChecking(false); + } + }, []); + + useEffect(() => { + void check(false); + }, [check]); + + return { info, checking, check }; +} + +/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */ +export async function checkForUpdatesNow( + check: (force: boolean) => Promise, +): Promise { + const res = await check(true); + let body: string; + if (!res || res.error === "fetch-failed") { + body = "Couldn’t reach the update server — are you online?"; + } else if (res.error === "update-channel-unknown") { + body = "Development build — update checks are disabled."; + } else if (res.update_available) { + body = `Update available: v${res.current} → v${res.latest}.`; + } else { + body = `You’re up to date (v${res.current}).`; + } + toaster.toast({ title: "Punktfunk", body }); +} + +export async function applyUpdate(info: UpdateInfo): Promise { + try { + const backend = window.DeckyBackend; + if (backend?.callable) { + // Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down + // before any result could arrive — so never await it. Decky shows its own confirm prompt. + void backend.callable("utilities/install_plugin")( + info.artifact, + "punktfunk", + info.latest, + info.hash, + INSTALL_TYPE_UPDATE, + ); + toaster.toast({ + title: "Punktfunk", + // Decky's installer also phones the plugin store first, which can hang on some + // networks before the actual install proceeds — set expectations. + body: `Updating to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`, + }); + return; + } + } catch { + // fall through to the manual path + } + toaster.toast({ + title: "Punktfunk", + body: "Update from Decky → Developer → Install Plugin from URL.", + }); +} + +// ---------------------------------------------------------------------------------------- +// Stream launch — via the hidden Steam shortcut (see steam.ts for why). +// ---------------------------------------------------------------------------------------- +export async function startStream(h: Host): Promise { + try { + await launchStream(h.host, h.port); + Navigation.CloseSideMenus(); + toaster.toast({ title: "Punktfunk", body: `Starting stream — ${h.name}` }); + } catch (e) { + toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` }); + } +} diff --git a/clients/decky/src/index.tsx b/clients/decky/src/index.tsx index 733617c..0272e3b 100644 --- a/clients/decky/src/index.tsx +++ b/clients/decky/src/index.tsx @@ -1,591 +1,65 @@ +// 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. import { ButtonItem, - Dropdown, Field, - Focusable, - DialogButton, - ModalRoot, Navigation, PanelSection, PanelSectionRow, - SliderField, Spinner, - Tabs, - ToggleField, showModal, staticClasses, } from "@decky/ui"; -import { definePlugin, routerHook, toaster } from "@decky/api"; -import { - Component, - CSSProperties, - ErrorInfo, - FC, - ReactNode, - useCallback, - useEffect, - useState, -} from "react"; -import { - FaTv, - FaSyncAlt, - FaLock, - FaLockOpen, - FaPlay, - FaArrowLeft, - FaDownload, -} from "react-icons/fa"; -import { - discover, - getSettings, - pair, - setSettings, - checkUpdate, - Host, - StreamSettings, - UpdateInfo, -} from "./backend"; -import { launchStream } from "./steam"; - -const ROUTE = "/punktfunk"; - -// Decky Loader exposes its already-authenticated WSRouter as a global. This is NOT part of -// @decky/api (it's a loader internal), so we treat it as optional and guard every use — on a -// loader without it we fall back to manual "Install Plugin from URL". We use it to drive -// Decky's own privileged install path (the root loader does the download + SHA-256 verify + -// extract + hot-reload), which is the only way a plugin can update itself: ~/homebrew/plugins -// is root-owned, so our unprivileged backend can't swap its own files. -declare global { - interface Window { - DeckyBackend?: { - callable: (route: string) => (...args: unknown[]) => Promise; - }; - } -} - -// PluginInstallType.UPDATE in decky-loader's browser.py (INSTALL=0/REINSTALL=1/UPDATE=2/…). -const INSTALL_TYPE_UPDATE = 2; - -// ---------------------------------------------------------------------------------------- -// Error boundary — contains ANY render failure in our UI so a single bad render can never take -// down the whole Quick Access "Decky" section (Decky's tab-level boundary shows the generic -// "Something went wrong while displaying this content" for the entire tab when one plugin -// throws). The realistic trigger is a future Steam client update that makes a @decky/ui -// component resolve to `undefined` (React then throws "Element type is invalid"). The fallback -// is built from ONLY plain DOM elements + inline styles, so it cannot itself depend on a -// (possibly broken) Steam-internal component — it is guaranteed to render. -// ---------------------------------------------------------------------------------------- -class PluginErrorBoundary extends Component< - { children: ReactNode }, - { error: Error | null } -> { - state: { error: Error | null } = { error: null }; - - static getDerivedStateFromError(error: Error) { - return { error }; - } - - componentDidCatch(error: Error, info: ErrorInfo) { - // Surface it for diagnosis, but never rethrow — containment is the whole point. - // eslint-disable-next-line no-console - console.error("[punktfunk] contained UI render error:", error, info?.componentStack); - } - - render() { - const { error } = this.state; - if (!error) return this.props.children; - return ( -
-
- punktfunk couldn’t draw this view -
-
- The plugin hit a display error — your Steam Deck is fine. Reload punktfunk from - Decky's plugin list, or update the plugin. -
-
- {String(error?.message ?? error)} -
-
- ); - } -} - -// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline). -function useUpdate() { - const [info, setInfo] = useState(null); - useEffect(() => { - void checkUpdate(false) - .then(setInfo) - .catch(() => {}); - }, []); - return info; -} - -async function applyUpdate(info: UpdateInfo) { - try { - const backend = window.DeckyBackend; - if (backend?.callable) { - // Fire-and-forget: the loader reinstalls + reloads THIS plugin, tearing the panel down - // before any result could arrive — so never await it. Decky shows its own confirm prompt. - void backend.callable("utilities/install_plugin")( - info.artifact, - "punktfunk", - info.latest, - info.hash, - INSTALL_TYPE_UPDATE, - ); - toaster.toast({ - title: "punktfunk", - body: `Updating to v${info.latest}… confirm the Decky prompt.`, - }); - return; - } - } catch { - // fall through to the manual path - } - toaster.toast({ - title: "punktfunk", - body: "Update from Decky → Developer → Install Plugin from URL.", - }); -} - -// ---------------------------------------------------------------------------------------- -// Discovery hook — shared by the QAM panel and the full page. -// ---------------------------------------------------------------------------------------- -function useHosts() { - const [hosts, setHosts] = useState([]); - const [scanning, setScanning] = useState(false); - - const refresh = useCallback(async () => { - setScanning(true); - try { - setHosts(await discover()); - } catch (e) { - toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` }); - } finally { - setScanning(false); - } - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - - return { hosts, scanning, refresh }; -} - -async function startStream(h: Host) { - try { - await launchStream(h.host, h.port); - Navigation.CloseSideMenus(); - toaster.toast({ title: "punktfunk", body: `Starting stream — ${h.name}` }); - } catch (e) { - toaster.toast({ title: "punktfunk", body: `Launch failed: ${e}` }); - } -} - -// ---------------------------------------------------------------------------------------- -// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode). -// The host displays the PIN after the operator arms pairing; the user enters it here. -// ---------------------------------------------------------------------------------------- -const PairModal: FC<{ - host: Host; - closeModal?: () => void; - onPaired: () => void; -}> = ({ host, closeModal, onPaired }) => { - const [pin, setPin] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d)); - const back = () => setPin((p) => p.slice(0, -1)); - - const submit = async () => { - setBusy(true); - setError(null); - try { - const res = await pair(host.host, 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"); - setPin(""); - } - } catch (e) { - setError(String(e)); - } finally { - setBusy(false); - } - }; - - return ( - -
- Pair with {host.name} -
-
- Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows. -
- -
- {pin.padEnd(4, "•")} -
- {error && ( -
- {error} -
- )} - - - {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => ( - press(d)}> - {d} - - ))} - - ⌫ - - press("0")}> - 0 - - - {busy ? : "Pair"} - - -
- ); -}; - -// ---------------------------------------------------------------------------------------- -// Settings section — resolution / refresh / bitrate / gamepad, written to the client's JSON. -// ---------------------------------------------------------------------------------------- -const RESOLUTIONS: [number, number, string][] = [ - [0, 0, "Native display"], - [1280, 720, "1280 × 720"], - [1920, 1080, "1920 × 1080"], - [2560, 1440, "2560 × 1440"], -]; -const REFRESH = [0, 30, 60, 90, 120]; -const GAMEPADS = ["auto", "xbox360", "dualsense", "steamdeck"]; -const GAMEPAD_LABELS: Record = { - auto: "Automatic", - xbox360: "Xbox 360", - dualsense: "DualSense", - steamdeck: "Steam Deck", -}; - -const SettingsSection: FC = () => { - const [s, setS] = useState(null); - - useEffect(() => { - void getSettings().then(setS); - }, []); - - const patch = (p: Partial) => { - setS((cur) => { - if (!cur) return cur; - const next = { ...cur, ...p }; - void setSettings(next); - return next; - }); - }; - - if (!s) return ; - - const resIdx = Math.max( - 0, - RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height), - ); - - return ( - <> - - ({ data: i, label }))} - selectedOption={resIdx} - onChange={(o) => { - const [w, h] = RESOLUTIONS[o.data as number]; - patch({ width: w, height: h }); - }} - /> - - - ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} - selectedOption={s.refresh_hz} - onChange={(o) => patch({ refresh_hz: o.data as number })} - /> - - patch({ bitrate_kbps: v * 1000 })} - /> - - ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} - selectedOption={s.gamepad} - onChange={(o) => patch({ gamepad: o.data as string })} - /> - - {s.gamepad === "steamdeck" && ( - - )} - patch({ mic_enabled: v })} - /> - - ); -}; - -// ---------------------------------------------------------------------------------------- -// One host row on the full page. -// ---------------------------------------------------------------------------------------- -const HostRow: FC<{ host: Host }> = ({ host }) => { - // The host's policy is `pair=required`, but if THIS device is already paired we don't need to - // pair again — show it as trusted and go straight to Stream. - const needsPair = host.pair === "required" && !host.paired; - return ( - - {needsPair ? : } - {host.name} - - } - description={`${host.host}:${host.port}${ - needsPair ? " · pairing required" : host.paired ? " · paired" : "" - }`} - childrenContainerWidth="max" - > - - {needsPair && ( - - showModal( {}} />) - } - > - Pair - - )} - startStream(host)}> - - Stream - - - - ); -}; - -// ---------------------------------------------------------------------------------------- -// The fullscreen page (registered as the /punktfunk route) — a tabbed Hosts / Settings view. -// ---------------------------------------------------------------------------------------- - -// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render -// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The -// value is generous on purpose (and harmless where the tab area already insets); tune to taste. -const SAFE_BOTTOM = "80px"; - -// Each tab is its own scroll area so long content is always reachable above the footer. -const tabScroll: CSSProperties = { - height: "100%", - overflowY: "auto", - padding: "0.5em 2.5em", - paddingBottom: SAFE_BOTTOM, - boxSizing: "border-box", -}; - -const HostsTab: FC<{ - hosts: Host[]; - scanning: boolean; - refresh: () => void; -}> = ({ hosts, scanning, refresh }) => ( -
- - - {scanning ? ( - - ) : ( - - )} - {scanning ? "Scanning…" : "Refresh"} - - - - {hosts.length === 0 && !scanning && ( - - No hosts found - - )} - {hosts.map((h) => ( - - ))} -
-); - -const SettingsTab: FC = () => ( -
- -
-); - -const PunktfunkPage: FC = () => { - const { hosts, scanning, refresh } = useHosts(); - const update = useUpdate(); - const [tab, setTab] = useState("hosts"); - - return ( -
- - Navigation.NavigateBack()} - > - - -
- punktfunk -
- {update?.update_available && ( - applyUpdate(update)}> - - Update v{update.latest} - - )} -
- -
- setTab(id)} - autoFocusContents - tabs={[ - { - id: "hosts", - title: "Hosts", - content: , - }, - { - id: "settings", - title: "Settings", - content: , - }, - ]} - /> -
-
- ); -}; +import { definePlugin, routerHook } from "@decky/api"; +import { FC } from "react"; +import { FaDownload, FaLock, FaLockOpen, FaSyncAlt, FaTv } from "react-icons/fa"; +import { PluginErrorBoundary } from "./boundary"; +import { applyUpdate, checkForUpdatesNow, startStream, useHosts, useUpdate } from "./hooks"; +import { PunktfunkRoute, ROUTE } from "./page"; +import { PairModal } from "./pair"; // ---------------------------------------------------------------------------------------- // QAM panel — quick status + entry into the full page + one-tap stream for known hosts. // ---------------------------------------------------------------------------------------- const QamPanel: FC = () => { const { hosts, scanning, refresh } = useHosts(); - const update = useUpdate(); + const { info: update, checking, check } = useUpdate(); return ( <> {update?.update_available && ( - + applyUpdate(update)} label={`v${update.current} → v${update.latest}`} + description="Installing can take a couple of minutes" > - Update punktfunk + Update Punktfunk )} - + { Navigation.Navigate(ROUTE); Navigation.CloseSideMenus(); }} > - Open punktfunk + Open Punktfunk + + + {scanning ? ( @@ -593,15 +67,21 @@ const QamPanel: FC = () => { ) : ( )} - {scanning ? "Scanning…" : "Refresh hosts"} + {scanning ? "Scanning…" : "Refresh"} - - - + {hosts.length === 0 && scanning && ( + + + + )} {hosts.length === 0 && !scanning && ( - No hosts found. + )} {hosts.map((h) => { @@ -629,24 +109,42 @@ const QamPanel: FC = () => { ); })} + + + + + + + void checkForUpdatesNow(check)} + > + {checking ? "Checking…" : "Check for updates"} + + + ); }; -// Full page behind the boundary — registered as the /punktfunk route. -const PunktfunkRoute: FC = () => ( - - - -); - export default definePlugin(() => { routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); return { + // `name` is the plugin's INTERNAL id — it must stay in sync with plugin.json (the loader + // keys plugins by it), so it stays lowercase; user-facing strings say "Punktfunk". name: "punktfunk", // `staticClasses?.Title` is guarded so a future client that drops the export can't throw // at plugin-load time (an error boundary only catches render-time, not load-time, errors). - titleView:
punktfunk
, + titleView:
Punktfunk
, content: ( diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx new file mode 100644 index 0000000..074fd26 --- /dev/null +++ b/clients/decky/src/page.tsx @@ -0,0 +1,338 @@ +// The fullscreen page (registered as the /punktfunk route) — Hosts / Settings / About tabs. +import { + DialogButton, + Field, + Focusable, + ModalRoot, + Navigation, + Spinner, + Tabs, + showModal, + staticClasses, +} from "@decky/ui"; +import { toaster } from "@decky/api"; +import { CSSProperties, FC, useState } from "react"; +import { + FaArrowLeft, + FaDownload, + FaExternalLinkAlt, + FaInfoCircle, + FaLock, + FaLockOpen, + FaPlay, + FaSyncAlt, +} from "react-icons/fa"; +import { Host, UpdateInfo, killStream } from "./backend"; +import { PluginErrorBoundary } from "./boundary"; +import { + DOCS_URL, + applyUpdate, + checkForUpdatesNow, + startStream, + useHosts, + useUpdate, +} from "./hooks"; +import { PairModal } from "./pair"; +import { SettingsSection } from "./settings"; +import { stopStream } from "./steam"; + +export const ROUTE = "/punktfunk"; + +// Bottom inset so the last control clears Gaming Mode's footer hint bar. Routed pages render +// *under* that bar otherwise — that's why the last Stream-settings row was getting hidden. The +// value is generous on purpose (and harmless where the tab area already insets); tune to taste. +const SAFE_BOTTOM = "80px"; + +// Each tab is its own scroll area so long content is always reachable above the footer. +const tabScroll: CSSProperties = { + height: "100%", + overflowY: "auto", + padding: "0.5em 2.5em", + paddingBottom: SAFE_BOTTOM, + boxSizing: "border-box", +}; + +// ---------------------------------------------------------------------------------------- +// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check +// against the host's own log / web console before trusting it. +// ---------------------------------------------------------------------------------------- +const HostDetailsModal: FC<{ host: Host; closeModal?: () => void }> = ({ + host, + closeModal, +}) => { + const fp = host.fp ? (host.fp.match(/.{1,4}/g) ?? [host.fp]).join(" ") : "not advertised"; + return ( + +
+ {host.name} +
+ + {host.host}:{host.port} + + + {host.proto || "unknown"} + + + {host.pair === "required" ? "PIN pairing required" : "Open (trust on first connect)"} + + + {host.paired ? "Paired" : "Not paired yet"} + + + {fp} + + } + /> +
+ ); +}; + +// ---------------------------------------------------------------------------------------- +// One host row: status icon + address, details / pair / stream actions. +// ---------------------------------------------------------------------------------------- +const HostRow: FC<{ host: Host; onPaired: () => void }> = ({ host, onPaired }) => { + // The host's policy is `pair=required`, but if THIS device is already paired we don't need to + // pair again — show it as trusted and go straight to Stream. + const needsPair = host.pair === "required" && !host.paired; + return ( + + {needsPair ? : } + {host.name} + + } + description={`${host.host}:${host.port}${ + needsPair ? " · pairing required" : host.paired ? " · paired" : "" + }`} + childrenContainerWidth="max" + > + + showModal()} + > + + + {needsPair && ( + showModal()} + > + Pair + + )} + startStream(host)}> + + Stream + + + + ); +}; + +const HostsTab: FC<{ + hosts: Host[]; + scanning: boolean; + refresh: () => void; +}> = ({ hosts, scanning, refresh }) => ( +
+ + + {scanning ? ( + + ) : ( + + )} + {scanning ? "Scanning…" : "Refresh"} + + + + {hosts.length === 0 && !scanning && ( + + )} + {hosts.map((h) => ( + + ))} +
+); + +const SettingsTab: FC = () => ( +
+ +
+); + +// ---------------------------------------------------------------------------------------- +// About — plugin version + explicit update check, docs link, stream-exit help, force-stop. +// ---------------------------------------------------------------------------------------- +async function forceStopStream(): Promise { + stopStream(); // ask Steam to end the "game" first (clean path) + const res = await killStream(); // then the flatpak-level hammer for a wedged client + toaster.toast({ + title: "Punktfunk", + body: res.ok ? "Stream client stopped." : "Couldn’t stop the stream client.", + }); +} + +const AboutTab: FC<{ + update: UpdateInfo | null; + checking: boolean; + check: (force: boolean) => Promise; +}> = ({ update, checking, check }) => ( +
+ + void checkForUpdatesNow(check)} + > + {checking ? : "Check for updates"} + + + {update?.update_available && ( + + applyUpdate(update)}> + + Update + + + )} + + Navigation.NavigateToExternalWeb(DOCS_URL)} + > + + Open + + + + + void forceStopStream()}> + Force-stop + + +
+); + +const PunktfunkPage: FC = () => { + const { hosts, scanning, refresh } = useHosts(); + const { info: update, checking, check } = useUpdate(); + const [tab, setTab] = useState("hosts"); + + return ( +
+ + Navigation.NavigateBack()} + > + + +
+ Punktfunk +
+ {update?.update_available && ( + applyUpdate(update)}> + + Update v{update.latest} + + )} +
+ +
+ setTab(id)} + autoFocusContents + tabs={[ + { + id: "hosts", + title: "Hosts", + content: , + }, + { + id: "settings", + title: "Settings", + content: , + }, + { + id: "about", + title: "About", + content: , + }, + ]} + /> +
+
+ ); +}; + +// Full page behind the boundary — registered as the /punktfunk route. +export const PunktfunkRoute: FC = () => ( + + + +); diff --git a/clients/decky/src/pair.tsx b/clients/decky/src/pair.tsx new file mode 100644 index 0000000..db40b1e --- /dev/null +++ b/clients/decky/src/pair.tsx @@ -0,0 +1,91 @@ +// PIN pairing modal — a gamepad-navigable digit grid (the OSK is unreliable in Gaming Mode). +// The host displays the PIN after the operator arms pairing; the user enters it here. +import { DialogButton, Focusable, ModalRoot, Spinner } from "@decky/ui"; +import { toaster } from "@decky/api"; +import { FC, useState } from "react"; +import { Host, pair } from "./backend"; + +export const PairModal: FC<{ + host: Host; + closeModal?: () => void; + onPaired: () => void; +}> = ({ host, closeModal, onPaired }) => { + const [pin, setPin] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const press = (d: string) => setPin((p) => (p.length >= 4 ? p : p + d)); + const back = () => setPin((p) => p.slice(0, -1)); + + const submit = async () => { + setBusy(true); + setError(null); + try { + const res = await pair(host.host, 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"); + setPin(""); + } + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + return ( + +
+ Pair with {host.name} +
+
+ Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows. +
+ +
+ {pin.padEnd(4, "•")} +
+ {error && ( +
+ {error} +
+ )} + + + {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => ( + press(d)}> + {d} + + ))} + + ⌫ + + press("0")}> + 0 + + + {busy ? : "Pair"} + + +
+ ); +}; diff --git a/clients/decky/src/settings.tsx b/clients/decky/src/settings.tsx new file mode 100644 index 0000000..4060846 --- /dev/null +++ b/clients/decky/src/settings.tsx @@ -0,0 +1,127 @@ +// Stream settings — resolution / refresh / bitrate / gamepad / compositor / mic, written to +// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The +// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`. +import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui"; +import { FC, useEffect, useState } from "react"; +import { getSettings, setSettings, StreamSettings } from "./backend"; + +const RESOLUTIONS: [number, number, string][] = [ + [0, 0, "Native display"], + [1280, 720, "1280 × 720"], + [1280, 800, "1280 × 800 (Deck)"], + [1920, 1080, "1920 × 1080"], + [2560, 1440, "2560 × 1440"], +]; +const REFRESH = [0, 30, 60, 90, 120]; +const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"]; +const GAMEPAD_LABELS: Record = { + auto: "Automatic", + xbox360: "Xbox 360", + xboxone: "Xbox One", + dualsense: "DualSense", + dualshock4: "DualShock 4", + steamdeck: "Steam Deck", +}; +const COMPOSITORS = ["auto", "kwin", "wlroots", "mutter", "gamescope"]; +const COMPOSITOR_LABELS: Record = { + auto: "Automatic", + kwin: "KDE Plasma (KWin)", + wlroots: "Sway (wlroots)", + mutter: "GNOME (Mutter)", + gamescope: "gamescope", +}; + +export const SettingsSection: FC = () => { + const [s, setS] = useState(null); + + useEffect(() => { + void getSettings().then(setS); + }, []); + + const patch = (p: Partial) => { + setS((cur) => { + if (!cur) return cur; + const next = { ...cur, ...p }; + void setSettings(next); + return next; + }); + }; + + if (!s) return ; + + const resIdx = Math.max( + 0, + RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height), + ); + + return ( + <> + + ({ data: i, label }))} + selectedOption={resIdx} + onChange={(o) => { + const [w, h] = RESOLUTIONS[o.data as number]; + patch({ width: w, height: h }); + }} + /> + + + ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} + selectedOption={s.refresh_hz} + onChange={(o) => patch({ refresh_hz: o.data as number })} + /> + + patch({ bitrate_kbps: v * 1000 })} + /> + + ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} + selectedOption={s.gamepad} + onChange={(o) => patch({ gamepad: o.data as string })} + /> + + {s.gamepad === "steamdeck" && ( + + )} + + ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))} + selectedOption={s.compositor} + onChange={(o) => patch({ compositor: o.data as string })} + /> + + patch({ mic_enabled: v })} + /> + + ); +}; diff --git a/clients/decky/src/steam.ts b/clients/decky/src/steam.ts index a94bb64..06634c5 100644 --- a/clients/decky/src/steam.ts +++ b/clients/decky/src/steam.ts @@ -3,9 +3,10 @@ // THE LAUNCH MECHANISM (verified against MoonDeck): gamescope only gives focus/fullscreen to // the window tree Steam launched via `reaper` (it detects the "current app" by AppID — see // gamescope#484). So we cannot launch the flatpak from the plugin backend; we register ONE -// hidden non-Steam shortcut that points at our wrapper script (bin/punktfunkrun.sh), pass the -// per-session host as the shortcut's Steam launch options, and start it with RunGame. The -// wrapper then execs `flatpak run io.unom.Punktfunk --connect ` as a reaper descendant. +// hidden non-Steam shortcut whose exe is `/bin/sh` running our wrapper script +// (bin/punktfunkrun.sh), pass the per-session host as the shortcut's Steam launch options, +// and start it with RunGame. The wrapper then execs +// `flatpak run io.unom.Punktfunk --connect ` as a reaper descendant. import { runnerInfo } from "./backend"; @@ -49,7 +50,15 @@ function hideShortcut(appId: number): void { setTimeout(attempt, 2500); // fresh shortcut: retry once its app overview lands } -const SHORTCUT_NAME = "punktfunk"; +// The shortcut name is user-visible (Steam overlay + library while streaming) — brand-case it. +const SHORTCUT_NAME = "Punktfunk"; + +// The shortcut's exe is /bin/sh, NOT the script itself: Decky extracts plugin zips without +// preserving the exec bit, and ~/homebrew/plugins is root-owned so the unprivileged plugin +// backend can't chmod it back on. Passing the script as an argument to the always-executable +// shell removes the +x dependency entirely. SteamOS /bin/sh is bash; the wrapper is plain +// POSIX sh regardless. +const SHELL = "/bin/sh"; // The 64-bit "gameid" RunGame wants, derived from a 32-bit non-Steam shortcut appId: the // standard non-Steam-game encoding (appid << 32 | 0x02000000). MoonDeck/decky tools use this. @@ -78,39 +87,34 @@ function recallAppId(): number | null { } /** - * Ensure exactly one hidden "punktfunk" shortcut exists pointing at the wrapper script, and - * return its appId. Reuses the remembered one when its exe still matches the current runner - * path (the plugin dir can change across reinstalls). + * Ensure exactly one hidden "Punktfunk" shortcut exists (exe = /bin/sh; the wrapper script is + * appended per-launch via the launch options), and return its appId + the current runner path. + * Reuses the remembered shortcut, re-pointing it each time — the plugin dir can change across + * reinstalls, and pre-0.4 shortcuts pointed at the script directly and relied on its exec bit. */ -async function ensureShortcut(): Promise { +async function ensureShortcut(): Promise<{ appId: number; runner: string }> { const info = await runnerInfo(); if (!info.exists) { throw new Error(`launch wrapper missing at ${info.runner}`); } + const startDir = info.runner.replace(/\/[^/]*$/, ""); // the plugin's bin/ dir const remembered = recallAppId(); if (remembered != null) { - // Re-point the existing shortcut at the current runner path (cheap + idempotent). - SteamClient.Apps.SetShortcutExe(remembered, info.runner); - SteamClient.Apps.SetShortcutStartDir( - remembered, - info.runner.replace(/\/[^/]*$/, ""), - ); - return remembered; + // Re-point + rename the existing shortcut (cheap + idempotent — migrates old installs). + SteamClient.Apps.SetShortcutExe(remembered, SHELL); + SteamClient.Apps.SetShortcutStartDir(remembered, startDir); + SteamClient.Apps.SetShortcutName(remembered, SHORTCUT_NAME); + return { appId: remembered, runner: info.runner }; } - const appId = await SteamClient.Apps.AddShortcut( - SHORTCUT_NAME, - info.runner, - info.runner.replace(/\/[^/]*$/, ""), // start dir = the bin/ dir - "", - ); + const appId = await SteamClient.Apps.AddShortcut(SHORTCUT_NAME, SHELL, startDir, ""); SteamClient.Apps.SetShortcutName(appId, SHORTCUT_NAME); // Hide it from the library — it's an implementation detail, launched programmatically. // Best-effort + deferred (see hideShortcut); never let it block the launch. hideShortcut(appId); rememberAppId(appId); - return appId; + return { appId, runner: info.runner }; } /** @@ -138,13 +142,14 @@ function disableSteamInputForShortcut(appId: number): void { * shortcut's launch options (so one generic shortcut serves every host), then RunGame. */ export async function launchStream(host: string, port: number): Promise { - const appId = await ensureShortcut(); + const { appId, runner } = await ensureShortcut(); // Best-effort so the Deck's rich controls reach the client; no-op if the API is absent (the user // disables Steam Input manually — see the Settings instruction). disableSteamInputForShortcut(appId); const target = port && port !== 9777 ? `${host}:${port}` : host; - // KEY=value ... %command% — the wrapper reads PF_HOST from the environment. - SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command%`); + // KEY=value ... %command% args — %command% expands to the shortcut exe (/bin/sh); the wrapper + // script rides behind it as an argument and reads PF_HOST from the environment. + SteamClient.Apps.SetAppLaunchOptions(appId, `PF_HOST=${target} %command% "${runner}"`); SteamClient.Apps.RunGame(gameIdFromAppId(appId), "", -1, 100); }