feat(vdisplay): display-management policy surface (Stage 0)
A user-configurable policy layer above the per-compositor VirtualDisplay backends: keep-alive, topology, conflict, identity, layout, max-displays — persisted to display-settings.json, editable from the web console, applied per connect. Design: design/display-management.md. Stage 0 stands up the surface and wires the two behaviors the existing code can already express — the Windows monitor linger duration and the "make the streamed output the sole desktop" topology — through it; every other option is stored + echoed but not yet enforced (later stages). An unconfigured host (no display-settings.json) keeps today's exact behavior. - vdisplay/policy.rs: pure DisplayPolicy + 5 presets + JSON store (gpu-settings pattern) + EffectivePolicy; 9 unit tests. - vdisplay.rs: resolve_topology(Auto); apply_session_env drives *_VIRTUAL_PRIMARY from the policy only when a settings file exists. - windows/manager.rs: linger_ms() + should_isolate() read the policy when configured. - mgmt: GET/PUT /api/v1/display/settings (bearer-only); PUT rejects keep_alive forever until the lifecycle stage. OpenAPI regenerated. - web console: Host → Virtual displays card (preset picker + custom fields); en+de. - docs-site: virtual-displays.md + configuration.md cross-links. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,27 @@
|
||||
"gpu_none": "Keine GPUs erkannt.",
|
||||
"gpu_missing_warning": "Die bevorzugte GPU „{name}“ ist nicht vorhanden — stattdessen wird automatisch gewählt.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} bindet die GPU im Automatikmodus.",
|
||||
"host_displays": "Virtuelle Displays",
|
||||
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
|
||||
"display_preset": "Voreinstellung",
|
||||
"display_preset_custom": "Benutzerdefiniert",
|
||||
"display_preset_default": "Standard",
|
||||
"display_preset_gaming_rig": "Gaming-Rig",
|
||||
"display_preset_shared_desktop": "Geteilter Desktop",
|
||||
"display_preset_hotdesk": "Hot-Desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Nach Trennung aktiv halten",
|
||||
"display_keep_alive_off": "Aus",
|
||||
"display_keep_alive_seconds": "Sekunden",
|
||||
"display_topology": "Topologie",
|
||||
"display_topology_auto": "Automatisch",
|
||||
"display_topology_extend": "Erweitern",
|
||||
"display_topology_primary": "Primär",
|
||||
"display_topology_exclusive": "Exklusiv",
|
||||
"display_max": "Max. Displays",
|
||||
"display_save": "Speichern",
|
||||
"display_effective": "Aktiv",
|
||||
"display_pending_note": "Konfliktbehandlung, Identität und Layout werden gespeichert, aber noch nicht angewendet — sie folgen in späteren Versionen.",
|
||||
"clients_title": "Gekoppelte Geräte",
|
||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||
"clients_name": "Name",
|
||||
|
||||
@@ -47,6 +47,27 @@
|
||||
"gpu_none": "No GPUs detected.",
|
||||
"gpu_missing_warning": "The preferred GPU “{name}” is not present — automatic selection is used instead.",
|
||||
"gpu_env_note": "PUNKTFUNK_RENDER_ADAPTER={value} pins the GPU while in automatic mode.",
|
||||
"host_displays": "Virtual displays",
|
||||
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
|
||||
"display_preset": "Preset",
|
||||
"display_preset_custom": "Custom",
|
||||
"display_preset_default": "Default",
|
||||
"display_preset_gaming_rig": "Gaming rig",
|
||||
"display_preset_shared_desktop": "Shared desktop",
|
||||
"display_preset_hotdesk": "Hot-desk",
|
||||
"display_preset_workstation": "Workstation",
|
||||
"display_keep_alive": "Keep alive after disconnect",
|
||||
"display_keep_alive_off": "Off",
|
||||
"display_keep_alive_seconds": "seconds",
|
||||
"display_topology": "Topology",
|
||||
"display_topology_auto": "Automatic",
|
||||
"display_topology_extend": "Extend",
|
||||
"display_topology_primary": "Primary",
|
||||
"display_topology_exclusive": "Exclusive",
|
||||
"display_max": "Max displays",
|
||||
"display_save": "Save",
|
||||
"display_effective": "In effect",
|
||||
"display_pending_note": "Conflict handling, identity, and layout are stored but not enforced yet — they arrive in later releases.",
|
||||
"clients_title": "Paired clients",
|
||||
"clients_empty": "No paired clients yet.",
|
||||
"clients_name": "Name",
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@unom/ui/button";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import {
|
||||
getGetDisplaySettingsQueryKey,
|
||||
useGetDisplaySettings,
|
||||
useSetDisplaySettings,
|
||||
} from "@/api/gen/display/display";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import type {
|
||||
DisplayPolicy,
|
||||
EffectivePolicy,
|
||||
KeepAlive,
|
||||
Preset,
|
||||
Topology,
|
||||
} from "@/api/gen/model";
|
||||
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 { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* Container: the host's virtual-display management policy (design/display-management.md). Reads the
|
||||
* stored policy + preset expansions, lets the operator pick a preset or set Custom fields, and PUTs
|
||||
* the result — a change applies to the next session. Stage 0 enforces keep-alive + topology; the
|
||||
* other stored options are shown but marked not-yet-enforced.
|
||||
*/
|
||||
export const DisplaySection: FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const q = useGetDisplaySettings();
|
||||
const save = useSetDisplaySettings();
|
||||
|
||||
// Local edit buffer, seeded once from the server and re-seeded after a successful save.
|
||||
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
|
||||
useEffect(() => {
|
||||
if (q.data && draft === null) setDraft(q.data.settings);
|
||||
}, [q.data, draft]);
|
||||
|
||||
const onSave = () => {
|
||||
if (!draft) return;
|
||||
save.mutate(
|
||||
{ data: draft },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setDraft(res.settings);
|
||||
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.host_displays()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
|
||||
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
|
||||
{q.data && draft && (
|
||||
<DisplayForm
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
presets={q.data.presets}
|
||||
onSave={onSave}
|
||||
busy={save.isPending}
|
||||
error={apiErrorMessage(save.error)}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
|
||||
const apiErrorMessage = (err: unknown): string | undefined => {
|
||||
if (err instanceof ApiError) {
|
||||
const data = err.data as { error?: string } | undefined;
|
||||
return data?.error ?? err.message;
|
||||
}
|
||||
return err ? String(err) : undefined;
|
||||
};
|
||||
|
||||
/** The `gaming-rig` preset expands to `keep_alive: forever`, which the host rejects until the
|
||||
* display-lifecycle stage — disable it rather than let the Save 400. */
|
||||
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
|
||||
|
||||
const PRESET_LABEL: Record<string, () => string> = {
|
||||
custom: m.display_preset_custom,
|
||||
default: m.display_preset_default,
|
||||
"gaming-rig": m.display_preset_gaming_rig,
|
||||
"shared-desktop": m.display_preset_shared_desktop,
|
||||
hotdesk: m.display_preset_hotdesk,
|
||||
workstation: m.display_preset_workstation,
|
||||
};
|
||||
|
||||
const TOPOLOGY_LABEL: Record<Topology, () => string> = {
|
||||
auto: m.display_topology_auto,
|
||||
extend: m.display_topology_extend,
|
||||
primary: m.display_topology_primary,
|
||||
exclusive: m.display_topology_exclusive,
|
||||
};
|
||||
|
||||
const fmtKeepAlive = (k: KeepAlive): string => {
|
||||
switch (k.mode) {
|
||||
case "off":
|
||||
return m.display_keep_alive_off();
|
||||
case "duration":
|
||||
return `${k.seconds} ${m.display_keep_alive_seconds()}`;
|
||||
case "forever":
|
||||
return "∞";
|
||||
}
|
||||
};
|
||||
|
||||
const DisplayForm: FC<{
|
||||
draft: DisplayPolicy;
|
||||
setDraft: (p: DisplayPolicy) => void;
|
||||
presets: { id: string; summary: string; fields: EffectivePolicy }[];
|
||||
onSave: () => void;
|
||||
busy: boolean;
|
||||
error?: string;
|
||||
}> = ({ draft, setDraft, presets, onSave, busy, error }) => {
|
||||
const preset: Preset = draft.preset ?? "custom";
|
||||
const isCustom = preset === "custom";
|
||||
const keepAlive: KeepAlive = draft.keep_alive ?? { mode: "duration", seconds: 10 };
|
||||
const topology: Topology = draft.topology ?? "auto";
|
||||
|
||||
// Preview the effective fields: from the selected preset's expansion, or the Custom fields.
|
||||
const effective: EffectivePolicy | undefined = isCustom
|
||||
? {
|
||||
keep_alive: keepAlive,
|
||||
topology,
|
||||
mode_conflict: draft.mode_conflict ?? "separate",
|
||||
identity: draft.identity ?? "per-client",
|
||||
layout: draft.layout ?? { mode: "auto-row", positions: {} },
|
||||
max_displays: draft.max_displays ?? 4,
|
||||
}
|
||||
: presets.find((p) => p.id === preset)?.fields;
|
||||
|
||||
const presetSummary = presets.find((p) => p.id === preset)?.summary;
|
||||
|
||||
const secondsValue = keepAlive.mode === "duration" ? keepAlive.seconds : 300;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Preset picker */}
|
||||
<div className="space-y-2">
|
||||
<Label>{m.display_preset()}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["custom", "default", "gaming-rig", "shared-desktop", "hotdesk", "workstation"] as const).map(
|
||||
(id) => (
|
||||
<Button
|
||||
key={id}
|
||||
size="sm"
|
||||
variant={preset === id ? "default" : "outline"}
|
||||
disabled={busy || DISABLED_PRESETS.has(id)}
|
||||
onClick={() => setDraft({ ...draft, preset: id as Preset })}
|
||||
>
|
||||
{(PRESET_LABEL[id] ?? (() => id))()}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{presetSummary && !isCustom && (
|
||||
<p className="text-xs text-muted-foreground">{presetSummary}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom fields: keep-alive + topology + max displays */}
|
||||
{isCustom && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{m.display_keep_alive()}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={keepAlive.mode === "off" ? "default" : "outline"}
|
||||
disabled={busy}
|
||||
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
|
||||
>
|
||||
{m.display_keep_alive_off()}
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
value={secondsValue}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
keep_alive: {
|
||||
mode: "duration",
|
||||
seconds: Math.max(0, Number(e.target.value) || 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{m.display_keep_alive_seconds()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{m.display_topology()}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["auto", "extend", "primary", "exclusive"] as const).map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
size="sm"
|
||||
variant={topology === t ? "default" : "outline"}
|
||||
disabled={busy}
|
||||
onClick={() => setDraft({ ...draft, topology: t })}
|
||||
>
|
||||
{TOPOLOGY_LABEL[t]()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disp-max">{m.display_max()}</Label>
|
||||
<Input
|
||||
id="disp-max"
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
className="w-24"
|
||||
value={draft.max_displays ?? 4}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effective preview */}
|
||||
{effective && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
|
||||
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
|
||||
<Badge variant="secondary">{TOPOLOGY_LABEL[effective.topology]()}</Badge>
|
||||
<Badge variant="outline">{effective.mode_conflict}</Badge>
|
||||
<Badge variant="outline">{effective.identity}</Badge>
|
||||
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>
|
||||
)}
|
||||
|
||||
<Button onClick={onSave} disabled={busy}>
|
||||
{m.display_save()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
import { useGetHostInfo, useListCompositors } from "@/api/gen/host/host";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { DisplaySection } from "./DisplayCard";
|
||||
import { GpuSection } from "./GpuCard";
|
||||
import { HostView } from "./view";
|
||||
|
||||
@@ -10,6 +11,11 @@ export const SectionHost: FC = () => {
|
||||
const compositors = useListCompositors();
|
||||
|
||||
return (
|
||||
<HostView host={host} compositors={compositors} gpu={<GpuSection />} />
|
||||
<HostView
|
||||
host={host}
|
||||
compositors={compositors}
|
||||
gpu={<GpuSection />}
|
||||
displays={<DisplaySection />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,9 @@ export const HostView: FC<{
|
||||
compositors: Loadable<AvailableCompositor[]>;
|
||||
/** The GPU inventory/selection card (a self-contained container — see `GpuCard.tsx`). */
|
||||
gpu?: ReactNode;
|
||||
}> = ({ host, compositors, gpu }) => {
|
||||
/** The virtual-display management card (self-contained container — see `DisplayCard.tsx`). */
|
||||
displays?: ReactNode;
|
||||
}> = ({ host, compositors, gpu, displays }) => {
|
||||
const h = host.data;
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
@@ -81,6 +83,8 @@ export const HostView: FC<{
|
||||
|
||||
{gpu}
|
||||
|
||||
{displays}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m.host_compositors()}</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user