From 87f0ce7997aa7ec3598f814537bff44a98ab0e62 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 4 Jul 2026 20:32:03 +0000 Subject: [PATCH] feat(vdisplay): lifecycle state machine + display state/release API (Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/openapi.json | 171 +++++++++ crates/punktfunk-host/src/mgmt.rs | 105 +++++- crates/punktfunk-host/src/vdisplay.rs | 10 + .../punktfunk-host/src/vdisplay/lifecycle.rs | 338 ++++++++++++++++++ .../punktfunk-host/src/vdisplay/registry.rs | 80 +++++ .../src/vdisplay/windows/manager.rs | 75 ++++ web/messages/de.json | 9 + web/messages/en.json | 9 + web/src/sections/Host/DisplayCard.tsx | 93 +++++ 9 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 crates/punktfunk-host/src/vdisplay/lifecycle.rs create mode 100644 crates/punktfunk-host/src/vdisplay/registry.rs diff --git a/api/openapi.json b/api/openapi.json index 311c233..f41f8a9 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -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": { "get": { "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": { "get": { "tags": [ @@ -1693,6 +1767,59 @@ "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": { "type": "object", "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": { "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`].", @@ -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": { "type": "object", "description": "Live host status (changes as clients launch/end sessions).", diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index d711aa0..5c7fde1 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -158,6 +158,8 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(set_gpu_preference)) .routes(routes!(get_display_settings)) .routes(routes!(set_display_settings)) + .routes(routes!(get_display_state)) + .routes(routes!(release_display)) .routes(routes!(get_status)) .routes(routes!(get_local_summary)) .routes(routes!(list_paired_clients)) @@ -1095,6 +1097,104 @@ async fn set_display_settings( 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, + /// Live sessions holding the display. + sessions: u32, + /// Short client label, when the owner tracks it. + client: Option, +} + +/// The host's managed virtual displays right now. +#[derive(Serialize, ToSchema)] +struct DisplayStateResponse { + displays: Vec, +} + +/// Request body for `releaseDisplay`. +#[derive(Deserialize, ToSchema)] +struct ReleaseDisplayRequest { + /// Slot to release (see `state`); omit to release **all** kept displays. + #[serde(default)] + slot: Option, +} + +/// 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 { + 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, +) -> Json { + let released = crate::vdisplay::registry::release(req.slot); + tracing::info!(slot = ?req.slot, released, "management API: display release"); + Json(ReleaseDisplayResult { released }) +} + /// Live host status #[utoipa::path( get, @@ -2650,7 +2750,10 @@ mod tests { let (status, body) = send(&app, put).await; assert_eq!(status, StatusCode::BAD_REQUEST); 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" ); } diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 70a3eeb..2a186e0 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -752,6 +752,16 @@ pub fn start_restore_worker() -> std::sync::Arc<()> { #[path = "vdisplay/policy.rs"] 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` /// 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 diff --git a/crates/punktfunk-host/src/vdisplay/lifecycle.rs b/crates/punktfunk-host/src/vdisplay/lifecycle.rs new file mode 100644 index 0000000..d0d2f06 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/lifecycle.rs @@ -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" + ); + } + } +} diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs new file mode 100644 index 0000000..53cb189 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -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, + /// Live sessions holding the display. + pub sessions: u32, + /// Short client label (cert-fp prefix / peer), when the owner tracks it. + pub client: Option, +} + +/// The live display set for the mgmt `/display/state` endpoint. +#[derive(Clone, Debug, Default)] +pub struct Snapshot { + pub displays: Vec, +} + +/// 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) -> 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 + } +} diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index b7ffd7d..e6daa28 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -892,6 +892,81 @@ fn resolve_render_pin() -> Option { 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, + /// 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 { + 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 { + 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 /// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob, /// else the 10 s default. diff --git a/web/messages/de.json b/web/messages/de.json index 2671942..215788f 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -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", diff --git a/web/messages/en.json b/web/messages/en.json index 4bc57c6..f4c4399 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -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", diff --git a/web/src/sections/Host/DisplayCard.tsx b/web/src/sections/Host/DisplayCard.tsx index 65f258c..4ba51b0 100644 --- a/web/src/sections/Host/DisplayCard.tsx +++ b/web/src/sections/Host/DisplayCard.tsx @@ -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 = () => { /> )} + ); }; +/** + * 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 ( +
+
+

{m.display_live()}

+ {kept.length > 0 && ( + + )} +
+ {displays.length === 0 ? ( +

{m.display_none_live()}

+ ) : ( +
    + {displays.map((d) => ( + doRelease(d.slot)} + /> + ))} +
+ )} +
+ ); +}; + +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 ( +
  • +
    +
    + {d.mode} + {stateLabel} + {active && d.sessions > 0 && ( + {m.display_sessions({ count: d.sessions })} + )} +
    + + {d.backend} + {d.expires_in_ms != null + ? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}` + : ""} + +
    + {!active && ( + + )} +
  • + ); +}; + /** 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) {