What is left of the plugin is what only a Decky plugin can do: start a stream through Steam so gamescope focuses it, and stand in front of the trust decision that gates it. One Quick Access panel, four sections, no route. HOSTS. One `useHosts()` calls discover and hosts-list together and merges them by fingerprint first, address second — so a host that moved DHCP lease still matches its record, and a different box that inherited the old address does not inherit its pairing. The CLI annotates `saved`/`paired` by that same rule, so the two surfaces cannot disagree. Rows sort online first, then most recently used, then by name: the host you streamed last night is the first thing under your thumb, and a host that is off right now never is. `needsPair` is now ONE rule: no pinned fingerprint. The session binary refuses a pinless connect, so a row without one can offer nothing but a button that fails. The old rule also consulted the advertised policy for unsaved hosts, which made the same box read differently before and after being saved. PINNED CARDS render NESTED under their host as `▸ <Profile name>`, not in a section of their own — a card IS a (host, profile) pair, and a row floating free of its host is exactly the "a pinned tile reads as a duplicate host" problem the desktop shells still have. The host's own BOUND profile is deliberately not drawn as a card: it applies silently on the plain row, and showing it twice would suggest the two do different things. This plugin creates, edits and deletes no profile and no card — pin creation belongs where profiles are edited. TRUST SHEET (new, trust.tsx). Request access (default) / Use a PIN instead… / Cancel, in the GTK dialog's order and wording. Request access is not a second ceremony — it saves the host with the fingerprint it ADVERTISED, then launches; the host parks that connect until its operator approves this Deck, admits it, and the stream starts by itself. No fingerprint, no request access. A host typed in by address advertises none, so the sheet offers the PIN path only and says why, rather than showing a button that could only fail. The sheet never TOFUs past a missing fingerprint: that pin is the only thing standing between a 185 s wait and an impostor answering for the host. The sheet is a `showModal` portal, so it captures its callbacks once and never re-renders from panel state — everything it acts on later is read through a ref. Reading a captured value is precisely what made pinning a second game compute from a stale base and clobber the first. LAUNCH PATH. The wrapper's contract becomes PF_REF / PF_PROFILE / PF_REQUEST_ACCESS / PF_BROWSE; PF_HOST, PF_LAUNCH, PF_MGMT and PF_CONNECT_TIMEOUT are gone. A stream is now `punktfunk launch <ref> [--profile <id>] --exec --fullscreen`, and a reference is all that ever rides Steam's launch options — no resolution, bitrate or codec, the same rule the deep-link grammar enforces. Request-access launches run SUPERVISED, without `--exec`: under --exec the CLI becomes the session, so no process survives to see the stream come up and record the approval. Safe for gamescope because focus follows reaper's descendant tree, not a single process, and flatpak-run/bwrap already sit in that tree on every other path. Wake-on-LAN comes out entirely. The plugin used to fire a magic packet itself and then stretch the connect budget to 75 s to cover the host's resume — a workaround for the CLI-less era. `punktfunk launch` runs the real wake-and-wait loop and only dials once the host answers, which is strictly better and deletes a backend method, a frontend call and a shell branch. The console-home branch of the wrapper is untouched on purpose: the shell binary already execs the session for `--browse`, so there is nothing to repoint and no reason to spend a diff there. Everything else in steam.ts — two shortcuts sharing one name (and so one Steam Input configset key), artwork versioning, appId verification, controller config, stopStream — is unchanged.
114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
// 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 { pair } from "./backend";
|
||
import { HostView } from "./hooks";
|
||
|
||
/**
|
||
* User-facing copy for a failed ceremony. The CLI's stable exit codes say WHICH failure it was,
|
||
* so the keypad can name the fix instead of echoing a log line: `refused` is overwhelmingly a
|
||
* mistyped PIN or a host nobody armed, and telling someone to check their network for that
|
||
* would send them the wrong way entirely.
|
||
*/
|
||
function pairErrorBody(error: string | undefined, name: string): string {
|
||
switch (error) {
|
||
case "refused":
|
||
return "Wrong PIN, or the host isn’t showing one. Arm pairing again and retry.";
|
||
case "unreachable":
|
||
return `Couldn’t reach ${name}.`;
|
||
case "client-outdated":
|
||
return "Update the Punktfunk client to pair from here.";
|
||
case "client-unavailable":
|
||
return "Couldn’t reach the Punktfunk client — is it still installed?";
|
||
default:
|
||
return "Pairing failed.";
|
||
}
|
||
}
|
||
|
||
export const PairModal: FC<{
|
||
host: HostView;
|
||
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.addr, host.port, pin, "Steam Deck");
|
||
if (res.ok) {
|
||
toaster.toast({ title: "Punktfunk", body: `Paired with ${host.name}` });
|
||
onPaired();
|
||
closeModal?.();
|
||
} else {
|
||
setError(pairErrorBody(res.error, host.name));
|
||
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>
|
||
);
|
||
};
|