feat(decky): plugin overhaul — on-Deck update check, exec-bit-free runner, About/host-detail UI, Punktfunk branding

Fixes from live debugging on the Deck:

- check_update() was dead on-device: Decky Loader's embedded (PyInstaller)
  Python has no usable default CA paths, so every HTTPS fetch failed with
  CERTIFICATE_VERIFY_FAILED. Build the SSL context explicitly: default paths
  first, then the known system bundles (SteamOS/Arch, Debian, Fedora/Bazzite,
  openSUSE), then certifi if importable. Verification stays on; the check
  stays offline-tolerant with its 30-min cache.
- "could not chmod runner" on every use: Decky extracts plugin zips without
  exec bits into a root-owned dir the unprivileged backend can't chmod. The
  Steam shortcut now launches the runner through /bin/sh with the script as a
  %command% argument — no exec bit needed, existing shortcuts migrate on
  reuse, the chmod attempt is gone.

UI/structure:

- index.tsx (660 lines) split into page/pair/settings/hooks/boundary modules;
  PluginErrorBoundary kept guarding every surface.
- New About section/tab: visible version + channel, explicit check-for-updates
  (forces past the cache, always toasts an outcome), setup-guide link, leave-
  chord help, and a Force-stop backstop for a wedged stream.
- Host rows open a details modal (address, protocol, pairing policy, paired
  state, fingerprint). Settings gain 1280×800 (Deck native), Xbox One and
  DualShock 4 pad types, and a host-compositor picker.
- Update flows note the Decky store contact can stall a couple of minutes on
  networks that blackhole plugins.deckbrew.xyz (observed live).
