Files
punktfunk/web/src/sections/Displays/SessionGameCard.tsx
T
enricobuehlerandClaude Opus 5 f66de3eba4 fix(web): close the last two high-severity items from the closeout audit
**The streamed-screen pin could still be clobbered by three other write paths.**
Deferring it to the server's value on Save fixed the reported sequence but not
the general case: the draft is only re-seeded while it is CLEAN, so once there is
an unsaved edit its `capture_monitor` is frozen at whatever it was before the
operator used the picker — and `applyAxis` (which spreads the last saved policy),
the built-in preset switch and the custom-preset apply all put that stale value
back. Every write path reads `serverCaptureMonitor()` now; no path spreads the
draft's copy.

**The session⇄game grace input had no accessible name.** That card has its own
`Field` and only DisplayCard's was fixed, so the number input was still announced
as an unnamed spin button. Same treatment: `htmlFor`/`id` for the single control,
`fieldset`/`legend` for the two button groups. Verified in a browser — zero
inputs without an accessible name across the Displays page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00

255 lines
8.6 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { ApiError } from "@/api/fetcher";
import type { GameOnSessionEnd, SessionSettings } from "@/api/gen/model";
import {
getGetSessionSettingsQueryKey,
useGetSessionSettings,
useSetSessionSettings,
} from "@/api/gen/session/session";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
const END_POLICIES: GameOnSessionEnd[] = ["keep", "on_quit", "always"];
/**
* Whether a launched game and its streaming session share a fate
* (design/session-game-lifetime.md), next to the display keep-alive policy because the two interact:
* a kept display and a kept game are separate decisions with separate timers, and `keep_alive:
* forever` outranks the game policy for the display itself.
*
* Every axis saves on change (like the display policy above) — there is no Save button to miss.
*/
export const SessionGameCard: FC = () => {
const qc = useQueryClient();
const q = useGetSessionSettings();
const save = useSetSessionSettings();
const server = q.data?.settings;
// Which axes this build acts on. An EMPTY list means the build enforces nothing — the contract
// says so outright ("Empty on a platform with no launch path (macOS), so the console can say so
// instead of offering a switch that does nothing"), and this card's own comment promises the
// controls are "shown disabled rather than hidden".
//
// The old `enforced.length === 0 || …` read empty as "enforces EVERYTHING", so on exactly the
// platform the flag exists for, every control stayed live: clicking one PUT the setting and
// toasted success for an axis the host would never act on. Absent (an older host that never
// sent the field) still means "assume it acts" — that is the compatible reading, and it is a
// different case from present-and-empty.
const enforced = q.data?.enforced;
const acts = (field: string) => !enforced || enforced.includes(field);
// The grace field is free text while being typed, so it gets a local buffer; the other two axes
// are discrete and go straight to the host.
const [grace, setGrace] = useState("");
useEffect(() => {
if (server) setGrace(String(server.disconnect_grace_seconds ?? 300));
}, [server]);
const apply = (patch: Partial<SessionSettings>) => {
if (!server) return;
save.mutate(
{ data: { ...server, ...patch } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetSessionSettingsQueryKey() });
toast.success(m.session_game_saved());
},
},
);
};
const busy = save.isPending;
const error = save.error instanceof ApiError ? save.error.message : undefined;
return (
<Card>
<CardHeader>
<CardTitle>{m.session_game_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.session_game_help()}
</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{server && (
<div className="space-y-6">
<Field
label={m.session_game_on_exit()}
help={m.session_game_on_exit_help()}
group
>
<div className="flex flex-wrap gap-2">
<Choice
selected={server.session_on_game_exit === true}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: true })}
>
{m.session_game_on_exit_end()}
</Choice>
<Choice
selected={server.session_on_game_exit === false}
disabled={busy || !acts("session_on_game_exit")}
onClick={() => apply({ session_on_game_exit: false })}
>
{m.session_game_on_exit_keep()}
</Choice>
</div>
</Field>
<Field
label={m.session_game_end_game()}
help={m.session_game_end_game_help()}
group
>
<div className="flex flex-wrap gap-2">
{END_POLICIES.map((p) => (
<Choice
key={p}
selected={(server.game_on_session_end ?? "keep") === p}
disabled={busy || !acts("game_on_session_end")}
onClick={() => apply({ game_on_session_end: p })}
>
{END_POLICY_LABEL[p]()}
</Choice>
))}
</div>
{(server.game_on_session_end ?? "keep") === "always" && (
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_always_warning()}
</p>
)}
{/* Shown for every option, including "leave it running": on a nested
gamescope launch the game IS inside the streamed display, so the
display's own keep-alive outranks anything chosen here — verified
on glass (.41), where a deliberate stop ended the game under
`keep`. Worded so a non-gamescope host reads it and moves on. */}
<p className="max-w-prose text-xs text-muted-foreground">
{m.session_game_nested_note()}
</p>
</Field>
{(server.game_on_session_end ?? "keep") === "always" && (
<Field
label={m.session_game_grace()}
help={m.session_game_grace_help()}
htmlFor="session-grace-seconds"
>
<div className="flex items-center gap-2">
<Input
id="session-grace-seconds"
type="number"
min={10}
max={86400}
className="w-28"
value={grace}
disabled={busy || !acts("disconnect_grace_seconds")}
onChange={(e) => setGrace(e.target.value)}
onBlur={() => {
const n = Number(grace);
if (!Number.isFinite(n)) {
setGrace(
String(server.disconnect_grace_seconds ?? 300),
);
return;
}
// The host clamps to 10..=86400 and returns what it stored, so
// a nonsense number is corrected rather than rejected.
if (n !== server.disconnect_grace_seconds) {
apply({ disconnect_grace_seconds: n });
}
}}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
</Field>
)}
{/* Present-and-empty is the "this build acts on none of it" signal; ABSENT
is an older host that never sent the field, where claiming inertness
would be a guess. Same distinction `acts()` makes above. */}
{enforced?.length === 0 && (
<Badge variant="outline">{m.session_game_inert()}</Badge>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)}
</QueryState>
</CardContent>
</Card>
);
};
const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
keep: () => m.session_game_end_keep(),
on_quit: () => m.session_game_end_on_quit(),
always: () => m.session_game_end_always(),
};
/**
* A labelled block. `htmlFor` pairs the label with a single control; without one it is a group.
*
* A bare `<Label>` beside an `<input>` with no `id` labels nothing at all — the grace input was
* announced as an unnamed spin button. Mirrors the same fix in DisplayCard's `Field`; the two stay
* separate on purpose (this card's axes are its own).
*/
const Field: FC<{
label: string;
help?: string;
children: ReactNode;
htmlFor?: string;
group?: boolean;
}> = ({ label, help, children, htmlFor, group }) => {
const body = (
<>
<Label className="block" htmlFor={htmlFor}>
{label}
</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</>
);
return group ? (
<fieldset className="space-y-3">
<legend className="mb-3 block text-sm font-medium leading-none">
{label}
</legend>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</fieldset>
) : (
<div className="space-y-3">{body}</div>
);
};
const Choice: FC<{
selected: boolean;
disabled: boolean;
onClick: () => void;
children: ReactNode;
}> = ({ selected, disabled, onClick, children }) => (
<Button
type="button"
variant={selected ? "default" : "outline"}
size="sm"
disabled={disabled}
aria-pressed={selected}
className={cn(disabled && "opacity-60")}
onClick={onClick}
>
{children}
</Button>
);