feat(vdisplay): lifecycle state machine + display state/release API (Stage 1)

Stage 1 of design/display-management.md — the lifecycle core + the display
management surface:

- vdisplay/lifecycle.rs: pure per-slot state machine (Idle/Active{refs}/
  Lingering{until}/Pinned) with acquire/release/expiry/force-release
  transitions. No I/O, no OS types — the platform-neutral distillation of the
  Windows manager's model. Unit + a 200k-iteration seeded property walk
  (no leaks / double-frees / refcount underflow across arbitrary interleavings).
- vdisplay/registry.rs: neutral snapshot/release facade over the per-OS
  lifecycle owners. Windows reads/controls the VirtualDisplayManager; Linux
  keep-alive (a per-session pool) lands in a following increment (needs GPU-box
  validation).
- windows/manager.rs: additive snapshot() + force_release() (no behavior change
  to the on-glass-validated path).
- mgmt: GET /api/v1/display/state (live/kept displays) + POST /api/v1/display/release
  (tear down lingering/pinned now; refuses active). OpenAPI regenerated.
- web console: Virtual displays card gains a live-display list (polled) with
  per-row + release-all buttons and a linger countdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 20:32:03 +00:00
parent bbd98241e4
commit 87f0ce7997
9 changed files with 889 additions and 1 deletions
+9
View File
@@ -68,6 +68,15 @@
"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.",
"display_live": "Aktive Displays",
"display_none_live": "Derzeit keine virtuellen Displays.",
"display_state_active": "Aktiv",
"display_state_lingering": "Wird gehalten",
"display_state_pinned": "Angeheftet",
"display_release_btn": "Freigeben",
"display_release_all": "Alle gehaltenen freigeben",
"display_expires_in": "Abbau in {sec}s",
"display_sessions": "{count} streamend",
"clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
+9
View File
@@ -68,6 +68,15 @@
"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.",
"display_live": "Live displays",
"display_none_live": "No virtual displays right now.",
"display_state_active": "Active",
"display_state_lingering": "Lingering",
"display_state_pinned": "Pinned",
"display_release_btn": "Release",
"display_release_all": "Release all kept",
"display_expires_in": "tears down in {sec}s",
"display_sessions": "{count} streaming",
"clients_title": "Paired clients",
"clients_empty": "No paired clients yet.",
"clients_name": "Name",
+93
View File
@@ -2,10 +2,14 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { type FC, useEffect, useState } from "react";
import {
getGetDisplayStateQueryKey,
getGetDisplaySettingsQueryKey,
useGetDisplaySettings,
useGetDisplayState,
useReleaseDisplay,
useSetDisplaySettings,
} from "@/api/gen/display/display";
import type { ApiDisplayInfo } from "@/api/gen/model";
import { ApiError } from "@/api/fetcher";
import type {
DisplayPolicy,
@@ -70,11 +74,100 @@ export const DisplaySection: FC = () => {
/>
)}
</QueryState>
<LiveDisplays />
</CardContent>
</Card>
);
};
/**
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
* for lingering/pinned ones (active displays can't be released — that's session control).
*/
const LiveDisplays: FC = () => {
const qc = useQueryClient();
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
const release = useReleaseDisplay();
const displays = state.data?.displays ?? [];
const kept = displays.filter((d) => d.state !== "active");
const doRelease = (slot?: number) =>
release.mutate(
{ data: { slot: slot ?? null } },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
);
return (
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between gap-4">
<h4 className="text-sm font-medium">{m.display_live()}</h4>
{kept.length > 0 && (
<Button
size="sm"
variant="outline"
disabled={release.isPending}
onClick={() => doRelease()}
>
{m.display_release_all()}
</Button>
)}
</div>
{displays.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
) : (
<ul className="divide-y rounded-md border">
{displays.map((d) => (
<DisplayRow
key={d.slot}
d={d}
busy={release.isPending}
onRelease={() => doRelease(d.slot)}
/>
))}
</ul>
)}
</div>
);
};
const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({
d,
busy,
onRelease,
}) => {
const active = d.state === "active";
const stateLabel =
d.state === "active"
? m.display_state_active()
: d.state === "pinned"
? m.display_state_pinned()
: m.display_state_lingering();
return (
<li className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{d.mode}</span>
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
{active && d.sessions > 0 && (
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge>
)}
</div>
<code className="text-xs text-muted-foreground">
{d.backend}
{d.expires_in_ms != null
? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}`
: ""}
</code>
</div>
{!active && (
<Button size="sm" variant="outline" disabled={busy} onClick={onRelease}>
{m.display_release_btn()}
</Button>
)}
</li>
);
};
/** 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) {