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.
189 lines
8.4 KiB
TypeScript
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");
|