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
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:
@@ -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.
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user