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>
134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { KeyRound, Smartphone, Timer } from "lucide-react";
|
|
import { type FC, useEffect, useRef } from "react";
|
|
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
|
|
import {
|
|
getGetNativePairingQueryKey,
|
|
getListNativeClientsQueryKey,
|
|
useArmNativePairing,
|
|
useDisarmNativePairing,
|
|
useGetNativePairing,
|
|
} from "@/api/gen/native/native";
|
|
import { QueryState } from "@/components/query-state";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import type { Loadable } from "@/lib/query";
|
|
import { m } from "@/paraglide/messages";
|
|
|
|
/** Seconds → `m:ss`. */
|
|
function fmtTime(secs: number): string {
|
|
const s = Math.max(0, Math.floor(secs));
|
|
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
/**
|
|
* Container: native (punktfunk/1) pairing — arm a window, poll fast while armed
|
|
* for the live countdown, slow otherwise.
|
|
*/
|
|
export const NativePairingSection: FC = () => {
|
|
const qc = useQueryClient();
|
|
const native = useGetNativePairing({
|
|
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
|
|
});
|
|
const arm = useArmNativePairing();
|
|
const disarm = useDisarmNativePairing();
|
|
|
|
// A device pairs via the QUIC PIN ceremony, NOT through approve/deny, so nothing else
|
|
// invalidates the paired-devices list on the happy path — it would stay stale until remount.
|
|
// The status poll's `paired_clients` count is the pairing signal: when it rises, refresh the
|
|
// list so the newly paired device appears immediately.
|
|
const pairedCount = native.data?.paired_clients;
|
|
const prevPairedCount = useRef(pairedCount);
|
|
useEffect(() => {
|
|
if (
|
|
prevPairedCount.current !== undefined &&
|
|
pairedCount !== undefined &&
|
|
pairedCount !== prevPairedCount.current
|
|
) {
|
|
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
|
}
|
|
prevPairedCount.current = pairedCount;
|
|
}, [pairedCount, qc]);
|
|
|
|
const refresh = () =>
|
|
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
|
|
const onArm = () =>
|
|
arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refresh });
|
|
const onDisarm = () => disarm.mutate(undefined, { onSuccess: refresh });
|
|
|
|
return (
|
|
<NativePairingCard
|
|
status={native}
|
|
onArm={onArm}
|
|
onDisarm={onDisarm}
|
|
isArming={arm.isPending}
|
|
isDisarming={disarm.isPending}
|
|
/>
|
|
);
|
|
};
|
|
|
|
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
|
export const NativePairingCard: FC<{
|
|
status: Loadable<NativePairStatus>;
|
|
onArm: () => void;
|
|
onDisarm: () => void;
|
|
isArming: boolean;
|
|
isDisarming: boolean;
|
|
}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => {
|
|
const d = status.data;
|
|
return (
|
|
<QueryState
|
|
isLoading={status.isLoading}
|
|
error={status.error}
|
|
refetch={status.refetch}
|
|
>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Smartphone className="size-4" />
|
|
{m.pairing_native_title()}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{!d?.enabled ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{m.pairing_native_disabled()}
|
|
</p>
|
|
) : d.armed && d.pin ? (
|
|
<div className="space-y-3">
|
|
<p className="text-sm">{m.pairing_native_enter()}</p>
|
|
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
|
|
{d.pin}
|
|
</div>
|
|
{d.expires_in_secs != null && (
|
|
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
|
<Timer className="size-4" />
|
|
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
|
|
</p>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
disabled={isDisarming}
|
|
onClick={onDisarm}
|
|
>
|
|
{m.pairing_native_cancel()}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">
|
|
{m.pairing_native_desc()}
|
|
</p>
|
|
<Button disabled={isArming} onClick={onArm}>
|
|
<KeyRound className="size-4" />
|
|
{m.pairing_native_arm()}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</QueryState>
|
|
);
|
|
};
|