01428ced58
apple / swift (push) Failing after 27s
release / apple (push) Failing after 26s
apple / screenshots (push) Has been skipped
windows-host / package (push) Has been cancelled
arch / build-publish (push) Has been cancelled
android / android (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled
Add a bounded, trust-agnostic, mDNS-INDEPENDENT QUIC reachability probe and surface it everywhere saved-host presence is shown, so a host reached over a routed network (Tailscale/VPN/multicast-filtering LAN) no longer reads Offline just because it isn't advertising — the display-side companion to the 0.8.4 dial-first connect fix. Core: - punktfunk-core: NativeClient::probe (bounded handshake; a real host answers even on trust mismatch, a wrong/closed/TCP-only port fails) + punktfunk_probe C ABI (ABI_VERSION 3->4, header regenerated). - pf-client-core: trust::probe_reachable_many (parallel per-host sweep). Presence pips now read `advertising OR probed-reachable`, refreshed by a ~10-12s background sweep off the UI thread: - Linux (relm4): ui_hosts probed map + HostsMsg::Probed sweep. - Windows (windows-reactor): pf-probe worker -> HostsProps.probed. - Apple (SwiftUI): HostStore.refreshReachability, driven by HomeView + GamepadHomeView .task. - Android (Compose): nativeProbe JNI seam + periodic LaunchedEffect (LNP-gated), online dot added to the touch HostCard. - Decky already probes via --list-hosts --probe. Decky client: make the flatpak client's known-hosts store the single source of truth via new headless CLI modes (--list-hosts / --add-host / --set-host / --forget-host / --reset / --reachable). The plugin can now add a host by address, edit/forget hosts, reset all state (keeping the client identity), and shows probe-backed online pips — state is shared with the desktop client, not duplicated. Also lands in-progress Android 17 LNP groundwork (targetSdk 37 + ACCESS_LOCAL_NETWORK runtime flow, permission dialogs) that was already present in the working tree. Verified: cargo check + clippy clean (punktfunk-core, pf-client-core, linux, android native); android assembleDebug BUILD SUCCESSFUL; decky typecheck + rollup build clean; probe true/false-positive behaviour exercised against a live host. Windows and Apple were not compiled locally (no MSVC/Xcode on this Linux box). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
492 lines
16 KiB
TypeScript
492 lines
16 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,
|
||
listHosts,
|
||
PinnedGame,
|
||
resetConfig,
|
||
SavedHost,
|
||
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 };
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// Saved hosts — the SHARED known-hosts store (client-known-hosts.json), the same file the
|
||
// desktop client reads/writes. Fetched WITH a reachability probe so a host reached over a
|
||
// routed network (Tailscale/VPN) reports online without ever appearing on mDNS.
|
||
// ----------------------------------------------------------------------------------------
|
||
export function useSavedHosts() {
|
||
const [saved, setSaved] = useState<SavedHost[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const refresh = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const r = await listHosts(true);
|
||
setSaved(r.hosts ?? []);
|
||
} catch {
|
||
/* backend unavailable — keep the current view */
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
return { saved, loading, refresh };
|
||
}
|
||
|
||
/**
|
||
* One host as the UI shows it — the union of the saved store and the live mDNS scan. A saved
|
||
* host is ONLINE when it either advertises on mDNS OR answers the reachability probe (so
|
||
* mDNS-blind-but-reachable hosts stop reading as offline). Discovered hosts not in the store
|
||
* are appended as unsaved rows.
|
||
*/
|
||
export interface HostView {
|
||
name: string;
|
||
addr: string;
|
||
port: number;
|
||
fp: string; // "" for a saved-but-unpaired placeholder
|
||
paired: boolean; // PIN-paired specifically (a TOFU host has fp but paired=false)
|
||
online: boolean;
|
||
saved: boolean; // present in the known-hosts store
|
||
pairPolicy: string; // the advert's policy ("required"|"optional"), "" when not advertising
|
||
mgmt: number; // advertised mgmt-API port (0 = not advertised → default)
|
||
id: string; // advertised stable host id ("" when not advertising)
|
||
}
|
||
|
||
function advertMatchesSaved(a: Host, s: SavedHost): boolean {
|
||
return (
|
||
(!!s.fp_hex && !!a.fp && s.fp_hex.toLowerCase() === a.fp.toLowerCase()) ||
|
||
(s.addr === a.host && s.port === a.port)
|
||
);
|
||
}
|
||
|
||
export function mergeHosts(saved: SavedHost[], discovered: Host[]): HostView[] {
|
||
const views: HostView[] = saved.map((s) => {
|
||
// Prefer a live advert's address (a host may have moved DHCP leases since it was saved).
|
||
const advert = discovered.find((a) => advertMatchesSaved(a, s));
|
||
return {
|
||
name: s.name || s.addr,
|
||
addr: advert?.host ?? s.addr,
|
||
port: advert?.port ?? s.port,
|
||
fp: s.fp_hex || advert?.fp || "",
|
||
paired: s.paired,
|
||
online: !!advert || s.online === true,
|
||
saved: true,
|
||
pairPolicy: advert?.pair ?? "",
|
||
mgmt: advert?.mgmt ?? 0,
|
||
id: advert?.id ?? "",
|
||
};
|
||
});
|
||
for (const a of discovered) {
|
||
if (saved.some((s) => advertMatchesSaved(a, s))) {
|
||
continue; // already rendered as its saved card (with a live pip)
|
||
}
|
||
views.push({
|
||
name: a.name,
|
||
addr: a.host,
|
||
port: a.port,
|
||
fp: a.fp,
|
||
paired: a.paired,
|
||
online: true,
|
||
saved: false,
|
||
pairPolicy: a.pair,
|
||
mgmt: a.mgmt,
|
||
id: a.id,
|
||
});
|
||
}
|
||
return views;
|
||
}
|
||
|
||
/**
|
||
* True when this host must be paired before it can stream. A saved host is streamable once it
|
||
* has a pinned fingerprint (PIN-paired OR TOFU-trusted); a saved placeholder (no fp yet) must be
|
||
* paired. For an unsaved discovered host we keep the advertised-policy rule the UI always used.
|
||
*/
|
||
export function needsPair(v: HostView): boolean {
|
||
return v.saved ? v.fp === "" : v.pairPolicy === "required" && !v.paired;
|
||
}
|
||
|
||
/** Adapt a merged view back into the `Host` shape the pair/library/stream helpers consume. */
|
||
export function toHost(v: HostView): Host {
|
||
return {
|
||
name: v.name,
|
||
host: v.addr,
|
||
port: v.port,
|
||
pair: v.pairPolicy || (needsPair(v) ? "required" : "optional"),
|
||
fp: v.fp,
|
||
proto: "",
|
||
paired: v.paired,
|
||
id: v.id,
|
||
mgmt: v.mgmt,
|
||
};
|
||
}
|
||
|
||
/** Is a pinned game's host currently online, considering BOTH the live scan and saved probe? */
|
||
export function pinIsOnline(pin: PinnedGame, views: HostView[]): boolean {
|
||
const fp = pin.host_fp.toLowerCase();
|
||
return views.some(
|
||
(v) =>
|
||
v.online &&
|
||
((!!fp && v.fp.toLowerCase() === fp) ||
|
||
(!!pin.host_id && v.id === pin.host_id) ||
|
||
(v.addr === pin.host && v.port === pin.port)),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Reset all Punktfunk state (saved hosts + stream settings + pins), keeping the client identity.
|
||
* Refreshes whatever views are passed so the UI clears immediately. Ends in a toast.
|
||
*/
|
||
export async function resetAll(refreshers: Array<() => void | Promise<void>>): Promise<void> {
|
||
try {
|
||
const r = await resetConfig();
|
||
for (const fn of refreshers) void fn();
|
||
toaster.toast({
|
||
title: "Punktfunk",
|
||
body: r.ok
|
||
? "Reset — saved hosts, settings, and pins cleared."
|
||
: `Reset failed${r.error ? ` (${r.error})` : ""}.`,
|
||
});
|
||
} catch {
|
||
toaster.toast({ title: "Punktfunk", body: "Reset failed." });
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------------------
|
||
// 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,
|
||
};
|
||
}
|