forked from unom/punktfunk
Pressing guide/Steam/QAM collided with the client device's own shell: iOS 26 opens its Game Overlay for the Home press (no app opt-out until iOS 27 makes it a user setting), and a Gaming-Mode client opened BOTH Steam overlays for one press — the local one covering the stream. Two cross-client tier-P settings, zero wire changes: - system_buttons (auto|forward|local): raw guide+misc1 passthrough. Auto forwards everywhere EXCEPT under gamescope, where SteamOS reacts to the same physical press no matter what. - guide_gesture (auto|on|off): hold Select ALONE ~350ms sends the HOST's guide, down until release — held on, that's the host's long-press, which opens a Gaming-Mode host's QAM for regular pads. A Select tap is delivered on release with its up TAP_PRESS (50ms) behind, because per-transition sends fold into seq'd GamepadState snapshots and a back-to-back pair can coalesce into no press at all. A Select inside a combo (the escape chord) passes through untouched. Auto arms it only where the raw press can't reach the host cleanly: gamescope, iOS/iPadOS, tvOS. The same SelectGesture rules live in pf-client-core (pure state machine + unit tests), the Apple client (mask-diff adaptation in GamepadCapture), and Android's GamepadRouter. Settings rows on every surface (GTK, WinUI, console UI, Decky, Apple x2, Android x2) with profile plumbing throughout. punktfunk-session grows a control socket ($XDG_RUNTIME_DIR[/app/$FLATPAK_ID]/punktfunk-session-ctl.sock — the one runtime path a flatpak and the host see identically): 'guide'/'qam' verbs inject synthetic taps. The Decky panel gains a Host menus section (visible while the client runs) whose buttons press the host's Steam/QAM and close the local menu so the host's shows through. iOS 27's GCControllerHomeButtonSettingsManager deep-link is a TODO (the class needs the Xcode 27 SDK to compile). Docs: input, client-settings, steam-deck. Design: punktfunk-planning design/system-buttons-routing.md. Gates: docker clippy --all-targets --locked -D warnings + tests (pf-client-core 88 incl. 6 new gesture tests, pf-console-ui 47), cargo fmt --all --check, swift build (macOS), gradle kit+app compile, decky tsc --noEmit + py_compile. clients/windows not compiled (no box).
198 lines
8.9 KiB
TypeScript
198 lines
8.9 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");
|
|
// Whether the streaming client's control socket exists (a stream/console client is up) —
|
|
// gates the QAM panel's host-button section.
|
|
export const streamRunning = callable<[], { running: boolean }>("stream_running");
|
|
// Press a HOST system button on the running stream: "guide" | "qam". The raw Steam/QAM
|
|
// presses stay on the Deck by default (the client's Controllers settings), so this — and
|
|
// holding Select — is how the host's own menus are reached.
|
|
export const hostAction = callable<[action: string], { ok: boolean; error?: string }>(
|
|
"host_action",
|
|
);
|
|
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");
|