The host has published every lifecycle transition on GET /api/v1/events since the API existed — client connect/disconnect, session and stream start/end, pairing decisions, display create/release, library, store and plugin changes — and nothing consumed a byte of it. The console instead polled ten endpoints on 1-5 s timers, so a change was up to 5 s stale and two pages could disagree while you looked at them. The Library page polled not at all: install a game in Steam and it never appeared until a full reload. The console now subscribes once and invalidates exactly the queries an event affects. Events never carry data into the cache — they only say "this is stale" — so an unknown future kind costs nothing and a missed event degrades to the polling that is still there underneath, now at a slow safety-net interval. The fast ticks that remain are the ones events cannot express: the live stream numbers while streaming, and a lingering display's teardown countdown. Four things had to be true for this to work, and none of them were. Each was found by measuring, not by reading: - Nitro's `localFetch` accumulates the response and only builds it when the handler returns, so nothing streams through the deployed Bun server. Three frames sent a second apart arrived together, three seconds late, when the upstream closed — and an SSE stream never closes, so nothing would ever have arrived. /api/v1/events gets its own route that hands back a web Response wrapping the upstream stream, which passes straight through. - Hydration mounts the app shell and discards it ~15 ms later. A subscription owned by that effect opened, closed, and never came back. It is a refcounted module singleton now, with a grace period so a remount re-attaches instead of reconnecting. - `getRouter()` runs more than once in the browser, and each call built its own QueryClient. The subscription held the first, the live pages read the second, and every invalidation went to a cache nobody was reading. One client per browser session; the server still gets a fresh one per request, which it must. - `invalidateQueries` only refetches queries that currently have an observer. An event means the HOST changed, so every cached copy is wrong whether or not something is watching it. Two features fall out of the same work: - **Automation** — a page for GET/PUT /api/v1/hooks. The host has run these hooks all along and the console never showed them, so the only way to see what your machine does when a stream starts was to open the config file. Writing one means writing a shell command the host will execute, so saving re-asks for the console password, like an update or an unreviewed install. - The Host page warns when another Moonlight-compatible server (Sunshine, Apollo) is running on the same machine. The host has detected this at startup for ages and reported it in /local/summary; nothing surfaced it. It is the most common reason a host looks installed and working but no client can reach it. Verified in a real browser against a mock host: three events drive three refetches of a query with no polling timer, the conflicts card names the intruder, the hook list and its dialog render, and the console reports no errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "@unom/ui/button";
|
|
import { UserPlus, X } from "lucide-react";
|
|
import type { FC } from "react";
|
|
import type { PendingDevice } from "@/api/gen/model";
|
|
import {
|
|
getListNativeClientsQueryKey,
|
|
getListPendingDevicesQueryKey,
|
|
useApprovePendingDevice,
|
|
useDenyPendingDevice,
|
|
useListPendingDevices,
|
|
} from "@/api/gen/native/native";
|
|
import { QueryState } from "@/components/query-state";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
|
import type { Loadable } from "@/lib/query";
|
|
import { fmtAge } from "@/lib/utils";
|
|
import { m } from "@/paraglide/messages";
|
|
|
|
/**
|
|
* Container: devices awaiting delegated approval. Polls so a knock appears while
|
|
* looking; approving pairs the device, so it also refreshes the paired-clients
|
|
* list (owned by the PairedDevices subsection — invalidated here by query key).
|
|
*/
|
|
export const PendingDevicesSection: FC = () => {
|
|
const qc = useQueryClient();
|
|
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
|
|
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
|
|
// the rows carry an age that should not visibly lag.
|
|
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
|
|
const approve = useApprovePendingDevice();
|
|
const deny = useDenyPendingDevice();
|
|
|
|
const refresh = () => {
|
|
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
|
|
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
|
};
|
|
const onApprove = (id: number, currentName: string) => {
|
|
const name = prompt(m.pairing_pending_name_prompt(), currentName);
|
|
if (name == null) return; // operator cancelled
|
|
approve.mutate(
|
|
{ id, data: { name: name.trim() ? name.trim() : null } },
|
|
{ onSuccess: refresh },
|
|
);
|
|
};
|
|
const onDeny = (id: number) => deny.mutate({ id }, { onSuccess: refresh });
|
|
|
|
// The id of the row whose approve/deny is in flight — only that row's buttons disable.
|
|
const pendingId =
|
|
(approve.isPending ? approve.variables?.id : undefined) ??
|
|
(deny.isPending ? deny.variables?.id : undefined) ??
|
|
null;
|
|
|
|
return (
|
|
<PendingDevices
|
|
pending={pending}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
pendingId={pendingId}
|
|
/>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Devices awaiting delegated approval: an unpaired device that tried to connect
|
|
* shows up here, and Approve pairs it on the spot. Renders nothing while empty
|
|
* (the common case) unless there's an error to surface.
|
|
*/
|
|
export const PendingDevices: FC<{
|
|
pending: Loadable<PendingDevice[]>;
|
|
onApprove: (id: number, currentName: string) => void;
|
|
onDeny: (id: number) => void;
|
|
/** Id of the row whose approve/deny is in flight, or null — only that row disables. */
|
|
pendingId: number | null;
|
|
}> = ({ pending, onApprove, onDeny, pendingId }) => {
|
|
const rows = pending.data ?? [];
|
|
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
|
|
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
|
|
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
|
|
if (rows.length === 0 && !pending.error) return null;
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent flush>
|
|
<CardHeader>
|
|
<CardTitle>
|
|
<h2 className="flex items-center gap-2 text-lg font-medium">
|
|
<UserPlus className="size-4" />
|
|
{m.pairing_pending_title()}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{m.pairing_pending_desc()}
|
|
</p>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
|
|
<QueryState
|
|
isLoading={pending.isLoading}
|
|
error={pending.error}
|
|
refetch={pending.refetch}
|
|
>
|
|
<Table>
|
|
<TableBody>
|
|
{rows.map((p) => (
|
|
<TableRow className="h-18" key={p.id}>
|
|
{/* The row must keep the actions on-canvas in a portrait phone
|
|
viewport: the name flexes and truncates (w-full + max-w-0),
|
|
and the fingerprint/age columns collapse into a sub-line
|
|
here below md/sm instead of widening the row past the
|
|
screen (the table wrapper scrolls, the page doesn't — an
|
|
off-canvas Approve button is unreachable on mobile). */}
|
|
<TableCell className="w-full max-w-0 font-medium">
|
|
<div className="truncate">{p.name}</div>
|
|
<div className="truncate font-mono text-xs font-normal text-muted-foreground md:hidden">
|
|
{p.fingerprint.slice(0, 16)}…
|
|
<span className="ml-2 font-sans sm:hidden">
|
|
{fmtAge(p.age_secs)}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="hidden font-mono text-xs text-muted-foreground md:table-cell">
|
|
{p.fingerprint.slice(0, 16)}…
|
|
</TableCell>
|
|
<TableCell className="hidden text-xs text-muted-foreground sm:table-cell">
|
|
{fmtAge(p.age_secs)}
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
disabled={pendingId === p.id}
|
|
onClick={() => onApprove(p.id, p.name)}
|
|
>
|
|
{m.pairing_pending_approve()}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
aria-label={m.pairing_pending_deny()}
|
|
disabled={pendingId === p.id}
|
|
onClick={() => onDeny(p.id)}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|