From d461d889c314c227a29e1dfbe8b2a71469b70c96 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 21:46:26 +0200 Subject: [PATCH] feat: the streamed screen is a persisted setting, pickable from the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P4 of design/per-monitor-portal-capture.md. The pin was an env var read once at startup, which a console picker can never write — so it becomes a field of the display policy (orthogonal to presets, like game_session), and `vdisplay::capture_monitor()` resolves env-over-policy: an appliance that pinned in its unit's host.env stays pinned there, and a console click cannot re-aim a machine whose operator already declared the answer. Read per `open` rather than cached, so a picker change takes effect on the next session instead of the next host restart. The host re-resolves the input anchor on every policy write — including clearing it when the pin is cleared, or a later virtual-display session inherits an anchor aimed at a monitor it is not showing. `with_manual_layout` carries the pin through like the other orthogonal axes: without that, saving a display ARRANGEMENT would silently swap the streamed screen back to virtual. Console: a "Streamed screen" card listing the host's real monitors beside "Virtual screen (default)", saving on selection. A disabled head is listed but not selectable (so "why isn't it here?" has an answer), and an env-pinned host renders read-only with the reason rather than offering controls that silently lose to the environment. Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes sessions to the mirror and anchors input; setting the env to a different connector overrides it, exactly as documented. Co-Authored-By: Claude Opus 5 (1M context) --- api/openapi.json | 136 ++++ crates/pf-vdisplay/src/lib.rs | 33 +- crates/pf-vdisplay/src/vdisplay/policy.rs | 36 +- crates/punktfunk-host/src/devtest.rs | 2 +- crates/punktfunk-host/src/main.rs | 114 +-- crates/punktfunk-host/src/mgmt/display.rs | 15 +- web/messages/de.json | 843 +++++++++++----------- web/messages/en.json | 843 +++++++++++----------- web/src/sections/Displays/MonitorCard.tsx | 160 ++++ web/src/sections/Displays/index.tsx | 2 + 10 files changed, 1291 insertions(+), 893 deletions(-) create mode 100644 web/src/sections/Displays/MonitorCard.tsx diff --git a/api/openapi.json b/api/openapi.json index 849b876b..aa14d7e2 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -190,6 +190,38 @@ } } }, + "/api/v1/display/monitors": { + "get": { + "tags": [ + "display" + ], + "summary": "Physical monitors", + "description": "The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and\nfor rendering a picker. Read-only: this never creates, moves or disables anything. Note these\nare *not* the managed virtual displays — those are `/display/state`. See\n`design/per-monitor-portal-capture.md` §5.1.", + "operationId": "getDisplayMonitors", + "responses": { + "200": { + "description": "The host's physical monitors", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MonitorsResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/display/presets": { "get": { "tags": [ @@ -3646,6 +3678,66 @@ } } }, + "ApiMonitorInfo": { + "type": "object", + "description": "One physical monitor this host has, as the compositor reports it.", + "required": [ + "connector", + "description", + "mode", + "x", + "y", + "scale", + "primary", + "enabled", + "managed", + "selected" + ], + "properties": { + "connector": { + "type": "string", + "description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes." + }, + "description": { + "type": "string", + "description": "Human label for a picker (`make model`, else the connector)." + }, + "enabled": { + "type": "boolean", + "description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing." + }, + "managed": { + "type": "boolean", + "description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)." + }, + "mode": { + "type": "string", + "description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)." + }, + "primary": { + "type": "boolean", + "description": "The compositor's primary/focused head." + }, + "scale": { + "type": "number", + "format": "double", + "description": "Logical scale factor." + }, + "selected": { + "type": "boolean", + "description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor." + }, + "x": { + "type": "integer", + "format": "int32", + "description": "Desktop-space top-left — what makes a head identifiable when two share a size." + }, + "y": { + "type": "integer", + "format": "int32" + } + } + }, "ApiSelectedGpu": { "type": "object", "description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)", @@ -4251,6 +4343,13 @@ "type": "object", "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", "properties": { + "capture_monitor": { + "type": [ + "string", + "null" + ], + "description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it." + }, "ddc_power_off": { "type": "boolean", "description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched." @@ -5720,6 +5819,43 @@ "reject" ] }, + "MonitorsResponse": { + "type": "object", + "description": "The host's physical monitors + which one capture is pinned to.", + "required": [ + "monitors" + ], + "properties": { + "compositor": { + "type": [ + "string", + "null" + ], + "description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved." + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"." + }, + "monitors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiMonitorInfo" + }, + "description": "The heads, ordered left-to-right by desktop position." + }, + "pinned": { + "type": [ + "string", + "null" + ], + "description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"." + } + } + }, "NativeClient": { "type": "object", "description": "A paired native (punktfunk/1) client.", diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index f8a3cae3..90390129 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -288,19 +288,34 @@ pub fn rebuild_probe_active() -> bool { REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0 } +/// The monitor this host mirrors instead of creating a virtual display, or `None` for the normal +/// virtual-display path. +/// +/// Precedence: **`PUNKTFUNK_CAPTURE_MONITOR` wins over the stored policy.** An appliance pins in +/// `host.env` and must stay pinned there — a console click (or a stale settings file) should not be +/// able to re-aim a machine whose operator declared the answer in its unit's environment. With the +/// env unset, the console's persisted choice applies, which is what makes a picker possible at all: +/// the env is read once at startup, so it could never be what a UI writes. +/// +/// Read per `open` rather than cached, so a console change takes effect on the next session instead +/// of at the next host restart. +pub fn capture_monitor() -> Option { + if let Some(env) = pf_host_config::config().capture_monitor.as_deref() { + return Some(env.to_string()); + } + policy::prefs().get().capture_monitor +} + /// Open the virtual-display driver for `compositor`. /// -/// A `PUNKTFUNK_CAPTURE_MONITOR` pin routes to the **mirror** backend instead: the host streams -/// that physical head and creates no virtual display at all. Deliberately resolved here, at the one -/// place every session opens a display, so the pin can't be honored on one plane and ignored on -/// another — it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3). +/// A [`capture_monitor`] pin routes to the **mirror** backend instead: the host streams that +/// physical head and creates no virtual display at all. Deliberately resolved here, at the one place +/// every session opens a display, so the pin can't be honored on one plane and ignored on another — +/// it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3). pub fn open(compositor: Compositor) -> Result> { #[cfg(target_os = "linux")] - if let Some(connector) = pf_host_config::config().capture_monitor.as_deref() { - return Ok(Box::new(mirror::MirrorDisplay::new( - compositor, - connector.to_string(), - )?)); + if let Some(connector) = capture_monitor() { + return Ok(Box::new(mirror::MirrorDisplay::new(compositor, connector)?)); } #[cfg(target_os = "linux")] { diff --git a/crates/pf-vdisplay/src/vdisplay/policy.rs b/crates/pf-vdisplay/src/vdisplay/policy.rs index 0fbdc741..51918610 100644 --- a/crates/pf-vdisplay/src/vdisplay/policy.rs +++ b/crates/pf-vdisplay/src/vdisplay/policy.rs @@ -246,6 +246,16 @@ pub struct DisplayPolicy { /// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off. #[serde(default)] pub pnp_disable_monitors: bool, + /// **Mirror a physical monitor instead of creating a virtual display**: the connector name + /// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path. + /// + /// Orthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and + /// `#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a + /// **host-wide** setting, not per-client — the host-pinned decision of record in + /// `design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see + /// [`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it. + #[serde(default)] + pub capture_monitor: Option, } fn one() -> u32 { @@ -271,6 +281,7 @@ impl Default for DisplayPolicy { game_session: GameSession::default(), ddc_power_off: false, pnp_disable_monitors: false, + capture_monitor: None, } } } @@ -316,6 +327,12 @@ impl DisplayPolicy { pub fn sanitized(mut self) -> Self { self.version = 1; self.max_displays = self.max_displays.clamp(1, 16); + // A picker that clears its selection sends `""`; that means "no pin", not "match the + // monitor named empty string" — same normalization the env knob does. + self.capture_monitor = self + .capture_monitor + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); self } } @@ -332,6 +349,7 @@ impl EffectivePolicy { game_session: GameSession, ddc_power_off: bool, pnp_disable_monitors: bool, + capture_monitor: Option, ) -> DisplayPolicy { DisplayPolicy { version: 1, @@ -345,10 +363,13 @@ impl EffectivePolicy { positions, }, max_displays: self.max_displays, - // Preserve the orthogonal axes (EffectivePolicy doesn't carry them). + // Preserve the orthogonal axes (EffectivePolicy doesn't carry them). Dropping any of + // them here would mean "saving a display arrangement silently cleared my setting" — + // for `capture_monitor` that would swap the streamed screen out from under the operator. game_session, ddc_power_off, pnp_disable_monitors, + capture_monitor, } } } @@ -791,12 +812,19 @@ mod tests { let mut positions = BTreeMap::new(); positions.insert("1".to_string(), Position { x: 0, y: 0 }); positions.insert("7".to_string(), Position { x: 2560, y: 0 }); - let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true); - // The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through - // the transform. + let p = eff.with_manual_layout( + positions, + GameSession::Dedicated, + true, + true, + Some("DP-2".into()), + ); + // The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are + // preserved through the transform — arranging displays must not clear an unrelated setting. assert_eq!(p.game_session, GameSession::Dedicated); assert!(p.ddc_power_off); assert!(p.pnp_disable_monitors); + assert_eq!(p.capture_monitor.as_deref(), Some("DP-2")); // Preset drops to Custom so the explicit fields (incl. the layout) rule… assert_eq!(p.preset, Preset::Custom); // …every other behavior axis is preserved verbatim… diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 1454cfe6..16cf3aa9 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -502,7 +502,7 @@ pub fn mirror_test(args: &[String]) -> Result<()> { let explicit = arg("--monitor"); let want = explicit .clone() - .or_else(|| pf_host_config::config().capture_monitor.clone()) + .or_else(crate::vdisplay::capture_monitor) .context( "no monitor named — pass --monitor or set PUNKTFUNK_CAPTURE_MONITOR", )?; diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 65deb759..be90c93b 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -191,6 +191,67 @@ fn main() { /// prints an alarming `SetProcessDpiAwarenessContext … "access denied"` WARN on a plain /// `plugins add`. `service run` is the SCM-launched host itself, so it is explicitly NOT lightweight /// (it must keep the hook — the hybrid-GPU ACCESS_LOST fix depends on it). +/// Resolve the effective monitor pin (env, else the stored policy) and aim absolute input at that +/// head — then report it. Called at startup (an operator sets the pin in a `host.env` and then has +/// no session to watch, so this is where a typo has to surface) and again whenever the console +/// writes the policy, so a picker change re-aims input without a host restart. +/// +/// The anchor lives HERE rather than in the mirror backend for two reasons: pf-vdisplay must not +/// depend on pf-inject (its crate doc), and the anchor is a host-level pin anyway — the injector is +/// host-lifetime and shared by every concurrent session, so there is nothing per-session to track +/// (`design/per-monitor-portal-capture.md` §7.2). +#[cfg(target_os = "linux")] +pub(crate) fn refresh_capture_monitor_anchor(context: &str) { + let Some(want) = pf_vdisplay::capture_monitor() else { + // No pin (or the console just cleared one): stop aiming input at a monitor we are no + // longer mirroring, or a later virtual-display session inherits a stale anchor. + pf_inject::set_absolute_anchor(None); + return; + }; + match pf_vdisplay::detect().and_then(pf_vdisplay::monitors::list) { + Ok(ms) => match pf_vdisplay::monitors::resolve(&ms, &want) { + Ok(m) => { + // Match the libei region by the head's ORIGIN: two monitors can share a size — and + // a mirrored head's region is not the client's size at all — so size matching would + // put the pointer on the wrong screen. + pf_inject::set_absolute_anchor(Some(pf_inject::AbsoluteAnchor { + origin: Some((m.x, m.y)), + mapping_id: None, + })); + tracing::info!( + context, + connector = %m.connector, + description = %m.description, + mode = %m.mode_label(), + at = %format!("+{}+{}", m.x, m.y), + "capture monitor: sessions will mirror this monitor (no virtual display) and \ + absolute input is anchored to it" + ); + } + // Left unanchored on purpose: a pin that resolves to nothing must not aim input at a + // guess. The session's own `create` fails with the same reason. + Err(e) => { + pf_inject::set_absolute_anchor(None); + tracing::warn!( + context, + error = %e, + "capture monitor: the pinned monitor is not on this host — sessions will fail \ + to start until it is corrected or cleared" + ); + } + }, + Err(e) => { + pf_inject::set_absolute_anchor(None); + tracing::warn!( + context, + error = %format!("{e:#}"), + monitor = %want, + "capture monitor: a monitor is pinned but the monitors could not be enumerated" + ); + } + } +} + fn is_management_cli(args: &[String]) -> bool { match args.first().map(String::as_str) { Some("plugins") @@ -234,51 +295,9 @@ fn real_main() -> Result<()> { ); } - // Resolve a `PUNKTFUNK_CAPTURE_MONITOR` pin at startup: report it (the operator sets it in a - // host.env and then has no session to watch, so this is where a typo has to surface), and aim - // absolute input at that head. - // - // The anchor is set HERE rather than inside the mirror backend for two reasons: pf-vdisplay must - // not depend on pf-inject (its crate doc), and the anchor is a host-level pin anyway — the - // injector is host-lifetime and shared by every concurrent session, so there is nothing - // per-session to track (`design/per-monitor-portal-capture.md` §7.2). #[cfg(target_os = "linux")] if !management_cli { - if let Some(want) = pf_host_config::config().capture_monitor.as_deref() { - match pf_vdisplay::detect().and_then(pf_vdisplay::monitors::list) { - Ok(ms) => match pf_vdisplay::monitors::resolve(&ms, want) { - Ok(m) => { - // Match the libei region by the head's ORIGIN: two monitors can share a - // size — and a mirrored head's region is not the client's size at all — so - // size matching would put the pointer on the wrong screen. - pf_inject::set_absolute_anchor(Some(pf_inject::AbsoluteAnchor { - origin: Some((m.x, m.y)), - mapping_id: None, - })); - tracing::info!( - connector = %m.connector, - description = %m.description, - mode = %m.mode_label(), - at = %format!("+{}+{}", m.x, m.y), - "PUNKTFUNK_CAPTURE_MONITOR: sessions will mirror this monitor (no \ - virtual display) and absolute input is anchored to it" - ); - } - // Left unanchored on purpose: a pin that resolves to nothing must not aim input - // at a guess. The session's own `create` fails with the same reason. - Err(e) => tracing::warn!( - error = %e, - "PUNKTFUNK_CAPTURE_MONITOR names no monitor on this host — sessions will \ - fail to start until it is corrected or unset" - ), - }, - Err(e) => tracing::warn!( - error = %format!("{e:#}"), - monitor = %want, - "PUNKTFUNK_CAPTURE_MONITOR is set but the monitors could not be enumerated" - ), - } - } + refresh_capture_monitor_anchor("startup"); } // Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a @@ -473,7 +492,7 @@ fn real_main() -> Result<()> { println!("{compositor:?}: no monitors"); return Ok(()); } - let pinned = pf_host_config::config().capture_monitor.as_deref(); + let pinned = vdisplay::capture_monitor(); println!("{compositor:?}:"); for m in &monitors { let mut tags = Vec::new(); @@ -486,8 +505,11 @@ fn real_main() -> Result<()> { if m.managed { tags.push("punktfunk virtual display"); } - if pinned.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector)) { - tags.push("PINNED (PUNKTFUNK_CAPTURE_MONITOR)"); + if pinned + .as_deref() + .is_some_and(|p| p.eq_ignore_ascii_case(&m.connector)) + { + tags.push("PINNED"); } println!( " {:<12} {:>13} at +{},+{} scale {} {}{}", diff --git a/crates/punktfunk-host/src/mgmt/display.rs b/crates/punktfunk-host/src/mgmt/display.rs index e1248a4e..cea6be3f 100644 --- a/crates/punktfunk-host/src/mgmt/display.rs +++ b/crates/punktfunk-host/src/mgmt/display.rs @@ -88,6 +88,9 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState { // (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere. "ddc_power_off".into(), "pnp_disable_monitors".into(), + // Linux-only in effect: routes every session to the mirror backend + // (design/per-monitor-portal-capture.md). + "capture_monitor".into(), ], } } @@ -141,6 +144,10 @@ pub(crate) async fn set_display_settings( ); } tracing::info!("management API: display policy updated"); + // The policy carries the capture-monitor pin, so a picker change must re-aim absolute input now + // rather than at the next host restart — and must clear the anchor when the pin is cleared. + #[cfg(target_os = "linux")] + crate::refresh_capture_monitor_anchor("display policy updated"); Json(display_settings_state()).into_response() } @@ -237,7 +244,12 @@ pub(crate) struct MonitorsResponse { ) )] pub(crate) async fn get_display_monitors() -> Json { - let pinned = pf_host_config::config().capture_monitor.clone(); + // The EFFECTIVE pin (env override, else the stored policy) — so the picker highlights what + // sessions will actually mirror, not just what the console last wrote. + #[cfg(target_os = "linux")] + let pinned = crate::vdisplay::capture_monitor(); + #[cfg(not(target_os = "linux"))] + let pinned: Option = None; // Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker. let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() { Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)), @@ -392,6 +404,7 @@ pub(crate) async fn set_display_layout(ApiJson(req): ApiJson { + const qc = useQueryClient(); + const monitors = useGetDisplayMonitors(); + const settings = useGetDisplaySettings(); + const save = useSetDisplaySettings(); + + const policy = settings.data?.settings; + const rows = monitors.data?.monitors ?? []; + // The host reports the EFFECTIVE pin — an env-pinned appliance shows its real answer here even + // though the console cannot change it. + const pinned = monitors.data?.pinned ?? null; + // `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's + // environment is read-only here: offering controls that silently lose to the env would be worse + // than saying so. + const envLocked = !!pinned && policy?.capture_monitor !== pinned; + + const choose = (connector: string | null) => { + if (!policy || envLocked) return; + save.mutate( + { data: { ...policy, capture_monitor: connector } }, + { + onSuccess: () => { + qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); + qc.invalidateQueries({ queryKey: getGetDisplayMonitorsQueryKey() }); + toast.success(m.display_monitor_saved()); + }, + }, + ); + }; + + const busy = save.isPending; + const error = save.error instanceof ApiError ? save.error.message : undefined; + + const row = ( + key: string, + selected: boolean, + title: string, + hint: string, + tags?: ReturnType[], + onSelect?: () => void, + ) => ( + + ); + + const monitorRow = (mon: ApiMonitorInfo) => { + const tags = [ + mon.primary ? ( + + {m.display_monitor_primary()} + + ) : null, + !mon.enabled ? ( + + {m.display_monitor_disabled()} + + ) : null, + ].filter(Boolean) as ReturnType[]; + return row( + mon.connector, + pinned?.toLowerCase() === mon.connector.toLowerCase(), + `${mon.connector} — ${mon.description}`, + `${mon.mode} · ${m.display_monitor_mirror_hint()}`, + tags, + // A disabled head cannot be streamed (the host refuses with that reason), so don't + // offer it as a choice — it is listed so "why isn't it here?" has an answer. + mon.enabled && !mon.managed ? () => choose(mon.connector) : undefined, + ); + }; + + return ( + + + {m.display_monitor_title()} + + +

+ {m.display_monitor_intro()} +

+ {envLocked && ( +

+ {m.display_monitor_env_locked()} +

+ )} + +
+ {row( + "__virtual__", + !pinned, + m.display_monitor_virtual(), + m.display_monitor_virtual_hint(), + undefined, + () => choose(null), + )} + {rows.map(monitorRow)} + {rows.length === 0 && ( +

+ {monitors.data?.error + ? m.display_monitor_unavailable() + : m.display_monitor_none()} +

+ )} +
+
+ {error &&

{error}

} +
+
+ ); +}; diff --git a/web/src/sections/Displays/index.tsx b/web/src/sections/Displays/index.tsx index ef530783..bca4a531 100644 --- a/web/src/sections/Displays/index.tsx +++ b/web/src/sections/Displays/index.tsx @@ -3,6 +3,7 @@ import type { FC } from "react"; import { useLocale } from "@/lib/i18n"; import { m } from "@/paraglide/messages"; import { DisplaySection } from "./DisplayCard"; +import { MonitorCard } from "./MonitorCard"; import { SessionGameCard } from "./SessionGameCard"; /** @@ -21,6 +22,7 @@ export const SectionDisplays: FC = () => {

{m.nav_displays()}

+