feat: the streamed screen is a persisted setting, pickable from the console

P4 of design/per-monitor-portal-capture.md. The pin was an env var read
once at startup, which a console picker can never write — so it becomes a
field of the display policy (orthogonal to presets, like game_session), and
`vdisplay::capture_monitor()` resolves env-over-policy: an appliance that
pinned in its unit's host.env stays pinned there, and a console click
cannot re-aim a machine whose operator already declared the answer.

Read per `open` rather than cached, so a picker change takes effect on the
next session instead of the next host restart. The host re-resolves the
input anchor on every policy write — including clearing it when the pin is
cleared, or a later virtual-display session inherits an anchor aimed at a
monitor it is not showing.

`with_manual_layout` carries the pin through like the other orthogonal
axes: without that, saving a display ARRANGEMENT would silently swap the
streamed screen back to virtual.

Console: a "Streamed screen" card listing the host's real monitors beside
"Virtual screen (default)", saving on selection. A disabled head is listed
but not selectable (so "why isn't it here?" has an answer), and an
env-pinned host renders read-only with the reason rather than offering
controls that silently lose to the environment.

Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes
sessions to the mirror and anchors input; setting the env to a different
connector overrides it, exactly as documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:51:41 +02:00
co-authored by Claude Opus 5
parent 358cfa4be4
commit d461d889c3
10 changed files with 1291 additions and 893 deletions
+160
View File
@@ -0,0 +1,160 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import { ApiError } from "@/api/fetcher";
import {
getGetDisplayMonitorsQueryKey,
getGetDisplaySettingsQueryKey,
useGetDisplayMonitors,
useGetDisplaySettings,
useSetDisplaySettings,
} from "@/api/gen/display/display";
import type { ApiMonitorInfo } from "@/api/gen/model";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QueryState } from "@/components/query-state";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
/**
* **Streamed screen** (design/per-monitor-portal-capture.md §5.3): stream one of the host's real
* monitors instead of creating a virtual display per client.
*
* Deliberately a HOST-wide choice, not per-client — it is the decision of record for this feature,
* and it is also what keeps input honest: the injector is host-lifetime and shared by every
* concurrent session, so "which screen do absolute coordinates land on" can only have one answer.
*
* Saves on selection (like the policy card above) — there is no Save button to miss. The pin is a
* field of the display policy, so it rides the same PUT.
*/
export const MonitorCard: FC = () => {
const qc = useQueryClient();
const monitors = useGetDisplayMonitors();
const settings = useGetDisplaySettings();
const save = useSetDisplaySettings();
const policy = settings.data?.settings;
const rows = monitors.data?.monitors ?? [];
// The host reports the EFFECTIVE pin — an env-pinned appliance shows its real answer here even
// though the console cannot change it.
const pinned = monitors.data?.pinned ?? null;
// `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's
// environment is read-only here: offering controls that silently lose to the env would be worse
// than saying so.
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
const choose = (connector: string | null) => {
if (!policy || envLocked) return;
save.mutate(
{ data: { ...policy, capture_monitor: connector } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
qc.invalidateQueries({ queryKey: getGetDisplayMonitorsQueryKey() });
toast.success(m.display_monitor_saved());
},
},
);
};
const busy = save.isPending;
const error = save.error instanceof ApiError ? save.error.message : undefined;
const row = (
key: string,
selected: boolean,
title: string,
hint: string,
tags?: ReturnType<typeof Badge>[],
onSelect?: () => void,
) => (
<button
key={key}
type="button"
disabled={busy || envLocked || !onSelect}
onClick={onSelect}
aria-pressed={selected}
className={cn(
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
(busy || envLocked) && "cursor-not-allowed opacity-60",
)}
>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-2 font-medium">
{title}
{tags}
</span>
<span className="text-sm text-muted-foreground">{hint}</span>
</span>
</button>
);
const monitorRow = (mon: ApiMonitorInfo) => {
const tags = [
mon.primary ? (
<Badge key="p" variant="secondary">
{m.display_monitor_primary()}
</Badge>
) : null,
!mon.enabled ? (
<Badge key="d" variant="outline">
{m.display_monitor_disabled()}
</Badge>
) : null,
].filter(Boolean) as ReturnType<typeof Badge>[];
return row(
mon.connector,
pinned?.toLowerCase() === mon.connector.toLowerCase(),
`${mon.connector}${mon.description}`,
`${mon.mode} · ${m.display_monitor_mirror_hint()}`,
tags,
// A disabled head cannot be streamed (the host refuses with that reason), so don't
// offer it as a choice — it is listed so "why isn't it here?" has an answer.
mon.enabled && !mon.managed ? () => choose(mon.connector) : undefined,
);
};
return (
<Card>
<CardHeader>
<CardTitle>{m.display_monitor_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.display_monitor_intro()}
</p>
{envLocked && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.display_monitor_env_locked()}
</p>
)}
<QueryState
isLoading={monitors.isLoading}
error={monitors.error}
refetch={monitors.refetch}
>
<div className="flex flex-col gap-2">
{row(
"__virtual__",
!pinned,
m.display_monitor_virtual(),
m.display_monitor_virtual_hint(),
undefined,
() => choose(null),
)}
{rows.map(monitorRow)}
{rows.length === 0 && (
<p className="text-sm text-muted-foreground">
{monitors.data?.error
? m.display_monitor_unavailable()
: m.display_monitor_none()}
</p>
)}
</div>
</QueryState>
{error && <p className="text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
);
};
+2
View File
@@ -3,6 +3,7 @@ import type { FC } from "react";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { DisplaySection } from "./DisplayCard";
import { MonitorCard } from "./MonitorCard";
import { SessionGameCard } from "./SessionGameCard";
/**
@@ -21,6 +22,7 @@ export const SectionDisplays: FC = () => {
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
<DisplaySection />
<MonitorCard />
<SessionGameCard />
</div>
</Section>