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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user