forked from unom/punktfunk
Pairing: - Refresh the paired-devices list after a native PIN pairing (the happy path never invalidated it, so a newly paired device stayed hidden until remount). - Moonlight PIN: a 204 means "PIN delivered to the waiting handshake", NOT paired, so it now reads "PIN sent" instead of a false "Paired successfully". - Hide the Moonlight pairing card on native-only hosts (HostInfo.gamestream) — it could never receive a PIN there. - Per-row pending on unpair/approve/deny; PIN input maxLength 16 (was 8). Displays / Library: - "Arrange displays" save refreshes the settings card (it rewrites the policy), without clobbering unsaved Custom edits (re-seed only when the draft still matches the server). - Live-display list wrapped in QueryState so errors don't read as "no displays". - "Forever" keep-alive option in the custom editor; edit-game form round-trips the logo artwork (was dropped on save); per-card delete pending. Stats: - Distinct colour for the native "queue" latency stage (it collided with "capture"). - "Not measured on this path" note on the GameStream health chart; configured-bitrate target line on throughput; host-authoritative elapsed timer; LiveCard surfaces non-404 errors. Shell / auth / i18n: - SSR-stable locale: first client render matches the base-locale SSR (no hydration mismatch), then adopts the persisted/browser locale post-hydration. - BFF proxy maps an upstream (mgmt-token) 401 to 502 so a logged-in user isn't bounced into a post-login redirect loop. - Logout checks the POST result before navigating; logs dedup by seq (StrictMode); login "next" keeps query/hash; Dashboard shows the active-session count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
5.1 KiB
TypeScript
182 lines
5.1 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { Trash2 } from "lucide-react";
|
|
import type { FC } from "react";
|
|
import {
|
|
getListPairedClientsQueryKey,
|
|
useListPairedClients,
|
|
useUnpairClient,
|
|
} from "@/api/gen/clients/clients";
|
|
import {
|
|
getListNativeClientsQueryKey,
|
|
useListNativeClients,
|
|
useUnpairNativeClient,
|
|
} from "@/api/gen/native/native";
|
|
import { QueryState } from "@/components/query-state";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { m } from "@/paraglide/messages";
|
|
|
|
/** The two pairing protocols a device can be paired over. */
|
|
export type PairedProtocol = "native" | "moonlight";
|
|
|
|
/** One paired device, normalized across the native + Moonlight lists. */
|
|
export interface PairedRow {
|
|
protocol: PairedProtocol;
|
|
fingerprint: string;
|
|
/** Native devices carry a name; Moonlight clients carry a cert subject; either may be empty. */
|
|
name: string;
|
|
}
|
|
|
|
/**
|
|
* Container: ALL paired devices in one list. Merges the native (punktfunk/1) clients and the
|
|
* GameStream/Moonlight clients — two separate host endpoints — into a single table tagged by
|
|
* protocol, and routes each unpair back to the right endpoint.
|
|
*/
|
|
export const PairedDevicesSection: FC = () => {
|
|
const qc = useQueryClient();
|
|
const native = useListNativeClients();
|
|
const moonlight = useListPairedClients();
|
|
const unpairNative = useUnpairNativeClient();
|
|
const unpairMoonlight = useUnpairClient();
|
|
|
|
const rows: PairedRow[] = [
|
|
...(native.data ?? []).map(
|
|
(c): PairedRow => ({
|
|
protocol: "native",
|
|
fingerprint: c.fingerprint,
|
|
name: c.name,
|
|
}),
|
|
),
|
|
...(moonlight.data ?? []).map(
|
|
(c): PairedRow => ({
|
|
protocol: "moonlight",
|
|
fingerprint: c.fingerprint,
|
|
name: c.subject ?? "",
|
|
}),
|
|
),
|
|
];
|
|
|
|
const onUnpair = (protocol: PairedProtocol, fingerprint: string) => {
|
|
if (!confirm(m.pairing_native_unpair_confirm())) return;
|
|
if (protocol === "native") {
|
|
unpairNative.mutate(
|
|
{ fingerprint },
|
|
{
|
|
onSuccess: () =>
|
|
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
|
|
},
|
|
);
|
|
} else {
|
|
unpairMoonlight.mutate(
|
|
{ fingerprint },
|
|
{
|
|
onSuccess: () =>
|
|
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
|
|
},
|
|
);
|
|
}
|
|
};
|
|
|
|
// The fingerprint of the row whose unpair is in flight (if any) — so only THAT row's button
|
|
// disables, not every row's.
|
|
const pendingFingerprint =
|
|
(unpairNative.isPending
|
|
? unpairNative.variables?.fingerprint
|
|
: undefined) ??
|
|
(unpairMoonlight.isPending
|
|
? unpairMoonlight.variables?.fingerprint
|
|
: undefined) ??
|
|
null;
|
|
|
|
return (
|
|
<PairedDevices
|
|
rows={rows}
|
|
isLoading={native.isLoading || moonlight.isLoading}
|
|
error={native.error ?? moonlight.error}
|
|
refetch={() => {
|
|
native.refetch();
|
|
moonlight.refetch();
|
|
}}
|
|
onUnpair={onUnpair}
|
|
pendingFingerprint={pendingFingerprint}
|
|
/>
|
|
);
|
|
};
|
|
|
|
/** All paired devices (native + Moonlight) in one table, differentiated by a protocol badge. */
|
|
export const PairedDevices: FC<{
|
|
rows: PairedRow[];
|
|
isLoading: boolean;
|
|
error: unknown;
|
|
refetch: () => void;
|
|
onUnpair: (protocol: PairedProtocol, fingerprint: string) => void;
|
|
/** Fingerprint of the row whose unpair is in flight, or null — only that row disables. */
|
|
pendingFingerprint: string | null;
|
|
}> = ({ rows, isLoading, error, refetch, onUnpair, pendingFingerprint }) => (
|
|
<Card>
|
|
<CardHeader>
|
|
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-6">
|
|
<QueryState isLoading={isLoading} error={error} refetch={refetch}>
|
|
{rows.length === 0 ? (
|
|
m.pairing_native_empty()
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{m.clients_name()}</TableHead>
|
|
<TableHead>{m.pairing_protocol()}</TableHead>
|
|
<TableHead>{m.clients_fingerprint()}</TableHead>
|
|
<TableHead className="w-12" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rows.map((r) => (
|
|
<TableRow key={`${r.protocol}:${r.fingerprint}`}>
|
|
<TableCell className="font-medium">{r.name || "—"}</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={
|
|
r.protocol === "native" ? "default" : "secondary"
|
|
}
|
|
>
|
|
{r.protocol === "native"
|
|
? m.pairing_protocol_native()
|
|
: m.pairing_protocol_moonlight()}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
{r.fingerprint.slice(0, 16)}…
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={m.action_unpair()}
|
|
disabled={pendingFingerprint === r.fingerprint}
|
|
onClick={() => onUnpair(r.protocol, r.fingerprint)}
|
|
>
|
|
<Trash2 className="size-4 text-destructive" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
);
|