Files
punktfunk/clients/decky/src/backend.ts
T
enricobuehler 017c37b78a feat(decky): rebuild the panel as a launcher — nested cards and request access
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.
2026-08-04 20:41:30 +02:00

189 lines
8.4 KiB
TypeScript

// Bridge to the Python backend (main.py) + shared types.
//
// Every call here is a thin shell over the headless `punktfunk` CLI, so these types are the
// CLI's JSON shapes rather than anything this plugin invents. That is deliberate: the plugin
// used to model the client's stores itself and drifted from them with every field the client
// added.
import { callable } from "@decky/api";
/** A settings profile as the CLI resolves it — ids are dangling-checked and names attached. */
export interface Profile {
id: string;
name: string;
}
/**
* A host answering on mDNS right now (`punktfunk discover --json`).
*
* `saved`/`paired` are annotated BY THE CLI against the saved-hosts store — fingerprint first,
* address second. The plugin does not join the two lists itself; that rule living in one place
* is what stops this surface disagreeing with the desktop client about the same box.
*/
export interface DiscoveredHost {
name: string;
addr: string;
port: number;
fp: string; // advertised cert fingerprint (lowercase hex); "" when not advertised
pair: string; // the HOST's policy: "required" | "optional"
id: string; // the host's advertised stable id; "" when not advertised
mgmt: number; // management-API port; 0 = not advertised
os: string; // OS-identity chain, e.g. "linux/fedora/bazzite"; "" on older hosts
saved: boolean;
paired: boolean;
}
/**
* A host in the shared saved-hosts store (`punktfunk hosts list --probe --json`) — the same
* `client-known-hosts.json` the desktop client owns.
*
* `online` comes from a mDNS-INDEPENDENT probe, so a host reached over Tailscale/VPN is not
* shown offline merely because it never advertises; `null` means the probe was skipped.
*
* `profile` is the host's DEFAULT binding, which a plain connect applies silently. It is not
* the same thing as `pinned_profiles`, which are the cards a user chose to surface. Both come
* back already resolved against the profile catalog, so this plugin never opens it.
*/
export interface SavedHost {
id: string | null; // the record's stable id — the reference a launch should use
name: string;
addr: string;
port: number;
fp_hex: string; // "" for a placeholder saved by address with no pin yet
paired: boolean;
mac: string[];
os: string;
last_used: number | null;
clipboard_sync: boolean;
profile: Profile | null;
pinned_profiles: Profile[];
online: boolean | null;
}
/**
* Every backend call answers in this shape. `error` is a stable code, never prose:
*
* - `client-unavailable` — no client is installed, or the call never ran
* - `client-outdated` — the installed client predates the verb (exit 5 + `unknown command`)
* - `unreachable` — the host did not answer
* - `refused` — trust rejected: a wrong PIN, or a fingerprint that already differs
* - `needs-pairing` — the CLI refused because it needs a person
* - `unresolved` — nothing matched what was named
* - `client-error` — anything else; `detail` carries the CLI's own last line
*/
export interface CliResult {
ok: boolean;
error?: string;
detail?: string;
}
export interface DiscoverResult extends CliResult {
hosts?: DiscoveredHost[];
}
export interface HostsResult extends CliResult {
hosts?: SavedHost[];
}
export interface PairResult extends CliResult {
fp?: string;
}
export interface RunnerInfo {
runner: string; // absolute path to bin/punktfunkrun.sh
app_id: string; // flatpak app id
exists: boolean;
// Which client the backend resolved: the flatpak, a native install (.deb/rpm/sysext/AUR/nix),
// or none at all. Older backends send neither field — hence optional.
client_kind?: "flatpak" | "native" | "none";
// Absolute path of the native binary; "" for flatpak. Passed to the wrapper as PF_CLIENT_BIN.
client_bin?: string;
}
export interface UpdateInfo {
current: string; // installed PLUGIN version (package.json)
latest: string; // newest plugin version in our registry for this channel
artifact: string; // immutable zip URL Decky should install
hash: string; // sha256 of that zip (Decky verifies it)
channel: string; // "latest" (stable) | "canary"
update_available: boolean; // a newer PLUGIN build is available
// The CLIENT versions independently of this plugin, and how it updates depends on how it was
// installed. A flatpak is a per-user install `sudo flatpak update` never touches, compared by
// OSTree commit; every other install (.deb/.rpm/pacman/sysext/nix/source) is compared by the
// client itself against the signed per-channel manifest (`punktfunk-client --check-update`).
client_update_available: boolean;
client_current: string; // installed client commit (flatpak) or version (native)
client_latest: string; // newest client commit (flatpak) or version (native)
client_install: string; // "flatpak" | "apt" | "dnf" | "pacman" | "sysext" | "nix" | "source" | ""
// Who can perform the update: "flatpak" (this plugin runs it), "helper" (the client drives the
// packaged root helper), "none" (nothing here can — show `client_command`).
client_applier: string;
client_command: string; // one copy-pastable line that updates this install by hand
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
client_error?: string; // the client check couldn't complete (e.g. "client-outdated")
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
}
// Steam-shortcut artwork (assets/ in the plugin dir): base64 PNGs keyed grid / gridwide /
// hero / logo, plus the icon's absolute path (SetShortcutIcon wants a file). Keys for
// missing files are absent.
export interface ShortcutArt {
grid?: string;
gridwide?: string;
hero?: string;
logo?: string;
icon_path: string;
}
// ---- The four CLI shells --------------------------------------------------------------
/** Browse the LAN over mDNS. Bounded by the CLI (3 s) plus a cold-start allowance. */
export const discover = callable<[], DiscoverResult>("discover");
/** The saved hosts, probed for reachability, with profiles and pinned cards resolved. */
export const hosts = callable<[], HostsResult>("hosts");
/** The PIN ceremony. `refused` = wrong PIN or a host that isn't armed. */
export const pair = callable<
[addr: string, port: number, pin: string, name: string],
PairResult
>("pair");
/**
* Step 1 of request access: save the host with its ADVERTISED fingerprint, pinned but unpaired.
* The launch that follows pins the same fingerprint, which is the only thing standing between a
* 185 s wait for approval and an impostor answering for the host. Idempotent; a host already
* saved under a DIFFERENT fingerprint comes back `refused` rather than being overwritten.
*/
export const trustHost = callable<
[addr: string, port: number, fp: string, name: string],
CliResult
>("trust_host");
// ---- Steam / plugin business (only a Decky plugin can do these) ------------------------
export const runnerInfo = callable<[], RunnerInfo>("runner_info");
export const shortcutArt = callable<[], ShortcutArt>("shortcut_art");
// Install the Steam Input layout (native touchscreen `ts_n` + gamepad passthrough) and point our
// shortcut(s) at it, so the Deck touchscreen reaches the client as native touch with no manual
// controller setup. Best-effort + idempotent; keyed by the shared shortcut NAME (both shortcuts
// use the same name → the same lowercase configset key), so one call covers both.
export const applyControllerConfig = callable<
[name: string],
{ ok: boolean; applied?: string[]; errors?: string[]; accounts?: number; error?: string; detail?: string }
>("apply_controller_config");
export const killStream = callable<[], { ok: boolean }>("kill_stream");
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the client by whichever route its install supports: `flatpak update --user` for the
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
// native install. Everything else comes back `ok: false, error: "manual"` with `command` — the
// line to run by hand. A package-manager run can take minutes; the backend allows 15.
export const updateClient = callable<
[],
{
ok: boolean;
updated: boolean;
staged?: boolean; // installed, but a reboot activates it (rpm-ostree)
error?: string;
detail?: string;
command?: string; // set with error "manual"
}
>("update_client");