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:
@@ -138,6 +138,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/display/release": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Release kept virtual displays",
|
||||||
|
"description": "Tear down lingering/pinned displays now — so a physical-screen user gets their screen back\nwithout waiting out the linger. `slot` releases one; omit it to release all kept displays.\nActive (streaming) displays are never torn down here (that is session control).",
|
||||||
|
"operationId": "releaseDisplay",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReleaseDisplayRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The number of kept displays released",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReleaseDisplayResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/display/settings": {
|
"/api/v1/display/settings": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -230,6 +272,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/display/state": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"display"
|
||||||
|
],
|
||||||
|
"summary": "Live virtual displays",
|
||||||
|
"description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.",
|
||||||
|
"operationId": "getDisplayState",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The live/kept virtual displays",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DisplayStateResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/gpus": {
|
"/api/v1/gpus": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1693,6 +1767,59 @@
|
|||||||
"av1"
|
"av1"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ApiDisplayInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One live or kept virtual display.",
|
||||||
|
"required": [
|
||||||
|
"slot",
|
||||||
|
"backend",
|
||||||
|
"mode",
|
||||||
|
"state",
|
||||||
|
"sessions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"backend": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Backend name (`pf-vdisplay`, `kwin`, …)."
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Short client label, when the owner tracks it."
|
||||||
|
},
|
||||||
|
"expires_in_ms": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Milliseconds until a lingering display is torn down (absent when active/pinned).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`WIDTHxHEIGHT@HZ`."
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Live sessions holding the display.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"slot": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Stable-enough id for the `/display/release` `slot` argument.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`active` | `lingering` | `pinned`."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ApiError": {
|
"ApiError": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Error envelope for every non-2xx response.",
|
"description": "Error envelope for every non-2xx response.",
|
||||||
@@ -2076,6 +2203,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DisplayStateResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The host's managed virtual displays right now.",
|
||||||
|
"required": [
|
||||||
|
"displays"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"displays": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ApiDisplayInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"EffectivePolicy": {
|
"EffectivePolicy": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
|
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
|
||||||
@@ -2795,6 +2937,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ReleaseDisplayRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request body for `releaseDisplay`.",
|
||||||
|
"properties": {
|
||||||
|
"slot": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Slot to release (see `state`); omit to release **all** kept displays.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ReleaseDisplayResult": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Result of a `/display/release`.",
|
||||||
|
"required": [
|
||||||
|
"released"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"released": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of kept displays torn down.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RuntimeStatus": {
|
"RuntimeStatus": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Live host status (changes as clients launch/end sessions).",
|
"description": "Live host status (changes as clients launch/end sessions).",
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(set_gpu_preference))
|
.routes(routes!(set_gpu_preference))
|
||||||
.routes(routes!(get_display_settings))
|
.routes(routes!(get_display_settings))
|
||||||
.routes(routes!(set_display_settings))
|
.routes(routes!(set_display_settings))
|
||||||
|
.routes(routes!(get_display_state))
|
||||||
|
.routes(routes!(release_display))
|
||||||
.routes(routes!(get_status))
|
.routes(routes!(get_status))
|
||||||
.routes(routes!(get_local_summary))
|
.routes(routes!(get_local_summary))
|
||||||
.routes(routes!(list_paired_clients))
|
.routes(routes!(list_paired_clients))
|
||||||
@@ -1095,6 +1097,104 @@ async fn set_display_settings(
|
|||||||
Json(display_settings_state()).into_response()
|
Json(display_settings_state()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One live or kept virtual display.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct ApiDisplayInfo {
|
||||||
|
/// Stable-enough id for the `/display/release` `slot` argument.
|
||||||
|
slot: u64,
|
||||||
|
/// Backend name (`pf-vdisplay`, `kwin`, …).
|
||||||
|
backend: String,
|
||||||
|
/// `WIDTHxHEIGHT@HZ`.
|
||||||
|
mode: String,
|
||||||
|
/// `active` | `lingering` | `pinned`.
|
||||||
|
state: String,
|
||||||
|
/// Milliseconds until a lingering display is torn down (absent when active/pinned).
|
||||||
|
expires_in_ms: Option<u64>,
|
||||||
|
/// Live sessions holding the display.
|
||||||
|
sessions: u32,
|
||||||
|
/// Short client label, when the owner tracks it.
|
||||||
|
client: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host's managed virtual displays right now.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct DisplayStateResponse {
|
||||||
|
displays: Vec<ApiDisplayInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `releaseDisplay`.
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
struct ReleaseDisplayRequest {
|
||||||
|
/// Slot to release (see `state`); omit to release **all** kept displays.
|
||||||
|
#[serde(default)]
|
||||||
|
slot: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a `/display/release`.
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
struct ReleaseDisplayResult {
|
||||||
|
/// Number of kept displays torn down.
|
||||||
|
released: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live virtual displays
|
||||||
|
///
|
||||||
|
/// The host's managed virtual displays right now — active (streaming), lingering (kept after
|
||||||
|
/// disconnect, counting down to teardown), or pinned (kept indefinitely). See
|
||||||
|
/// `design/display-management.md`.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/display/state",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "getDisplayState",
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The live/kept virtual displays", body = DisplayStateResponse),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_display_state() -> Json<DisplayStateResponse> {
|
||||||
|
let snap = crate::vdisplay::registry::snapshot();
|
||||||
|
Json(DisplayStateResponse {
|
||||||
|
displays: snap
|
||||||
|
.displays
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| ApiDisplayInfo {
|
||||||
|
slot: d.slot,
|
||||||
|
backend: d.backend,
|
||||||
|
mode: format!("{}x{}@{}", d.mode.0, d.mode.1, d.mode.2),
|
||||||
|
state: d.state,
|
||||||
|
expires_in_ms: d.expires_in_ms,
|
||||||
|
sessions: d.sessions,
|
||||||
|
client: d.client,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release kept virtual displays
|
||||||
|
///
|
||||||
|
/// Tear down lingering/pinned displays now — so a physical-screen user gets their screen back
|
||||||
|
/// without waiting out the linger. `slot` releases one; omit it to release all kept displays.
|
||||||
|
/// Active (streaming) displays are never torn down here (that is session control).
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/display/release",
|
||||||
|
tag = "display",
|
||||||
|
operation_id = "releaseDisplay",
|
||||||
|
request_body = ReleaseDisplayRequest,
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "The number of kept displays released", body = ReleaseDisplayResult),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn release_display(
|
||||||
|
ApiJson(req): ApiJson<ReleaseDisplayRequest>,
|
||||||
|
) -> Json<ReleaseDisplayResult> {
|
||||||
|
let released = crate::vdisplay::registry::release(req.slot);
|
||||||
|
tracing::info!(slot = ?req.slot, released, "management API: display release");
|
||||||
|
Json(ReleaseDisplayResult { released })
|
||||||
|
}
|
||||||
|
|
||||||
/// Live host status
|
/// Live host status
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -2650,7 +2750,10 @@ mod tests {
|
|||||||
let (status, body) = send(&app, put).await;
|
let (status, body) = send(&app, put).await;
|
||||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||||
assert!(
|
assert!(
|
||||||
body["error"].as_str().unwrap_or_default().contains("forever"),
|
body["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("forever"),
|
||||||
"the rejection names the unsupported option"
|
"the rejection names the unsupported option"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -752,6 +752,16 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
|||||||
#[path = "vdisplay/policy.rs"]
|
#[path = "vdisplay/policy.rs"]
|
||||||
pub(crate) mod policy;
|
pub(crate) mod policy;
|
||||||
|
|
||||||
|
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
|
||||||
|
// property-tested; the registry executes the side effects its transitions dictate.
|
||||||
|
#[path = "vdisplay/lifecycle.rs"]
|
||||||
|
pub(crate) mod lifecycle;
|
||||||
|
|
||||||
|
// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool
|
||||||
|
// later), for the management API's /display/state + /display/release.
|
||||||
|
#[path = "vdisplay/registry.rs"]
|
||||||
|
pub(crate) mod registry;
|
||||||
|
|
||||||
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
|
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
|
||||||
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
|
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
|
||||||
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
|
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
//! Pure per-display **lifecycle state machine** (design: `design/display-management.md` §3).
|
||||||
|
//!
|
||||||
|
//! One virtual display's earned refcount + linger + pin state, with **no I/O and no OS-specific
|
||||||
|
//! types** — the registry ([`super::registry`]) executes the side effects (backend create /
|
||||||
|
//! teardown / linger timer) that this machine's transitions dictate. Extracted so the lifecycle
|
||||||
|
//! logic is unit- and property-testable in isolation, and so the Linux registry and (later) the
|
||||||
|
//! Windows manager share one audited machine instead of each re-deriving refcount+linger by hand.
|
||||||
|
//!
|
||||||
|
//! It is the platform-neutral distillation of the model the Windows `VirtualDisplayManager` already
|
||||||
|
//! runs on glass: `Idle → Active{refs} → Lingering{until} → Idle`, plus a `Pinned` state for
|
||||||
|
//! keep-alive-forever. The registry pairs one [`State`] with the owned backend resource; the machine
|
||||||
|
//! only tracks the discriminant + refcount + deadline and reports what to do.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use super::policy::Linger;
|
||||||
|
|
||||||
|
/// The lifecycle state of one virtual-display slot.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum State {
|
||||||
|
/// No display exists.
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
/// A display exists with `refs` live sessions holding it.
|
||||||
|
Active { refs: u32 },
|
||||||
|
/// The last session left; the display is kept until `until`, then torn down.
|
||||||
|
Lingering { until: Instant },
|
||||||
|
/// The last session left; the display is kept indefinitely (keep-alive forever), until an
|
||||||
|
/// explicit release.
|
||||||
|
Pinned,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What acquiring a slot means for the backend.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Acquire {
|
||||||
|
/// The slot was empty — the backend must CREATE a fresh display.
|
||||||
|
Create,
|
||||||
|
/// The slot was already Active — another session JOINS the live display (refcount++).
|
||||||
|
Join,
|
||||||
|
/// The slot was kept alive (Lingering/Pinned) — REUSE the existing display (re-attach capture).
|
||||||
|
Reuse,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What releasing a hold on a slot means for the backend.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Release {
|
||||||
|
/// Another session still holds the display — nothing to do.
|
||||||
|
Decref,
|
||||||
|
/// The last session left; keep the display until its deadline ([`State::Lingering`]), then tear down.
|
||||||
|
Linger,
|
||||||
|
/// The last session left; keep the display indefinitely ([`State::Pinned`]).
|
||||||
|
Pin,
|
||||||
|
/// The last session left and keep-alive is off — tear the display down now.
|
||||||
|
Teardown,
|
||||||
|
/// A release with no live hold (stale/duplicate) — no-op.
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
/// True while a backend display resource exists (Active/Lingering/Pinned) — the registry holds
|
||||||
|
/// the keepalive in exactly these states, and `Idle` means it has been dropped.
|
||||||
|
pub fn has_display(self) -> bool {
|
||||||
|
!matches!(self, State::Idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of live sessions holding the display (0 unless Active).
|
||||||
|
pub fn refs(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
State::Active { refs } => refs,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session acquires the slot. Transitions the state and reports whether the backend must
|
||||||
|
/// create a fresh display, join the live one, or reuse the kept one.
|
||||||
|
pub fn acquire(&mut self) -> Acquire {
|
||||||
|
match *self {
|
||||||
|
State::Idle => {
|
||||||
|
*self = State::Active { refs: 1 };
|
||||||
|
Acquire::Create
|
||||||
|
}
|
||||||
|
State::Active { refs } => {
|
||||||
|
*self = State::Active { refs: refs + 1 };
|
||||||
|
Acquire::Join
|
||||||
|
}
|
||||||
|
State::Lingering { .. } | State::Pinned => {
|
||||||
|
*self = State::Active { refs: 1 };
|
||||||
|
Acquire::Reuse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session releases the slot. When the LAST session leaves, `now` + the resolved `linger`
|
||||||
|
/// decide the kept state. Returns what the registry should do.
|
||||||
|
pub fn release(&mut self, now: Instant, linger: Linger) -> Release {
|
||||||
|
match *self {
|
||||||
|
State::Active { refs } if refs > 1 => {
|
||||||
|
*self = State::Active { refs: refs - 1 };
|
||||||
|
Release::Decref
|
||||||
|
}
|
||||||
|
State::Active { .. } => match linger {
|
||||||
|
Linger::Immediate => {
|
||||||
|
*self = State::Idle;
|
||||||
|
Release::Teardown
|
||||||
|
}
|
||||||
|
Linger::For(d) => {
|
||||||
|
*self = State::Lingering { until: now + d };
|
||||||
|
Release::Linger
|
||||||
|
}
|
||||||
|
Linger::Forever => {
|
||||||
|
*self = State::Pinned;
|
||||||
|
Release::Pin
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Releasing a slot with no live hold is a stale/duplicate release. The registry's
|
||||||
|
// gen-stamped leases already make a stale lease's drop a no-op before it reaches here;
|
||||||
|
// this is the defensive backstop.
|
||||||
|
State::Idle | State::Lingering { .. } | State::Pinned => Release::Noop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registry's linger-timer tick: a Lingering slot past its deadline goes Idle and returns
|
||||||
|
/// `true` (the registry tears the display down). Pinned and every other state are untouched.
|
||||||
|
pub fn poll_expiry(&mut self, now: Instant) -> bool {
|
||||||
|
match *self {
|
||||||
|
State::Lingering { until } if now >= until => {
|
||||||
|
*self = State::Idle;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-release a kept display (the `/display/release` endpoint): a Lingering/Pinned slot goes
|
||||||
|
/// Idle and the registry tears it down (`true`). An Active slot is refused (`false`) — releasing
|
||||||
|
/// a display that still has live sessions is session management, not display management. Idle → `false`.
|
||||||
|
pub fn force_release(&mut self) -> bool {
|
||||||
|
match *self {
|
||||||
|
State::Lingering { .. } | State::Pinned => {
|
||||||
|
*self = State::Idle;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
State::Active { .. } | State::Idle => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_join_reuse_and_teardown() {
|
||||||
|
let mut s = State::default();
|
||||||
|
assert_eq!(s.acquire(), Acquire::Create);
|
||||||
|
assert_eq!(s, State::Active { refs: 1 });
|
||||||
|
// A concurrent session joins.
|
||||||
|
assert_eq!(s.acquire(), Acquire::Join);
|
||||||
|
assert_eq!(s.refs(), 2);
|
||||||
|
// One leaves — still active.
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(s.release(now, Linger::Immediate), Release::Decref);
|
||||||
|
assert_eq!(s.refs(), 1);
|
||||||
|
// The last leaves with keep-alive off — teardown.
|
||||||
|
assert_eq!(s.release(now, Linger::Immediate), Release::Teardown);
|
||||||
|
assert_eq!(s, State::Idle);
|
||||||
|
assert!(!s.has_display());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linger_then_reuse_within_window() {
|
||||||
|
let mut s = State::default();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
s.acquire();
|
||||||
|
assert_eq!(
|
||||||
|
s.release(t0, Linger::For(Duration::from_secs(10))),
|
||||||
|
Release::Linger
|
||||||
|
);
|
||||||
|
assert!(s.has_display());
|
||||||
|
// A tick before the deadline does nothing.
|
||||||
|
assert!(!s.poll_expiry(t0 + Duration::from_secs(5)));
|
||||||
|
// A reconnect inside the window reuses the kept display.
|
||||||
|
assert_eq!(s.acquire(), Acquire::Reuse);
|
||||||
|
assert_eq!(s, State::Active { refs: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn linger_expires_to_teardown() {
|
||||||
|
let mut s = State::default();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
s.acquire();
|
||||||
|
s.release(t0, Linger::For(Duration::from_secs(10)));
|
||||||
|
// Past the deadline → teardown.
|
||||||
|
assert!(s.poll_expiry(t0 + Duration::from_secs(11)));
|
||||||
|
assert_eq!(s, State::Idle);
|
||||||
|
// A second tick is idempotent (nothing to tear down).
|
||||||
|
assert!(!s.poll_expiry(t0 + Duration::from_secs(12)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pinned_never_expires_but_force_releases() {
|
||||||
|
let mut s = State::default();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
s.acquire();
|
||||||
|
assert_eq!(s.release(t0, Linger::Forever), Release::Pin);
|
||||||
|
assert_eq!(s, State::Pinned);
|
||||||
|
// No amount of ticking tears a pinned display down.
|
||||||
|
assert!(!s.poll_expiry(t0 + Duration::from_secs(86_400)));
|
||||||
|
assert!(s.has_display());
|
||||||
|
// Only an explicit release does.
|
||||||
|
assert!(s.force_release());
|
||||||
|
assert_eq!(s, State::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_release_refuses_active() {
|
||||||
|
let mut s = State::default();
|
||||||
|
s.acquire();
|
||||||
|
assert!(
|
||||||
|
!s.force_release(),
|
||||||
|
"an active display can't be force-released"
|
||||||
|
);
|
||||||
|
assert_eq!(s.refs(), 1);
|
||||||
|
// Idle also can't.
|
||||||
|
let mut idle = State::default();
|
||||||
|
assert!(!idle.force_release());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_release_is_noop() {
|
||||||
|
let mut s = State::default();
|
||||||
|
assert_eq!(s.release(Instant::now(), Linger::Immediate), Release::Noop);
|
||||||
|
assert_eq!(s, State::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property test (deterministic seeded walk): across an arbitrary interleaving of acquire /
|
||||||
|
/// release / expiry-tick / force-release, the machine must never (a) leak or double-free the
|
||||||
|
/// backend resource — `has_display()` must exactly track a shadow "resource alive" flag, with
|
||||||
|
/// every Create preceded by no live resource and every teardown preceded by one — nor (b)
|
||||||
|
/// underflow the refcount, nor (c) tear a display down while a session still holds it.
|
||||||
|
#[test]
|
||||||
|
fn property_no_leaks_no_double_free_no_underflow() {
|
||||||
|
// Tiny deterministic LCG (Numerical Recipes) — reproducible, no dependency.
|
||||||
|
let mut rng: u64 = 0x1234_5678_9abc_def0;
|
||||||
|
let mut next = || {
|
||||||
|
rng = rng
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
(rng >> 33) as u32
|
||||||
|
};
|
||||||
|
|
||||||
|
let base = Instant::now();
|
||||||
|
let mut logical_ms: u64 = 0;
|
||||||
|
let mut s = State::default();
|
||||||
|
// Shadow model.
|
||||||
|
let mut resource_alive = false;
|
||||||
|
let mut live_holds: u32 = 0;
|
||||||
|
|
||||||
|
for _ in 0..200_000 {
|
||||||
|
// Advance logical time by 0..2000 ms each step so lingers cross their deadlines.
|
||||||
|
logical_ms += (next() % 2000) as u64;
|
||||||
|
let now = base + Duration::from_millis(logical_ms);
|
||||||
|
|
||||||
|
match next() % 5 {
|
||||||
|
0 => {
|
||||||
|
// acquire
|
||||||
|
let before_alive = resource_alive;
|
||||||
|
let a = s.acquire();
|
||||||
|
match a {
|
||||||
|
Acquire::Create => {
|
||||||
|
assert!(!before_alive, "Create while a resource was alive")
|
||||||
|
}
|
||||||
|
Acquire::Join | Acquire::Reuse => {
|
||||||
|
assert!(before_alive, "Join/Reuse with no live resource")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resource_alive = true;
|
||||||
|
live_holds += 1;
|
||||||
|
}
|
||||||
|
1 | 2 => {
|
||||||
|
// release (weighted 2/5 so refs actually drain)
|
||||||
|
let linger = match next() % 3 {
|
||||||
|
0 => Linger::Immediate,
|
||||||
|
1 => Linger::For(Duration::from_millis((next() % 3000) as u64 + 1)),
|
||||||
|
_ => Linger::Forever,
|
||||||
|
};
|
||||||
|
let held_before = live_holds;
|
||||||
|
let r = s.release(now, linger);
|
||||||
|
match r {
|
||||||
|
Release::Noop => assert_eq!(held_before, 0, "Noop only with no live hold"),
|
||||||
|
Release::Decref => {
|
||||||
|
assert!(held_before >= 2, "Decref must leave the display held");
|
||||||
|
live_holds -= 1;
|
||||||
|
}
|
||||||
|
Release::Teardown => {
|
||||||
|
assert_eq!(held_before, 1, "Teardown only on the last hold");
|
||||||
|
live_holds = 0;
|
||||||
|
resource_alive = false;
|
||||||
|
}
|
||||||
|
Release::Linger | Release::Pin => {
|
||||||
|
assert_eq!(held_before, 1, "Linger/Pin only on the last hold");
|
||||||
|
live_holds = 0;
|
||||||
|
// resource stays alive (kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
// expiry tick
|
||||||
|
if s.poll_expiry(now) {
|
||||||
|
assert_eq!(live_holds, 0, "expiry tore down a held display");
|
||||||
|
resource_alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// force release
|
||||||
|
if s.force_release() {
|
||||||
|
assert_eq!(live_holds, 0, "force-release tore down a held display");
|
||||||
|
resource_alive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invariant after every step: the machine's own view of "a display exists" matches the
|
||||||
|
// shadow, and the refcount matches the live-hold count.
|
||||||
|
assert_eq!(
|
||||||
|
s.has_display(),
|
||||||
|
resource_alive,
|
||||||
|
"has_display drifted from the shadow model"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.refs(),
|
||||||
|
live_holds,
|
||||||
|
"refs drifted from the live-hold count"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//! Neutral **facade over the per-OS virtual-display lifecycle owners**, for the management API's
|
||||||
|
//! `/display/state` + `/display/release` (design: `design/display-management.md` §7).
|
||||||
|
//!
|
||||||
|
//! Windows already owns its display lifecycle in [`super::manager::VirtualDisplayManager`] (one
|
||||||
|
//! shared IddCx monitor, refcounted, lingering); this facade reads and controls it. Linux keep-alive
|
||||||
|
//! (a per-session output pool driven by [`super::lifecycle`]) lands in a following increment — it
|
||||||
|
//! needs on-glass validation on a GPU box, which the current headless VM can't provide — so until
|
||||||
|
//! then the Linux side reports no managed displays and release is a no-op.
|
||||||
|
//!
|
||||||
|
//! The lifecycle *state machine* ([`super::lifecycle::State`]) is the platform-neutral core both
|
||||||
|
//! sides converge on; Windows adopts it when its manager is refactored onto it (that unification is
|
||||||
|
//! deferred so the on-glass-validated Windows path stays untouched this stage).
|
||||||
|
|
||||||
|
/// One live or kept virtual display, for the mgmt snapshot.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DisplayInfo {
|
||||||
|
/// A stable-enough id for the `/display/release` slot argument (the backend's generation stamp).
|
||||||
|
pub slot: u64,
|
||||||
|
/// Backend name (`"pf-vdisplay"`, `"kwin"`, …).
|
||||||
|
pub backend: String,
|
||||||
|
/// `(width, height, refresh_hz)`.
|
||||||
|
pub mode: (u32, u32, u32),
|
||||||
|
/// `"active"` | `"lingering"` | `"pinned"`.
|
||||||
|
pub state: String,
|
||||||
|
/// Milliseconds until a lingering display is torn down (`None` when active/pinned).
|
||||||
|
pub expires_in_ms: Option<u64>,
|
||||||
|
/// Live sessions holding the display.
|
||||||
|
pub sessions: u32,
|
||||||
|
/// Short client label (cert-fp prefix / peer), when the owner tracks it.
|
||||||
|
pub client: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live display set for the mgmt `/display/state` endpoint.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Snapshot {
|
||||||
|
pub displays: Vec<DisplayInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read);
|
||||||
|
/// safe per management request.
|
||||||
|
pub fn snapshot() -> Snapshot {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let displays = super::manager::snapshot()
|
||||||
|
.map(|i| DisplayInfo {
|
||||||
|
slot: i.gen,
|
||||||
|
backend: i.backend.to_string(),
|
||||||
|
mode: i.mode,
|
||||||
|
state: i.state.to_string(),
|
||||||
|
expires_in_ms: i.expires_in_ms,
|
||||||
|
sessions: i.sessions,
|
||||||
|
client: None,
|
||||||
|
})
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
Snapshot { displays }
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
// Linux keep-alive pool: not yet (needs GPU-box validation) — no managed displays to report.
|
||||||
|
Snapshot::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-release kept (lingering/pinned) displays now — the `/display/release` endpoint. `slot`
|
||||||
|
/// selects one by [`DisplayInfo::slot`]; `None` releases every kept display. Active displays are
|
||||||
|
/// refused (releasing a display with live sessions is session management). Returns the number
|
||||||
|
/// released.
|
||||||
|
pub fn release(_slot: Option<u64>) -> usize {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Windows manages a single shared monitor at Stage 1, so `slot` is moot — release the one
|
||||||
|
// lingering monitor if present. (Multi-monitor gives `slot` meaning later.)
|
||||||
|
usize::from(super::manager::force_release())
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -892,6 +892,81 @@ fn resolve_render_pin() -> Option<LUID> {
|
|||||||
crate::win_adapter::resolve_render_adapter_luid()
|
crate::win_adapter::resolve_render_adapter_luid()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
|
||||||
|
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
|
||||||
|
/// maps it into the wire shape.
|
||||||
|
pub(crate) struct ManagedInfo {
|
||||||
|
pub backend: &'static str,
|
||||||
|
pub mode: (u32, u32, u32),
|
||||||
|
/// `"active"` | `"lingering"`.
|
||||||
|
pub state: &'static str,
|
||||||
|
/// Milliseconds until a lingering monitor is torn down (`None` when active).
|
||||||
|
pub expires_in_ms: Option<u64>,
|
||||||
|
/// Live sessions holding the monitor.
|
||||||
|
pub sessions: u32,
|
||||||
|
/// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg.
|
||||||
|
pub gen: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VirtualDisplayManager {
|
||||||
|
/// Snapshot the current monitor for the mgmt `/display/state` endpoint. `None` when Idle.
|
||||||
|
pub(crate) fn snapshot(&self) -> Option<ManagedInfo> {
|
||||||
|
let st = self.state.lock().unwrap();
|
||||||
|
let (mon, state, sessions, expires_in_ms) = match &*st {
|
||||||
|
MgrState::Idle => return None,
|
||||||
|
MgrState::Active { mon, refs } => (mon, "active", *refs, None),
|
||||||
|
MgrState::Lingering { mon, until } => {
|
||||||
|
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64;
|
||||||
|
(mon, "lingering", 0u32, Some(ms))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(ManagedInfo {
|
||||||
|
backend: self.driver.name(),
|
||||||
|
mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz),
|
||||||
|
state,
|
||||||
|
expires_in_ms,
|
||||||
|
sessions,
|
||||||
|
gen: mon.gen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-tear-down a LINGERING monitor now (the `/display/release` endpoint) — so a
|
||||||
|
/// physical-screen user gets their screen back without waiting out the linger. An Active monitor
|
||||||
|
/// is refused (stopping a live session is session management, not display management). Returns
|
||||||
|
/// `true` if a lingering monitor was released.
|
||||||
|
pub(crate) fn force_release(&self) -> bool {
|
||||||
|
let Some(dev) = self.device_handle() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut st = self.state.lock().unwrap();
|
||||||
|
if matches!(&*st, MgrState::Lingering { .. }) {
|
||||||
|
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *st, MgrState::Idle) {
|
||||||
|
// SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()`
|
||||||
|
// (cached handles are never closed — a dead one is retired, kept alive; see
|
||||||
|
// `DeviceSlot`). `mon` was moved out of the `Lingering` state under the `state` lock,
|
||||||
|
// so it is exclusively owned here — no aliasing.
|
||||||
|
unsafe { self.teardown(dev, mon) };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot the managed monitor, or `None` when no backend has initialised the manager yet (no
|
||||||
|
/// session has ever run) or it is Idle. Safe to call per management request.
|
||||||
|
pub(crate) fn snapshot() -> Option<ManagedInfo> {
|
||||||
|
VDM.get().and_then(VirtualDisplayManager::snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-release a lingering monitor now; `false` if nothing was lingering (or the manager is
|
||||||
|
/// uninitialised).
|
||||||
|
pub(crate) fn force_release() -> bool {
|
||||||
|
VDM.get()
|
||||||
|
.map(VirtualDisplayManager::force_release)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Linger window before a session-less monitor is torn down. The console display-management policy
|
/// Linger window before a session-less monitor is torn down. The console display-management policy
|
||||||
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
|
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
|
||||||
/// else the 10 s default.
|
/// else the 10 s default.
|
||||||
|
|||||||
@@ -68,6 +68,15 @@
|
|||||||
"display_save": "Speichern",
|
"display_save": "Speichern",
|
||||||
"display_effective": "Aktiv",
|
"display_effective": "Aktiv",
|
||||||
"display_pending_note": "Konfliktbehandlung, Identität und Layout werden gespeichert, aber noch nicht angewendet — sie folgen in späteren Versionen.",
|
"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_title": "Gekoppelte Geräte",
|
||||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -68,6 +68,15 @@
|
|||||||
"display_save": "Save",
|
"display_save": "Save",
|
||||||
"display_effective": "In effect",
|
"display_effective": "In effect",
|
||||||
"display_pending_note": "Conflict handling, identity, and layout are stored but not enforced yet — they arrive in later releases.",
|
"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_title": "Paired clients",
|
||||||
"clients_empty": "No paired clients yet.",
|
"clients_empty": "No paired clients yet.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Button } from "@unom/ui/button";
|
import { Button } from "@unom/ui/button";
|
||||||
import { type FC, useEffect, useState } from "react";
|
import { type FC, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
getGetDisplayStateQueryKey,
|
||||||
getGetDisplaySettingsQueryKey,
|
getGetDisplaySettingsQueryKey,
|
||||||
useGetDisplaySettings,
|
useGetDisplaySettings,
|
||||||
|
useGetDisplayState,
|
||||||
|
useReleaseDisplay,
|
||||||
useSetDisplaySettings,
|
useSetDisplaySettings,
|
||||||
} from "@/api/gen/display/display";
|
} from "@/api/gen/display/display";
|
||||||
|
import type { ApiDisplayInfo } from "@/api/gen/model";
|
||||||
import { ApiError } from "@/api/fetcher";
|
import { ApiError } from "@/api/fetcher";
|
||||||
import type {
|
import type {
|
||||||
DisplayPolicy,
|
DisplayPolicy,
|
||||||
@@ -70,11 +74,100 @@ export const DisplaySection: FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
|
<LiveDisplays />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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. */
|
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
|
||||||
const apiErrorMessage = (err: unknown): string | undefined => {
|
const apiErrorMessage = (err: unknown): string | undefined => {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
Reference in New Issue
Block a user