57ae00a9c8
ci / rust (push) Failing after 41s
apple / swift (push) Successful in 1m8s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Successful in 2m55s
decky / build-publish (push) Successful in 27s
apple / screenshots (push) Successful in 5m46s
ci / bench (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
GTK Linux client: - hosts/library: clicking a card was dead — the handler was on FlowBoxChild::activate (never emitted on click); bridge child-activated → child.activate() on the FlowBox (ui_hosts, ui_library). - stream: the Ctrl+Alt+Shift+D/Q/S chords (and all key forwarding) were dropped because the key controller sat on the overlay, which loses focus to the header back button after nav.push+fullscreen — move it to the window and remove it on teardown. - video: a mid-session VAAPI decode error rebuilt a software decoder but never requested a keyframe, so under the infinite GOP the picture stayed gray/frozen forever. Request an IDR on any VAAPI error, keep the hardware decoder, and demote to software only after repeated failures. - stream: fix a per-session Capture↔overlay reference cycle that leaked the overlay subtree + the Arc<NativeClient> on every session end — hold the overlay weakly. - stream: accumulate the fractional wheel remainder so precision-scroll (Deck trackpad / hi-res wheels) sub-unit deltas aren't dropped. - gamepad library: keep the launcher smooth on the Deck — freeze the aurora and trim the visible card range (fewer 3D offscreen passes) on low-power. - gamepad: log full pad identity (vid:pid:name:type:virtual) on attach to diagnose an empty controller list on the Deck. - cli: --connect host:<badport> silently did nothing; default to 9777 + warn. - css: add the missing .pf-neutral pill rule; fix the clipped most-recent accent (inset outline instead of a corner-clipped box-shadow bar). Decky plugin: - surface the on-screen library browser: label the host-row Games button. - fix silent pin data-loss — the detached Games modal captured a frozen pins array, so pinning a second game clobbered the first; mirror pins in a ref and track the modal's pinned ids locally for a live label. - route pair-required hosts through the pairing modal from the fullscreen Stream button (parity with the QAM panel). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||
import { toaster } from "@decky/api";
|
||
import { Navigation } from "@decky/ui";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import {
|
||
checkUpdate,
|
||
discover,
|
||
GameEntry,
|
||
getPins,
|
||
Host,
|
||
PinnedGame,
|
||
setPins as setPinsBackend,
|
||
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;
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// Discovery — mDNS scan state shared by the QAM panel and the full page.
|
||
// ----------------------------------------------------------------------------------------
|
||
export function useHosts() {
|
||
const [hosts, setHosts] = useState<Host[]>([]);
|
||
const [scanning, setScanning] = useState(false);
|
||
|
||
const refresh = useCallback(async () => {
|
||
setScanning(true);
|
||
try {
|
||
setHosts(await discover());
|
||
} catch (e) {
|
||
toaster.toast({ title: "Punktfunk", body: `Discovery failed: ${e}` });
|
||
} finally {
|
||
setScanning(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
return { hosts, scanning, 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 flatpak client has a pending update. */
|
||
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
|
||
return !!info && (info.update_available || info.client_update_available);
|
||
}
|
||
|
||
/** 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("client");
|
||
body = `Update available: ${parts.join(" + ")}.`;
|
||
} 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 });
|
||
}
|
||
|
||
/**
|
||
* Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope
|
||
* `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC
|
||
* reinstalls it — which reloads the plugin and tears this panel down, so it goes last and is
|
||
* fire-and-forget. `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) {
|
||
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
|
||
try {
|
||
const r = await updateClient();
|
||
toaster.toast({
|
||
title: "Punktfunk",
|
||
body: !r.ok
|
||
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
|
||
: r.updated
|
||
? "Client updated to the latest version."
|
||
: "Client is already up to date.",
|
||
});
|
||
} catch {
|
||
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
|
||
}
|
||
}
|
||
|
||
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,
|
||
"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).
|
||
// ----------------------------------------------------------------------------------------
|
||
export async function startStream(
|
||
h: Host,
|
||
opts: LaunchOpts = {},
|
||
label?: string,
|
||
): Promise<void> {
|
||
try {
|
||
await launchStream(h.host, h.port, opts);
|
||
Navigation.CloseSideMenus();
|
||
toaster.toast({ title: "Punktfunk", body: `Starting ${label ?? "stream"} — ${h.name}` });
|
||
} catch (e) {
|
||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||
}
|
||
}
|
||
|
||
/** Open the GTK client's gamepad library launcher for a host (`--browse` via PF_BROWSE). */
|
||
export async function startBrowse(h: Host): Promise<void> {
|
||
try {
|
||
await launchStream(h.host, h.port, { browse: true, mgmt: h.mgmt });
|
||
Navigation.CloseSideMenus();
|
||
toaster.toast({ title: "Punktfunk", body: `Opening library — ${h.name}` });
|
||
} catch (e) {
|
||
toaster.toast({ title: "Punktfunk", body: `Launch failed: ${e}` });
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// Pinned games — the QAM's one-tap game rows, persisted by the backend next to the
|
||
// client's config (survives plugin reinstalls).
|
||
// ----------------------------------------------------------------------------------------
|
||
export interface PinsApi {
|
||
pins: PinnedGame[];
|
||
addPin: (h: Host, g: GameEntry) => void;
|
||
removePin: (hostFp: string, gameId: string) => void;
|
||
isPinned: (hostFp: string, gameId: string) => boolean;
|
||
/** Refresh a pin's stored address from a live advert (hosts change IPs). */
|
||
updatePinHost: (pin: PinnedGame, h: Host) => void;
|
||
refresh: () => Promise<void>;
|
||
}
|
||
|
||
export function usePins(): PinsApi {
|
||
const [pins, setPins] = useState<PinnedGame[]>([]);
|
||
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
|
||
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
|
||
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
|
||
// game in the same session would compute from the stale `[]` and clobber the first (silent
|
||
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
|
||
// callbacks keep a stable identity (deps free of `pins`).
|
||
const pinsRef = useRef<PinnedGame[]>([]);
|
||
pinsRef.current = pins;
|
||
|
||
const refresh = useCallback(async () => {
|
||
try {
|
||
setPins((await getPins()).pins);
|
||
} catch {
|
||
/* backend unavailable — keep the current view */
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
||
const save = useCallback(
|
||
(next: PinnedGame[]) => {
|
||
pinsRef.current = next;
|
||
setPins(next);
|
||
setPinsBackend(next).catch(() => void refresh());
|
||
},
|
||
[refresh],
|
||
);
|
||
|
||
const addPin = useCallback(
|
||
(h: Host, g: GameEntry) => {
|
||
const pin: PinnedGame = {
|
||
game_id: g.id,
|
||
title: g.title,
|
||
store: g.store,
|
||
host_fp: h.fp,
|
||
host_id: h.id,
|
||
host_name: h.name,
|
||
host: h.host,
|
||
port: h.port,
|
||
mgmt: h.mgmt,
|
||
added_at: Math.floor(Date.now() / 1000),
|
||
paired: h.paired,
|
||
};
|
||
save([
|
||
...pinsRef.current.filter(
|
||
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
|
||
),
|
||
pin,
|
||
]);
|
||
},
|
||
[save],
|
||
);
|
||
|
||
const removePin = useCallback(
|
||
(hostFp: string, gameId: string) => {
|
||
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
|
||
},
|
||
[save],
|
||
);
|
||
|
||
const isPinned = useCallback(
|
||
(hostFp: string, gameId: string) =>
|
||
pins.some((p) => p.host_fp === hostFp && p.game_id === gameId),
|
||
[pins],
|
||
);
|
||
|
||
const updatePinHost = useCallback(
|
||
(pin: PinnedGame, h: Host) => {
|
||
if (pin.host === h.host && pin.port === h.port && pin.mgmt === h.mgmt) {
|
||
return;
|
||
}
|
||
save(
|
||
pinsRef.current.map((p) =>
|
||
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
||
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
||
: p,
|
||
),
|
||
);
|
||
},
|
||
[save],
|
||
);
|
||
|
||
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
||
}
|
||
|
||
/**
|
||
* The host a pin should launch against right now: match the live mDNS scan by cert
|
||
* fingerprint first (pairing is fp-keyed, survives IP changes), then by the host's stable
|
||
* id, else fall back to the stored address (host offline or scan flaky — still launch).
|
||
*/
|
||
export function resolvePinHost(
|
||
pin: PinnedGame,
|
||
live: Host[],
|
||
): { host: Host; online: boolean } {
|
||
const fp = pin.host_fp.toLowerCase();
|
||
const match =
|
||
(fp && live.find((h) => h.fp && h.fp.toLowerCase() === fp)) ||
|
||
(pin.host_id && live.find((h) => h.id && h.id === pin.host_id)) ||
|
||
undefined;
|
||
if (match) {
|
||
return { host: match, online: true };
|
||
}
|
||
return {
|
||
host: {
|
||
name: pin.host_name || pin.host,
|
||
host: pin.host,
|
||
port: pin.port,
|
||
pair: pin.paired ? "optional" : "required",
|
||
fp: pin.host_fp,
|
||
proto: "",
|
||
paired: !!pin.paired,
|
||
id: pin.host_id,
|
||
mgmt: pin.mgmt,
|
||
},
|
||
online: false,
|
||
};
|
||
}
|