feat(clients/decky): SteamOS Gaming-Mode launcher plugin (spike)
ci / rust (push) Successful in 2m7s
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 29s
apple / swift (push) Successful in 1m15s
ci / bench (push) Successful in 1m35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m52s
docker / deploy-docs (push) Successful in 16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m23s

A Decky Loader plugin so a Steam Deck / SteamOS box can launch the punktfunk
client from Gaming Mode using REAL Steam UI components (it runs inside Steam's
CEF, so the panel is built from @decky/ui — the literal Big Picture primitives,
not a replica).

- Frontend (src/index.tsx, @decky/api + @decky/ui): a Quick Access Menu panel —
  Refresh → discover hosts, a native list (name, ip:port, pairing flag), tap to
  connect with a status toast, Disconnect.
- Backend (main.py): discover() shells `avahi-browse -rpt _punktfunk._udp` and
  parses the host's advertised TXT keys (proto/fp/pair/id from discovery.rs),
  dedup by id preferring IPv4; connect() resolves + spawns
  `punktfunk-client --connect host:port` (gamescope composites its video like a
  game), tracking the child; disconnect() terminates it.
- Mirrors the current official Decky template (the API moved to @decky/ui +
  @decky/api). Frontend builds clean (pnpm build → dist/index.js); main.py
  py_compiles. dist/ + node_modules gitignored — build on the Deck per README.

Spike scope: launcher only, runtime untested (no Deck here). Next on this track:
the in-stream Quick-Access overlay (volume/disconnect/stats over the running
stream) and a fuller real-components UI. Client decode on the AMD Deck is the
existing VAAPI path; the host-encode VAAPI gap is separate (NVIDIA host = NVENC).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 12:50:57 +00:00
parent c64816c70a
commit b3f98a5d7d
10 changed files with 2704 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
import {
ButtonItem,
Field,
PanelSection,
PanelSectionRow,
Spinner,
} from "@decky/ui";
import {
callable,
definePlugin,
toaster,
} from "@decky/api";
import { useEffect, useState } from "react";
import { FaTv, FaSyncAlt, FaStop, FaLock, FaLockOpen } from "react-icons/fa";
// ---- Backend bridge (see main.py) ----
interface Host {
name: string;
host: string;
port: number;
pair: string; // "required" | "optional"
fp: string;
}
interface ConnectResult {
ok: boolean;
host: string | null;
error?: string;
}
interface Status {
connected: boolean;
host: string | null;
}
const discover = callable<[], Host[]>("discover");
const connect = callable<[host: string, port: number], ConnectResult>("connect");
const disconnect = callable<[], { ok: boolean; host: string | null }>("disconnect");
const getStatus = callable<[], Status>("status");
function Content() {
const [hosts, setHosts] = useState<Host[]>([]);
const [scanning, setScanning] = useState(false);
const [busyHost, setBusyHost] = useState<string | null>(null);
const [connectedHost, setConnectedHost] = useState<string | null>(null);
const refresh = async () => {
setScanning(true);
try {
const found = await discover();
setHosts(found);
toaster.toast({
title: "punktfunk",
body:
found.length === 0
? "No hosts found on the LAN"
: `Found ${found.length} host${found.length === 1 ? "" : "s"}`,
});
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Discovery failed: ${e}` });
} finally {
setScanning(false);
}
};
const onConnect = async (h: Host) => {
const target = `${h.host}:${h.port}`;
setBusyHost(target);
try {
const res = await connect(h.host, h.port);
if (res.ok) {
setConnectedHost(res.host);
toaster.toast({ title: "punktfunk", body: `Connecting to ${h.name}` });
} else {
toaster.toast({
title: "punktfunk",
body:
res.error === "client-not-found"
? "punktfunk-client is not installed"
: `Connect failed: ${res.error ?? "unknown"}`,
});
}
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Connect failed: ${e}` });
} finally {
setBusyHost(null);
}
};
const onDisconnect = async () => {
try {
await disconnect();
setConnectedHost(null);
toaster.toast({ title: "punktfunk", body: "Disconnected" });
} catch (e) {
toaster.toast({ title: "punktfunk", body: `Disconnect failed: ${e}` });
}
};
// On panel open: sync the current connection status and do an initial scan.
useEffect(() => {
getStatus()
.then((s) => setConnectedHost(s.connected ? s.host : null))
.catch(() => {});
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<PanelSection title="Status">
<PanelSectionRow>
<Field label="State" focusable={false}>
{connectedHost ? `Connected — ${connectedHost}` : "Idle"}
</Field>
</PanelSectionRow>
{connectedHost && (
<PanelSectionRow>
<ButtonItem layout="below" onClick={onDisconnect}>
<FaStop style={{ marginRight: "0.5em" }} />
Disconnect
</ButtonItem>
</PanelSectionRow>
)}
</PanelSection>
<PanelSection title="Hosts">
<PanelSectionRow>
<ButtonItem layout="below" onClick={refresh} disabled={scanning}>
{scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : (
<FaSyncAlt style={{ marginRight: "0.5em" }} />
)}
{scanning ? "Scanning…" : "Refresh"}
</ButtonItem>
</PanelSectionRow>
{hosts.length === 0 && !scanning && (
<PanelSectionRow>
<Field focusable={false}>No hosts discovered yet.</Field>
</PanelSectionRow>
)}
{hosts.map((h) => {
const target = `${h.host}:${h.port}`;
const isBusy = busyHost === target;
const pairRequired = h.pair === "required";
return (
<PanelSectionRow key={h.fp || target}>
<ButtonItem
layout="below"
disabled={isBusy}
onClick={() => onConnect(h)}
label={
<span>
{pairRequired ? (
<FaLock style={{ marginRight: "0.4em" }} />
) : (
<FaLockOpen style={{ marginRight: "0.4em" }} />
)}
{h.name}
</span>
}
description={`${target}${pairRequired ? " · pairing required" : ""}`}
>
{isBusy ? "Connecting…" : "Connect"}
</ButtonItem>
</PanelSectionRow>
);
})}
</PanelSection>
</>
);
}
export default definePlugin(() => {
return {
name: "punktfunk",
titleView: <div>punktfunk</div>,
content: <Content />,
icon: <FaTv />,
onDismount() {
// The backend tears the client down on _unload; nothing frontend-side to clean up.
},
};
});