The panel captioned most rows with an IP address. The saved records were the source: `hosts add` falls back to the address when the pairing path knew nothing better, so `name` is literally "192.168.1.21" — and `mergeHosts` took `s.name || s.addr` unconditionally. The fallback only ever fired for an EMPTY name, so a name that was already a copy of the address sailed through as if it were meaningful, and the row printed the address twice: once as its title, once as its subtitle. The friendly name was in hand the whole time. The row is built by joining the saved record to the live advert, and that advert carries the host's actual hostname — the join was already trusted for address, port, online and OS, and only the name was read from the saved side alone. So treat a name equal to the record's own address as the placeholder it is and yield to the advert. A real saved name still wins, even when stale: it may be one the user chose, and an advert must never silently overwrite it. The comparison is against the SAVED address, so a host that moved DHCP lease still recognises its old address as a placeholder rather than mistaking it for a chosen name. Checked against the Deck that reported this, over its actual store and browse: three online rows turn into home-worker-5, ENRICOS-DESKTOP and steamdeck, the four offline ones keep their address (nothing is advertising a better name for them yet), and a user-chosen name survives a conflicting advert.
463 lines
19 KiB
TypeScript
463 lines
19 KiB
TypeScript
// Shared state hooks + user actions for the QAM panel.
|
||
import { toaster } from "@decky/api";
|
||
import { Navigation } from "@decky/ui";
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import {
|
||
checkUpdate,
|
||
discover,
|
||
DiscoveredHost,
|
||
hosts as listHosts,
|
||
Profile,
|
||
SavedHost,
|
||
updateClient,
|
||
UpdateInfo,
|
||
} from "./backend";
|
||
import { LaunchOpts, launchStream } from "./steam";
|
||
|
||
export const DOCS_URL = "https://docs.punktfunk.unom.io/docs/steam-deck";
|
||
|
||
// 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;
|
||
|
||
/**
|
||
* How far this device has got with a host. The three states are what the row says under the
|
||
* name, and which of them a host is in decides whether pressing it streams or opens the trust
|
||
* sheet.
|
||
*
|
||
* - `paired` — the host approved this device (a PIN ceremony, or request access).
|
||
* - `trusted` — its fingerprint is pinned but nobody has approved us yet. Streams work if
|
||
* the host's policy is `optional`; under `required` the connect parks.
|
||
* - `needs-access` — no pinned fingerprint. Not streamable until the trust sheet runs.
|
||
*/
|
||
export type TrustState = "paired" | "trusted" | "needs-access";
|
||
|
||
/**
|
||
* One host as the panel shows it — the union of the saved store and the live mDNS browse.
|
||
*
|
||
* A saved host is ONLINE when it either advertises or answers the reachability probe, so a box
|
||
* reached over Tailscale/VPN stops reading as offline. Discovered hosts that aren't saved are
|
||
* appended as extra rows.
|
||
*/
|
||
export interface HostView {
|
||
name: string;
|
||
addr: string;
|
||
port: number;
|
||
/**
|
||
* The fingerprint PINNED ON THE RECORD. "" means nothing is pinned, which is exactly what
|
||
* makes a host unstreamable — the session binary refuses a pinless connect.
|
||
*
|
||
* Deliberately NOT filled in from a live advert. A host saved by address that happens to be
|
||
* advertising right now still has an empty pin on disk, and borrowing the advert's here would
|
||
* draw it as ready to stream while every launch refused for want of a fingerprint. What the
|
||
* advert offers is [`advertisedFp`], and moving it onto the record is a trust decision the
|
||
* user makes in the sheet.
|
||
*/
|
||
fp: string;
|
||
/** What the host is advertising right now, if anything — what request access would pin. */
|
||
advertisedFp: string;
|
||
/**
|
||
* The host is answering at an address its record does not carry — it changed DHCP lease.
|
||
*
|
||
* This matters because a launch names the host by [`ref`], and the CLI dials whatever address
|
||
* the RECORD holds. So the row would show the live address and dial the dead one. The record
|
||
* has to be re-pointed before such a host can stream; `startStream` does it.
|
||
*/
|
||
moved: boolean;
|
||
paired: boolean;
|
||
online: boolean;
|
||
saved: boolean;
|
||
/** The advert's policy ("required"|"optional"); "" when the host isn't advertising. */
|
||
pairPolicy: string;
|
||
/** OS-identity chain (live advert preferred, else the stored one); "" unknown. */
|
||
os: string;
|
||
/**
|
||
* What a launch should NAME this host by: the record's stable id, which survives renames and
|
||
* DHCP moves, falling back to `addr:port` for a row that has no record yet (a discovered host
|
||
* the trust sheet is about to save, or a client too old to have minted ids).
|
||
*/
|
||
ref: string;
|
||
/** The host's default profile binding — applied silently by a plain connect, not a card. */
|
||
profile: Profile | null;
|
||
/** The cards to render nested under this host; already resolved against the catalog. */
|
||
pinnedProfiles: Profile[];
|
||
lastUsed: number | null;
|
||
}
|
||
|
||
export function trustState(v: HostView): TrustState {
|
||
if (v.paired) return "paired";
|
||
return v.fp ? "trusted" : "needs-access";
|
||
}
|
||
|
||
/**
|
||
* Must this host go through the trust sheet before it can stream?
|
||
*
|
||
* A pinned fingerprint is the ONLY rule. The session binary refuses a pinless connect, so a row
|
||
* without one can offer nothing but a button that fails; with one, the connect is verified and
|
||
* the host either admits it or parks it for an operator. The old rule also consulted the
|
||
* advertised policy for unsaved hosts, which made the answer depend on which of two lists a row
|
||
* came from — the same box could read differently before and after being saved.
|
||
*/
|
||
export function needsPair(v: HostView): boolean {
|
||
return v.fp === "";
|
||
}
|
||
|
||
function advertMatchesSaved(a: DiscoveredHost, s: SavedHost): boolean {
|
||
return (
|
||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||
(s.addr === a.addr && s.port === a.port)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The label a saved row shows.
|
||
*
|
||
* A saved record whose name IS its own address is a PLACEHOLDER, not a choice: `hosts add`
|
||
* falls back to the address when the pairing path had nothing better, so the row ends up
|
||
* captioned with the same string it already prints underneath. When the box is on the air it
|
||
* is advertising its actual hostname — prefer that, and the row reads "home-worker-5" instead
|
||
* of "192.168.1.21".
|
||
*
|
||
* A real saved name always wins over the advert, even a stale one: it may be a name the user
|
||
* chose, and a live advert must never quietly overwrite that. Compared against the SAVED
|
||
* address, so a host that moved DHCP lease still recognises its old address as a placeholder.
|
||
*/
|
||
function hostLabel(s: SavedHost, advert?: DiscoveredHost): string {
|
||
const placeholder = !s.name || s.name === s.addr || s.name === `${s.addr}:${s.port}`;
|
||
if (!placeholder) return s.name;
|
||
return advert?.name || s.name || s.addr;
|
||
}
|
||
|
||
/**
|
||
* Join the saved store and the live browse into the rows the panel draws.
|
||
*
|
||
* Fingerprint first, address second — 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's
|
||
* `discover` annotates `saved`/`paired` by exactly this rule too, so the two can't disagree.
|
||
*/
|
||
export function mergeHosts(saved: SavedHost[], discovered: DiscoveredHost[]): HostView[] {
|
||
const views: HostView[] = saved.map((s) => {
|
||
// Prefer a live advert's address: the host may have moved since it was last saved.
|
||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||
return {
|
||
name: hostLabel(s, advert),
|
||
addr: advert?.addr ?? s.addr,
|
||
port: advert?.port ?? s.port,
|
||
fp: s.fp_hex,
|
||
advertisedFp: advert?.fp ?? "",
|
||
moved: !!advert && (advert.addr !== s.addr || advert.port !== s.port),
|
||
paired: s.paired,
|
||
online: !!advert || s.online === true,
|
||
saved: true,
|
||
pairPolicy: advert?.pair ?? "",
|
||
os: advert?.os || s.os || "",
|
||
ref: s.id || `${advert?.addr ?? s.addr}:${advert?.port ?? s.port}`,
|
||
profile: s.profile,
|
||
pinnedProfiles: s.pinned_profiles ?? [],
|
||
lastUsed: s.last_used,
|
||
};
|
||
});
|
||
for (const a of discovered) {
|
||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||
continue; // already rendered as its saved row, with a live pip
|
||
}
|
||
views.push({
|
||
name: a.name,
|
||
addr: a.addr,
|
||
port: a.port,
|
||
// No record, so nothing is pinned — whatever it advertises is an OFFER, not a pin.
|
||
fp: "",
|
||
advertisedFp: a.fp,
|
||
moved: false, // no record, so nothing to be stale
|
||
paired: a.paired,
|
||
online: true,
|
||
saved: false,
|
||
pairPolicy: a.pair,
|
||
os: a.os,
|
||
ref: `${a.addr}:${a.port}`,
|
||
profile: null,
|
||
pinnedProfiles: [],
|
||
lastUsed: null,
|
||
});
|
||
}
|
||
return views.sort(sortRows);
|
||
}
|
||
|
||
/**
|
||
* Online first, then most recently used, then by name. The host you streamed last night should
|
||
* be the first thing under your thumb; a host that is off right now should never be.
|
||
*/
|
||
function sortRows(a: HostView, b: HostView): number {
|
||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||
if ((a.lastUsed ?? 0) !== (b.lastUsed ?? 0)) return (b.lastUsed ?? 0) - (a.lastUsed ?? 0);
|
||
return a.name.localeCompare(b.name);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// Hosts — ONE call site for both lists. They were separate hooks when the plugin had two
|
||
// views mounting them independently; the panel is the only view now, and merging them means
|
||
// the "scanning" state covers the whole row set rather than half of it flickering in first.
|
||
// ----------------------------------------------------------------------------------------
|
||
export function useHosts() {
|
||
const [views, setViews] = useState<HostView[]>([]);
|
||
const [scanning, setScanning] = useState(false);
|
||
// Why the list is empty, when it is empty for a reason other than an empty LAN. Rendering
|
||
// either of these as "No hosts yet" would blame the user's network for the plugin's problem:
|
||
// "client-outdated" — the installed client predates `punktfunk discover`
|
||
// "client-unavailable" — there is no client installed at all
|
||
const [problem, setProblem] = useState<string | null>(null);
|
||
|
||
const refresh = useCallback(async () => {
|
||
setScanning(true);
|
||
try {
|
||
// Both in flight at once: the browse is time-bounded and the probe is network-bound, so
|
||
// running them in sequence would cost the sum of two waits for no benefit.
|
||
const [d, s] = await Promise.all([discover(), listHosts()]);
|
||
// Both calls run the same binary, so they fail the same way; take whichever answered.
|
||
setProblem(
|
||
d.error === "client-unavailable" || s.error === "client-unavailable"
|
||
? "client-unavailable"
|
||
: d.error === "client-outdated" || s.error === "client-outdated"
|
||
? "client-outdated"
|
||
: null,
|
||
);
|
||
setViews(mergeHosts(s.hosts ?? [], d.hosts ?? []));
|
||
} catch (e) {
|
||
toaster.toast({ title: "Punktfunk", body: `Couldn't list hosts: ${e}` });
|
||
} finally {
|
||
setScanning(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
return { views, scanning, problem, refresh };
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// Self-update — checks our registry on mount (the backend caches for 30 min + is non-fatal
|
||
// offline); `check(true)` bypasses the cache for the explicit "Check for updates" button.
|
||
// ----------------------------------------------------------------------------------------
|
||
export function useUpdate() {
|
||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||
const [checking, setChecking] = useState(false);
|
||
|
||
const check = useCallback(async (force: boolean): Promise<UpdateInfo | null> => {
|
||
setChecking(true);
|
||
try {
|
||
const res = await checkUpdate(force);
|
||
setInfo(res);
|
||
return res;
|
||
} catch {
|
||
return null;
|
||
} finally {
|
||
setChecking(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void check(false);
|
||
}, [check]);
|
||
|
||
return { info, checking, check };
|
||
}
|
||
|
||
/** True when EITHER the plugin or the client has a pending update. */
|
||
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
|
||
return !!info && (info.update_available || info.client_update_available);
|
||
}
|
||
|
||
/**
|
||
* Can this Deck actually INSTALL the pending client update, or only tell you how?
|
||
*
|
||
* A flatpak and a one-tap-capable native install (the packaged root helper + the operator's
|
||
* group opt-in) get a button; a sysext, a nix profile, a source build or a box that hasn't
|
||
* opted in gets the command. Offering a button that can only fail is worse than saying so.
|
||
*/
|
||
export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boolean {
|
||
return (
|
||
!!info &&
|
||
info.client_update_available &&
|
||
(info.client_applier === "flatpak" || info.client_applier === "helper")
|
||
);
|
||
}
|
||
|
||
/** True when the only pending update is one this Deck can't apply itself. */
|
||
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
|
||
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
|
||
}
|
||
|
||
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
|
||
export async function checkForUpdatesNow(
|
||
check: (force: boolean) => Promise<UpdateInfo | null>,
|
||
): Promise<void> {
|
||
const res = await check(true);
|
||
let body: string;
|
||
if (!res || res.error === "fetch-failed") {
|
||
body = "Couldn’t reach the update server — are you online?";
|
||
} else if (hasUpdate(res)) {
|
||
const parts: string[] = [];
|
||
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
|
||
if (res.client_update_available) {
|
||
parts.push(res.client_latest ? `client ${res.client_latest}` : "client");
|
||
}
|
||
body = `Update available: ${parts.join(" + ")}.`;
|
||
if (clientUpdateIsManualOnly(res)) {
|
||
// Say the honest thing up front rather than letting the user find out at the button.
|
||
body += " The client updates outside Punktfunk on this install.";
|
||
}
|
||
} else if (res.client_error) {
|
||
// A failed CLIENT check must never read as "up to date" — that is the one wrong answer.
|
||
body =
|
||
res.client_error === "client-outdated"
|
||
? "Couldn’t check the client — it predates update checks. Update it once by hand."
|
||
: "Couldn’t check the client for updates.";
|
||
} else if (res.error === "update-channel-unknown") {
|
||
body = "Development build — plugin updates are disabled; the client is up to date.";
|
||
} else {
|
||
body = `You’re up to date (plugin v${res.current}).`;
|
||
}
|
||
toaster.toast({ title: "Punktfunk", body });
|
||
}
|
||
|
||
/** One line of user-facing copy for whatever `updateClient()` came back with. */
|
||
function clientUpdateResultBody(r: Awaited<ReturnType<typeof updateClient>>): string {
|
||
if (r.ok) {
|
||
if (r.staged) return "Client updated — reboot to finish.";
|
||
return r.updated ? "Client updated to the latest version." : "Client is already up to date.";
|
||
}
|
||
// "manual" is not a failure: the box simply can't install it, and `command` says how.
|
||
if (r.error === "manual") {
|
||
return r.command
|
||
? `This client updates outside Punktfunk. Run: ${r.command}`
|
||
: "This client updates outside Punktfunk — use the way you installed it.";
|
||
}
|
||
if (r.error === "timeout") return "Client update timed out — check the box and try again.";
|
||
if (r.error === "client-unavailable")
|
||
return "Couldn’t reach the client to update it — is it still installed?";
|
||
return `Client update failed${r.detail ? `: ${r.detail}` : r.error ? ` (${r.error})` : ""}.`;
|
||
}
|
||
|
||
/**
|
||
* Apply whichever updates are pending.
|
||
*
|
||
* The CLIENT goes first and is awaited, by whichever route its install supports — a user-scope
|
||
* `flatpak update`, or the packaged root helper via `punktfunk-client --apply-update`. An
|
||
* install neither can serve is not attempted at all: the user gets the command in a toast,
|
||
* because a button that can only fail teaches nothing.
|
||
*
|
||
* The PLUGIN goes last and is fire-and-forget: Decky's install RPC reinstalls and reloads the
|
||
* plugin, tearing this panel down before any result could arrive. `check` (when passed)
|
||
* refreshes the panel state after a client-only update so the "Update available" button clears.
|
||
*/
|
||
export async function applyUpdate(
|
||
info: UpdateInfo,
|
||
check?: (force: boolean) => Promise<UpdateInfo | null>,
|
||
): Promise<void> {
|
||
if (info.client_update_available && clientUpdateIsOneTap(info)) {
|
||
toaster.toast({
|
||
title: "Punktfunk",
|
||
// A package-manager run is not instant; say so before the wait, not after.
|
||
body:
|
||
info.client_applier === "helper"
|
||
? "Updating the client — this can take a few minutes…"
|
||
: "Updating the client…",
|
||
});
|
||
try {
|
||
const r = await updateClient();
|
||
toaster.toast({ title: "Punktfunk", body: clientUpdateResultBody(r) });
|
||
} catch {
|
||
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
|
||
}
|
||
} else if (info.client_update_available) {
|
||
// Nothing here can install it — hand over the one line that does, rather than a button
|
||
// that would fail. `client_opt_in` wins when joining the group is what's missing, since
|
||
// that is the step that turns this into a one-tap update from then on.
|
||
const line = info.client_opt_in || info.client_command;
|
||
toaster.toast({
|
||
title: "Punktfunk",
|
||
body: line
|
||
? `Client update available (${info.client_latest}). Run: ${line}`
|
||
: `A newer client (${info.client_latest}) is available — update it the way you installed it.`,
|
||
duration: 12_000,
|
||
});
|
||
}
|
||
|
||
if (info.update_available) {
|
||
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,
|
||
// The name Decky uninstalls before extracting the new zip — it locates the folder by
|
||
// matching plugin.json "name", so this must equal THIS build's plugin.json name (the
|
||
// brand-cased one), not the lowercase on-disk dir.
|
||
"Punktfunk",
|
||
info.latest,
|
||
info.hash,
|
||
INSTALL_TYPE_UPDATE,
|
||
);
|
||
toaster.toast({
|
||
title: "Punktfunk",
|
||
// Decky's installer also phones the plugin store first, which can hang on some
|
||
// networks before the actual install proceeds — set expectations.
|
||
body: `Updating the plugin to v${info.latest} — confirm Decky’s prompt. This can take a couple of minutes.`,
|
||
});
|
||
return;
|
||
}
|
||
} catch {
|
||
// fall through to the manual path
|
||
}
|
||
toaster.toast({
|
||
title: "Punktfunk",
|
||
body: "Update the plugin from Decky → Developer → Install Plugin from URL.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Client-only update (no plugin reinstall): refresh so the button clears.
|
||
if (check) void check(true);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// Stream launch — via the hidden Steam shortcut (see steam.ts for why it can't be direct).
|
||
// ----------------------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Stream this host. `opts.profileId` streams one of its pinned cards; `opts.requestAccess`
|
||
* runs the supervised launch that waits for the host's operator to approve this Deck.
|
||
*
|
||
* The host is named by REFERENCE (`v.ref`), never by value — no resolution, bitrate or codec
|
||
* ever rides the launch path, which is the same rule the deep-link grammar enforces.
|
||
*/
|
||
export async function startStream(
|
||
v: HostView,
|
||
opts: LaunchOpts = {},
|
||
label?: string,
|
||
): Promise<void> {
|
||
try {
|
||
await launchStream(v.ref, opts);
|
||
Navigation.CloseSideMenus();
|
||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${v.name}` });
|
||
} catch (e) {
|
||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||
}
|
||
}
|