- "Punktfunk" in all user-facing strings; plugin id/paths/env unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:36:55 +00:00
parent 79dd8f58e3
commit fd699b3e2c
13 changed files with 927 additions and 613 deletions
+56 -558
View File
@@ -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<unknown>;
};
}
}
// 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 (
<div style={{ padding: "1em", lineHeight: 1.45 }}>
<div style={{ fontWeight: "bold", marginBottom: "0.4em" }}>
punktfunk couldnt draw this view
</div>
<div style={{ opacity: 0.8, marginBottom: "0.6em" }}>
The plugin hit a display error your Steam Deck is fine. Reload punktfunk from
Decky&apos;s plugin list, or update the plugin.
</div>
<div
style={{
opacity: 0.55,
fontFamily: "monospace",
fontSize: "0.8em",
wordBreak: "break-word",
}}
>
{String(error?.message ?? error)}
</div>
</div>
);
}
}
// Checks our registry for a newer build on mount (the backend caches + is non-fatal offline).
function useUpdate() {
const [info, setInfo] = useState<UpdateInfo | null>(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<Host[]>([]);
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<string | null>(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 (
<ModalRoot closeModal={closeModal}>
<div style={{ fontWeight: "bold", fontSize: "1.3em", marginBottom: "0.3em" }}>
Pair with {host.name}
</div>
<div style={{ opacity: 0.8, marginBottom: "1em" }}>
Arm pairing on the host (its console or web UI), then enter the 4-digit PIN it shows.
</div>
<div
style={{
fontSize: "2.2em",
letterSpacing: "0.4em",
textAlign: "center",
fontFamily: "monospace",
minHeight: "1.4em",
marginBottom: "0.6em",
}}
>
{pin.padEnd(4, "•")}
</div>
{error && (
<div style={{ color: "#ff6b6b", textAlign: "center", marginBottom: "0.6em" }}>
{error}
</div>
)}
<Focusable
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "0.5em",
}}
>
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
<DialogButton key={d} disabled={busy} onClick={() => press(d)}>
{d}
</DialogButton>
))}
<DialogButton disabled={busy} onClick={back}>
</DialogButton>
<DialogButton disabled={busy} onClick={() => press("0")}>
0
</DialogButton>
<DialogButton
disabled={busy || pin.length !== 4}
onClick={submit}
>
{busy ? <Spinner style={{ height: "1em" }} /> : "Pair"}
</DialogButton>
</Focusable>
</ModalRoot>
);
};
// ----------------------------------------------------------------------------------------
// 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<string, string> = {
auto: "Automatic",
xbox360: "Xbox 360",
dualsense: "DualSense",
steamdeck: "Steam Deck",
};
const SettingsSection: FC = () => {
const [s, setS] = useState<StreamSettings | null>(null);
useEffect(() => {
void getSettings().then(setS);
}, []);
const patch = (p: Partial<StreamSettings>) => {
setS((cur) => {
if (!cur) return cur;
const next = { ...cur, ...p };
void setSettings(next);
return next;
});
};
if (!s) return <Spinner style={{ height: "1.5em" }} />;
const resIdx = Math.max(
0,
RESOLUTIONS.findIndex(([w, h]) => w === s.width && h === s.height),
);
return (
<>
<Field
label="Resolution"
description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max"
>
<Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx}
onChange={(o) => {
const [w, h] = RESOLUTIONS[o.data as number];
patch({ width: w, height: h });
}}
/>
</Field>
<Field label="Refresh rate" childrenContainerWidth="max">
<Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })}
/>
</Field>
<SliderField
label="Bitrate"
description="Mbit/s · 0 = host default"
value={Math.round(s.bitrate_kbps / 1000)}
min={0}
max={150}
step={5}
showValue
valueSuffix=" Mbit/s"
onChange={(v) => patch({ bitrate_kbps: v * 1000 })}
/>
<Field label="Gamepad type" childrenContainerWidth="max">
<Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })}
/>
</Field>
{s.gamepad === "steamdeck" && (
<Field
label="⚠ Disable Steam Input"
description="Steam Deck mode forwards the paddles, both trackpads, and gyro to the host. For that, Steam Input must be OFF for punktfunk: on the game page tap ⚙ → Controller Settings → set Steam Input to Off. Otherwise Steam keeps the Deck's controls and only the sticks + buttons reach the host."
/>
)}
<ToggleField
label="Stream microphone"
checked={s.mic_enabled}
onChange={(v) => 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 (
<Field
label={
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
{needsPair ? <FaLock /> : <FaLockOpen />}
{host.name}
</span>
}
description={`${host.host}:${host.port}${
needsPair ? " · pairing required" : host.paired ? " · paired" : ""
}`}
childrenContainerWidth="max"
>
<Focusable style={{ display: "flex", gap: "0.5em" }}>
{needsPair && (
<DialogButton
style={{ minWidth: "5em" }}
onClick={() =>
showModal(<PairModal host={host} onPaired={() => {}} />)
}
>
Pair
</DialogButton>
)}
<DialogButton style={{ minWidth: "6em" }} onClick={() => startStream(host)}>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
</Focusable>
</Field>
);
};
// ----------------------------------------------------------------------------------------
// 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 }) => (
<div style={tabScroll}>
<Field
label="Discover"
description={
scanning
? "Scanning the LAN…"
: `${hosts.length} host${hosts.length === 1 ? "" : "s"} on your network`
}
childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"}
>
<DialogButton style={{ minWidth: "8em" }} disabled={scanning} onClick={refresh}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</DialogButton>
</Field>
{hosts.length === 0 && !scanning && (
<Field
focusable={false}
description="No punktfunk hosts found. Make sure a host is running on the same network."
>
No hosts found
</Field>
)}
{hosts.map((h) => (
<HostRow key={h.fp || `${h.host}:${h.port}`} host={h} />
))}
</div>
);
const SettingsTab: FC = () => (
<div style={tabScroll}>
<SettingsSection />
</div>
);
const PunktfunkPage: FC = () => {
const { hosts, scanning, refresh } = useHosts();
const update = useUpdate();
const [tab, setTab] = useState("hosts");
return (
<div
style={{
marginTop: "40px",
height: "calc(100% - 40px)",
display: "flex",
flexDirection: "column",
}}
>
<Focusable
style={{
display: "flex",
alignItems: "center",
gap: "1em",
padding: "0 2.5em",
marginBottom: "0.4em",
flexShrink: 0,
}}
>
<DialogButton
style={{ width: "3em", minWidth: "3em", padding: 0 }}
onClick={() => Navigation.NavigateBack()}
>
<FaArrowLeft />
</DialogButton>
<div className={staticClasses?.Title} style={{ flex: 1, margin: 0 }}>
punktfunk
</div>
{update?.update_available && (
<DialogButton style={{ minWidth: "9em" }} onClick={() => applyUpdate(update)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update v{update.latest}
</DialogButton>
)}
</Focusable>
<div style={{ flex: 1, minHeight: 0 }}>
<Tabs
activeTab={tab}
onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[
{
id: "hosts",
title: "Hosts",
content: <HostsTab hosts={hosts} scanning={scanning} refresh={refresh} />,
},
{
id: "settings",
title: "Settings",
content: <SettingsTab />,
},
]}
/>
</div>
</div>
);
};
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 && (
<PanelSection title="Update">
<PanelSection title="Update available">
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() => applyUpdate(update)}
label={`v${update.current} → v${update.latest}`}
description="Installing can take a couple of minutes"
>
<FaDownload style={{ marginRight: "0.5em" }} />
Update punktfunk
Update Punktfunk
</ButtonItem>
</PanelSectionRow>
</PanelSection>
)}
<PanelSection title="punktfunk">
<PanelSection title="Punktfunk">
<PanelSectionRow>
<ButtonItem
layout="below"
description="Host details, stream settings, and help"
onClick={() => {
Navigation.Navigate(ROUTE);
Navigation.CloseSideMenus();
}}
>
<FaTv style={{ marginRight: "0.5em" }} />
Open punktfunk
Open Punktfunk
</ButtonItem>
</PanelSectionRow>
</PanelSection>
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
{scanning ? (
@@ -593,15 +67,21 @@ const QamPanel: FC = () => {
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh hosts"}
{scanning ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
</PanelSection>
<PanelSection title="Hosts">
{hosts.length === 0 && scanning && (
<PanelSectionRow>
<Field focusable={false} description="Scanning your network…" />
</PanelSectionRow>
)}
{hosts.length === 0 && !scanning && (
<PanelSectionRow>
<Field focusable={false}>No hosts found.</Field>
<Field
focusable={false}
label="No hosts found"
description="Start a Punktfunk host on this network, then refresh."
/>
</PanelSectionRow>
)}
{hosts.map((h) => {
@@ -629,24 +109,42 @@ const QamPanel: FC = () => {
);
})}
</PanelSection>
<PanelSection title="About">
<PanelSectionRow>
<Field
focusable={false}
label="Version"
description={
update
? `v${update.current}${update.channel ? ` · ${update.channel}` : " · dev build"}`
: "…"
}
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
disabled={checking}
onClick={() => void checkForUpdatesNow(check)}
>
{checking ? "Checking…" : "Check for updates"}
</ButtonItem>
</PanelSectionRow>
</PanelSection>
</>
);
};
// Full page behind the boundary — registered as the /punktfunk route.
const PunktfunkRoute: FC = () => (
<PluginErrorBoundary>
<PunktfunkPage />
</PluginErrorBoundary>
);
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: <div className={staticClasses?.Title}>punktfunk</div>,
titleView: <div className={staticClasses?.Title}>Punktfunk</div>,
content: (
<PluginErrorBoundary>
<QamPanel />