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: , }, ]} />
); }; // ---------------------------------------------------------------------------------------- // 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(); return ( <> {update?.update_available && ( applyUpdate(update)} label={`v${update.current} → v${update.latest}`} > Update punktfunk )} { Navigation.Navigate(ROUTE); Navigation.CloseSideMenus(); }} > Open punktfunk {scanning ? ( ) : ( )} {scanning ? "Scanning…" : "Refresh hosts"} {hosts.length === 0 && !scanning && ( No hosts found. )} {hosts.map((h) => { const needsPair = h.pair === "required" && !h.paired; return ( needsPair ? showModal( startStream(h)} />) : startStream(h) } label={ {needsPair ? : } {h.name} } description={`${h.host}:${h.port}${h.paired ? " · paired" : ""}`} > {needsPair ? "Pair & Stream" : "Stream"} ); })} ); }; // Full page behind the boundary — registered as the /punktfunk route. const PunktfunkRoute: FC = () => ( ); export default definePlugin(() => { routerHook.addRoute(ROUTE, PunktfunkRoute, { exact: true }); return { 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
, content: ( ), icon: , onDismount() { routerHook.removeRoute(ROUTE); }, }; });