From 95e3314d9a6420c67de7b13828b14c250e341123 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 18:51:10 +0200 Subject: [PATCH] feat(vdisplay): enumerate the host's physical monitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 of per-monitor capture (design/per-monitor-portal-capture.md): nothing in the tree could answer "what heads does this host have?", which both a monitor pin and a console picker need first. One read per compositor, each beside the code that already speaks that dialect: KWin's kde_output_device_v2 (the topology session now also records geometry + scale), Mutter's DisplayConfig.GetCurrentState, swaymsg get_outputs, hyprctl monitors. Logical geometry throughout, because x/y is what identifies a head — two monitors can share a size but never an origin. PUNKTFUNK_CAPTURE_MONITOR parses here and is reported at startup, loudly including that it is not yet enforced: a knob that is read but inert has to say so, or "it didn't work" reads as a bug. GET /display/monitors always answers 200 with an explained empty list, so a compositor-less host renders as "nothing to pick", not as a broken console. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-host-config/src/lib.rs | 13 ++ crates/pf-vdisplay/src/lib.rs | 7 + .../src/vdisplay/linux/hyprland.rs | 52 ++++++ .../src/vdisplay/linux/kwin_output_mgmt.rs | 67 +++++++ .../pf-vdisplay/src/vdisplay/linux/mutter.rs | 55 ++++++ .../pf-vdisplay/src/vdisplay/linux/wlroots.rs | 62 +++++++ crates/pf-vdisplay/src/vdisplay/monitors.rs | 173 ++++++++++++++++++ crates/punktfunk-host/src/main.rs | 31 ++++ crates/punktfunk-host/src/mgmt.rs | 1 + crates/punktfunk-host/src/mgmt/display.rs | 94 ++++++++++ crates/punktfunk-host/src/mgmt/tests.rs | 23 +++ 11 files changed, 578 insertions(+) create mode 100644 crates/pf-vdisplay/src/vdisplay/monitors.rs diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index fda96efe..c1ca2156 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -96,6 +96,14 @@ pub struct HostConfig { pub perf: bool, /// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic). pub video_source: Option, + /// `PUNKTFUNK_CAPTURE_MONITOR` — pin capture at a NAMED physical monitor (`DP-1`, `HDMI-A-2`), + /// instead of creating a virtual display or taking whichever head the portal hands back. The + /// point of the knob is an unattended host: a background `systemd --user` service has nobody to + /// answer a chooser dialog, so the monitor has to be config, not a prompt. A name that matches + /// no head is a hard error at session open (never a silent fall-back to a different screen — + /// showing the wrong monitor is worse than showing none). Linux-only today; see + /// `design/per-monitor-portal-capture.md`. + pub capture_monitor: Option, /// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected /// session — this one is a constant operator knob; `apply_session_env` never writes it. pub compositor: Option, @@ -170,6 +178,11 @@ impl HostConfig { chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true), perf: flag("PUNKTFUNK_PERF"), video_source: val("PUNKTFUNK_VIDEO_SOURCE"), + // Trimmed + emptied-to-None: `PUNKTFUNK_CAPTURE_MONITOR=` in a host.env means "not + // set", not "match the monitor named empty string". + capture_monitor: val("PUNKTFUNK_CAPTURE_MONITOR") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), compositor: val("PUNKTFUNK_COMPOSITOR"), gamepad: val("PUNKTFUNK_GAMEPAD"), vdisplay: val("PUNKTFUNK_VDISPLAY"), diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index 603cfbb4..0eb71762 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -359,6 +359,13 @@ pub fn probe(compositor: Compositor) -> Result<()> { #[path = "vdisplay/policy.rs"] pub mod policy; +// Read-only physical-monitor enumeration (the heads the compositor ALREADY has — not ours), for +// pinning capture at one of them + the console picker. Platform-neutral facade; the per-backend +// reads live beside the code that already speaks each dialect. See +// `design/per-monitor-portal-capture.md` §5.1. +#[path = "vdisplay/monitors.rs"] +pub mod monitors; + // 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"] diff --git a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs index afcdbb65..88baea51 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs @@ -272,6 +272,58 @@ fn hyprctl(args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } +/// Every head Hyprland reports, for [`crate::monitors::list`]. +/// +/// `hyprctl -j monitors all` (rather than plain `monitors`) so **disabled** heads are listed too — +/// a picker that silently omits the monitor the operator is trying to name is worse than one that +/// shows it greyed out. Hyprland reports geometry post-transform in logical pixels, which is the +/// space `crate::monitors` documents. +pub(crate) fn list_monitors() -> Result> { + let raw = hyprctl(&["-j", "monitors", "all"])?; + let parsed: serde_json::Value = + serde_json::from_str(&raw).context("parse hyprctl -j monitors all")?; + let mut out: Vec<_> = parsed + .as_array() + .context("hyprctl monitors: not an array")? + .iter() + .filter_map(|m| { + let connector = m.get("name")?.as_str()?.to_string(); + let num = |k: &str| m.get(k).and_then(|v| v.as_i64()).unwrap_or(0); + let description = m + .get("description") + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(&connector) + .to_string(); + Some(crate::monitors::PhysicalMonitor { + connector, + description, + width: num("width").max(0) as u32, + height: num("height").max(0) as u32, + // `refreshRate` is Hz as a float. + refresh_mhz: (m.get("refreshRate").and_then(|v| v.as_f64()).unwrap_or(0.0) * 1000.0) + as u32, + x: num("x") as i32, + y: num("y") as i32, + scale: m + .get("scale") + .and_then(|v| v.as_f64()) + .filter(|s| *s > 0.0) + .unwrap_or(1.0), + primary: m.get("focused").and_then(|v| v.as_bool()).unwrap_or(false), + enabled: !m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false), + // Our headless outputs are named `PF-` (see `next_output_name`). + managed: m + .get("name") + .and_then(|v| v.as_str()) + .is_some_and(|n| n.starts_with("PF-")), + }) + }) + .collect(); + out.sort_by_key(|m| (m.x, m.y, m.connector.clone())); + Ok(out) +} + /// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by /// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to /// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin_output_mgmt.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin_output_mgmt.rs index feb865d8..c64a47f4 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/kwin_output_mgmt.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin_output_mgmt.rs @@ -131,6 +131,14 @@ struct DeviceState { name: Option, uuid: Option, enabled: bool, + /// Top-left in the compositor's global logical space, from the `geometry` event — the key that + /// makes a head identifiable when two of them share a size (`monitors::PhysicalMonitor`). + position: (i32, i32), + /// `make` / `model` from the same event, for the picker label. + make: Option, + model: Option, + /// Logical scale from the `scale` event. `None` ⇒ the protocol's documented default of 1. + scale: Option, /// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18). priority: Option, /// The `current_mode` object id; its size is looked up in [`State::mode_dims`]. @@ -237,6 +245,15 @@ impl Dispatch for State { match event { DeviceEvent::Name { name } => entry.name = Some(name), DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid), + DeviceEvent::Geometry { + x, y, make, model, .. + } => { + entry.position = (x, y); + entry.make = Some(make); + entry.model = Some(model); + } + // `fixed` decodes to f64 in wayland-rs. + DeviceEvent::Scale { factor } => entry.scale = Some(factor), DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0, DeviceEvent::Priority { priority } => entry.priority = Some(priority), DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()), @@ -455,6 +472,56 @@ fn mode_spec(dims: (u32, u32, u32)) -> String { /// treated as a physical to disable, and its primary is never stolen (first-slot-wins). const MANAGED_PREFIX: &str = "Virtual-punktfunk"; +/// Every head KWin reports, for [`crate::monitors::list`]. +/// +/// Reuses the same bounded enumerate-only session the topology path opens — no configuration is +/// built and nothing is applied, so this is a pure read. A device that never completed its `done` +/// burst is skipped rather than reported half-read (its geometry would be a guess, and geometry is +/// exactly what callers key on). +pub(crate) fn list_monitors() -> anyhow::Result> { + let session = Session::open().ok_or_else(|| { + anyhow::anyhow!( + "KWin did not answer kde_output_management_v2 (not a KWin session, the protocol is \ + not advertised to this client, or the compositor is wedged)" + ) + })?; + let mut out: Vec<_> = session + .state + .devices + .values() + .filter(|d| d.seen_done) + .filter_map(|d| { + let connector = d.name.clone()?; + let dims = session.current_dims(d); + let label = match (d.make.as_deref(), d.model.as_deref()) { + (Some(make), Some(model)) if !make.is_empty() || !model.is_empty() => { + format!("{make} {model}").trim().to_string() + } + _ => connector.clone(), + }; + Some(crate::monitors::PhysicalMonitor { + managed: connector.starts_with(MANAGED_PREFIX), + description: label, + // A disabled output has no current mode — report 0s rather than inventing one. + width: dims.map(|d| d.0).unwrap_or(0), + height: dims.map(|d| d.1).unwrap_or(0), + refresh_mhz: dims.map(|d| d.2).unwrap_or(0), + x: d.position.0, + y: d.position.1, + scale: d.scale.filter(|s| *s > 0.0).unwrap_or(1.0), + // KWin ranks outputs; 1 is the primary. + primary: d.priority == Some(1), + enabled: d.enabled, + connector, + }) + }) + .collect(); + // The device globals arrive in bind order, which is not stable across runs; sort by desktop + // position so a picker (and a log line) reads left-to-right the way the desk looks. + out.sort_by_key(|m| (m.x, m.y, m.connector.clone())); + Ok(out) +} + /// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the /// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`. /// See the module docs for why this is done in-process instead of via `kscreen-doctor`. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs index 6af22925..32d02143 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs @@ -811,6 +811,61 @@ fn logical_scale(state: &CurrentState, connector: &str) -> Option { .map(|l| l.2) } +/// Every head Mutter reports, for [`crate::monitors::list`]. +/// +/// A pure `GetCurrentState` read on its own short-lived connection + runtime — no session, no +/// `ApplyMonitorsConfig`, so it never touches the topology and never contends [`TOPOLOGY_LOCK`]. +/// Geometry comes from the **logical** monitors (`state.2`), which is the coordinate space that +/// matters (see `crate::monitors`); a monitor absent from every logical monitor is disabled, and is +/// reported as such at the origin rather than dropped. +pub(crate) fn list_monitors() -> Result> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build tokio runtime (monitor enumeration)")?; + let state = rt.block_on(async { + let dc = display_config().await?; + get_state(&dc).await + })?; + let mut out: Vec<_> = state + .1 + .iter() + .map(|(spec, _modes, _props)| { + let (connector, vendor, product, _serial) = spec; + // The logical monitor carrying this connector: its (x, y), scale and primary flag. + let logical = state + .2 + .iter() + .find(|l| l.5.iter().any(|s| &s.0 == connector)); + let (w, h, refresh) = current_mode_full(&state, connector) + .map(|(_id, w, h, hz)| (w.max(0) as u32, h.max(0) as u32, (hz * 1000.0) as u32)) + .unwrap_or((0, 0, 0)); + let label = format!("{vendor} {product}"); + crate::monitors::PhysicalMonitor { + connector: connector.clone(), + description: if label.trim().is_empty() { + connector.clone() + } else { + label.trim().to_string() + }, + width: w, + height: h, + refresh_mhz: refresh, + x: logical.map(|l| l.0).unwrap_or(0), + y: logical.map(|l| l.1).unwrap_or(0), + scale: logical.map(|l| l.2).filter(|s| *s > 0.0).unwrap_or(1.0), + primary: logical.map(|l| l.4).unwrap_or(false), + enabled: logical.is_some(), + // Mutter names a `RecordVirtual` monitor indistinguishably from a physical one — + // no prefix to key on, and the connector is minted per session. See the field doc. + managed: false, + } + }) + .collect(); + out.sort_by_key(|m| (m.x, m.y, m.connector.clone())); + Ok(out) +} + /// Read the virtual output's current scale and, when the user changed it (GNOME Settings /// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it. /// Best-effort: read failures (teardown races, shell restart) are silently skipped. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs index 032fb597..232627a5 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs @@ -229,6 +229,68 @@ fn output_names() -> Result> { .collect()) } +/// Every head sway reports, for [`crate::monitors::list`]. +/// +/// `swaymsg -t get_outputs` reports `rect` in the logical coordinate space (post-scale, +/// post-transform) — what `crate::monitors` documents. An inactive output has no `current_mode`, so +/// its mode reads as zeros rather than a guess. +pub(crate) fn list_monitors() -> Result> { + let raw = swaymsg(&["-t", "get_outputs", "--raw"])?; + let parsed: serde_json::Value = serde_json::from_str(&raw).context("parse get_outputs")?; + let mut out: Vec<_> = parsed + .as_array() + .context("get_outputs: not an array")? + .iter() + .filter_map(|o| { + let connector = o.get("name")?.as_str()?.to_string(); + let rect = |k: &str| { + o.get("rect") + .and_then(|r| r.get(k)) + .and_then(|v| v.as_i64()) + .unwrap_or(0) + }; + let mode = |k: &str| { + o.get("current_mode") + .and_then(|m| m.get(k)) + .and_then(|v| v.as_i64()) + .unwrap_or(0) + }; + let str_field = |k: &str| o.get(k).and_then(|v| v.as_str()).unwrap_or("").trim(); + let label = format!("{} {}", str_field("make"), str_field("model")); + Some(crate::monitors::PhysicalMonitor { + description: if label.trim().is_empty() { + connector.clone() + } else { + label.trim().to_string() + }, + width: mode("width").max(0) as u32, + height: mode("height").max(0) as u32, + // sway reports `refresh` in mHz already. + refresh_mhz: mode("refresh").max(0) as u32, + x: rect("x") as i32, + y: rect("y") as i32, + scale: o + .get("scale") + .and_then(|v| v.as_f64()) + .filter(|s| *s > 0.0) + .unwrap_or(1.0), + primary: o + .get("primary") + .and_then(|v| v.as_bool()) + .or_else(|| o.get("focused").and_then(|v| v.as_bool())) + .unwrap_or(false), + enabled: o.get("active").and_then(|v| v.as_bool()).unwrap_or(true), + // Sway auto-names headless outputs `HEADLESS-N` and that is what `create` adds. A + // sway started with its own headless output would match too — hence best-effort. + managed: connector.starts_with("HEADLESS-"), + connector, + }) + }) + .collect(); + out.sort_by_key(|m| (m.x, m.y, m.connector.clone())); + Ok(out) +} + /// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N). fn wait_new_output(before: &[String], timeout: Duration) -> Result { let deadline = Instant::now() + timeout; diff --git a/crates/pf-vdisplay/src/vdisplay/monitors.rs b/crates/pf-vdisplay/src/vdisplay/monitors.rs new file mode 100644 index 00000000..0a677b88 --- /dev/null +++ b/crates/pf-vdisplay/src/vdisplay/monitors.rs @@ -0,0 +1,173 @@ +//! Physical-monitor enumeration — "what heads does this host actually have?". +//! +//! This is the read-only counterpart to the rest of this crate: everything else here *creates* and +//! owns virtual outputs, while this module only *reports* the heads the compositor already has, so +//! an operator can pin capture at one of them (`PUNKTFUNK_CAPTURE_MONITOR`) and the console can +//! render a picker. See `design/per-monitor-portal-capture.md` §5.1. +//! +//! It lives in pf-vdisplay because monitor enumeration is a **per-compositor** question and this +//! crate already speaks every one of those dialects — KWin's `kde_output_device_v2`, Mutter's +//! `DisplayConfig.GetCurrentState`, `swaymsg -t get_outputs`, `hyprctl -j monitors`. Each backend's +//! implementation sits beside the code that already talks to it; this module is the shared type and +//! the dispatch. +//! +//! **The geometry is the point.** `x`/`y` are what make a head *identifiable*: two monitors can +//! share a size (and then a size-keyed match is a coin flip — see `pf-inject`'s absolute-coordinate +//! region selection), but they can never share an origin in the compositor's global space. + +use crate::Compositor; +use anyhow::{bail, Result}; + +/// One head as the compositor currently reports it. Logical (post-scale) geometry throughout — +/// the same coordinate space libei regions and compositor layout use, *not* pixels. +#[derive(Clone, Debug, PartialEq)] +pub struct PhysicalMonitor { + /// Connector name — `DP-1`, `HDMI-A-2`, `eDP-1`. The id `PUNKTFUNK_CAPTURE_MONITOR` names. + pub connector: String, + /// Human label for a picker (`make model`, else the connector). Never used for matching. + pub description: String, + /// Current mode, in pixels. + pub width: u32, + pub height: u32, + /// Refresh in mHz (60000 = 60 Hz). 0 when the backend doesn't report it. + pub refresh_mhz: u32, + /// Top-left in the compositor's global logical space — the identity key (see the module doc). + pub x: i32, + pub y: i32, + /// Logical scale factor (1.0 when unreported). + pub scale: f64, + /// The compositor's primary/focused head, when it says. + pub primary: bool, + /// Enabled (driven). A disabled head is still listed, so "why can't I pick it?" has an answer. + pub enabled: bool, + /// **Best-effort**: this output is one WE created (a managed virtual display), not a real head. + /// Only KWin can say so reliably (managed outputs carry a name prefix); the other backends + /// name virtual outputs indistinguishably from physical ones, so this stays `false` there + /// rather than guessing. Callers use it to grey out nonsense choices, never to filter blindly. + pub managed: bool, +} + +impl PhysicalMonitor { + /// `1920x1080@60` — for logs and pickers. + pub fn mode_label(&self) -> String { + if self.refresh_mhz == 0 { + format!("{}x{}", self.width, self.height) + } else { + format!( + "{}x{}@{}", + self.width, + self.height, + (self.refresh_mhz as f64 / 1000.0).round() as u32 + ) + } + } +} + +/// Every head `compositor` reports, in the compositor's own order. +/// +/// Errors when the backend can't be reached (compositor not running, IPC unavailable) — that is +/// deliberately distinct from `Ok(vec![])`, which means "reached it, it has no heads" (a headless +/// session). Callers that only want to *offer* a picker can treat both as "nothing to show"; +/// callers resolving a pinned monitor must not (see [`resolve`]). +pub fn list(compositor: Compositor) -> Result> { + match compositor { + #[cfg(target_os = "linux")] + Compositor::Kwin => crate::kwin_output_mgmt::list_monitors(), + #[cfg(target_os = "linux")] + Compositor::Mutter => crate::mutter::list_monitors(), + #[cfg(target_os = "linux")] + Compositor::Wlroots => crate::wlroots::list_monitors(), + #[cfg(target_os = "linux")] + Compositor::Hyprland => crate::hyprland::list_monitors(), + // gamescope is nested: it has no physical heads of its own, and mirroring the desktop it + // runs on is a question for the compositor UNDER it. Not an error — just nothing to offer. + #[cfg(target_os = "linux")] + Compositor::Gamescope => Ok(Vec::new()), + #[cfg(not(target_os = "linux"))] + _ => bail!("physical-monitor enumeration is implemented for the Linux backends only"), + } +} + +/// Resolve a configured monitor name against `monitors`, exactly then case-insensitively. +/// +/// **A miss is a hard error carrying the available names**, never a silent fall-back to some other +/// head: an operator who pinned `DP-2` and gets `DP-1` streamed has been shown the wrong screen, +/// which is worse than a host that refuses to start a session and says why +/// (`design/per-monitor-portal-capture.md` §5.2). +pub fn resolve<'a>(monitors: &'a [PhysicalMonitor], want: &str) -> Result<&'a PhysicalMonitor> { + if let Some(m) = monitors.iter().find(|m| m.connector == want) { + return Ok(m); + } + if let Some(m) = monitors + .iter() + .find(|m| m.connector.eq_ignore_ascii_case(want)) + { + return Ok(m); + } + let available = monitors + .iter() + .map(|m| m.connector.as_str()) + .collect::>() + .join(", "); + if available.is_empty() { + bail!("no monitor named {want:?} — this compositor reports no monitors at all"); + } + bail!("no monitor named {want:?} — this host has: {available}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mon(connector: &str) -> PhysicalMonitor { + PhysicalMonitor { + connector: connector.into(), + description: String::new(), + width: 1920, + height: 1080, + refresh_mhz: 60000, + x: 0, + y: 0, + scale: 1.0, + primary: false, + enabled: true, + managed: false, + } + } + + #[test] + fn resolve_matches_exactly_then_case_insensitively() { + let ms = [mon("DP-1"), mon("HDMI-A-2")]; + assert_eq!(resolve(&ms, "DP-1").unwrap().connector, "DP-1"); + assert_eq!(resolve(&ms, "hdmi-a-2").unwrap().connector, "HDMI-A-2"); + } + + #[test] + fn resolve_prefers_the_exact_match_over_a_case_fold() { + // Connector names are case-sensitive to the compositor; if both spellings exist, the + // exact one wins rather than whichever the fold happened to reach first. + let ms = [mon("dp-1"), mon("DP-1")]; + assert_eq!(resolve(&ms, "DP-1").unwrap().connector, "DP-1"); + } + + #[test] + fn a_miss_lists_what_is_available() { + let ms = [mon("DP-1"), mon("HDMI-A-2")]; + let err = resolve(&ms, "DP-9").unwrap_err().to_string(); + assert!(err.contains("DP-1") && err.contains("HDMI-A-2"), "{err}"); + } + + #[test] + fn a_miss_with_no_monitors_says_so() { + let err = resolve(&[], "DP-1").unwrap_err().to_string(); + assert!(err.contains("no monitors at all"), "{err}"); + } + + #[test] + fn mode_label_drops_an_unknown_refresh() { + let mut m = mon("DP-1"); + assert_eq!(m.mode_label(), "1920x1080@60"); + m.refresh_mhz = 0; + assert_eq!(m.mode_label(), "1920x1080"); + } +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 4f442269..cf41abf8 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -231,6 +231,37 @@ fn real_main() -> Result<()> { ); } + // Report a `PUNKTFUNK_CAPTURE_MONITOR` pin at startup — the operator sets it in a host.env and + // then has no session to watch, so this is the only place a typo can surface. It is deliberately + // loud about not being enforced yet: a knob that is parsed but inert must SAY so, or a silent + // "it didn't work" reads as a bug. + #[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) => tracing::info!( + connector = %m.connector, + description = %m.description, + mode = %m.mode_label(), + at = %format!("+{}+{}", m.x, m.y), + "PUNKTFUNK_CAPTURE_MONITOR names this monitor — NOTE: per-monitor capture \ + is not implemented yet, so sessions still use the normal display path" + ), + Err(e) => tracing::warn!( + error = %e, + "PUNKTFUNK_CAPTURE_MONITOR names no monitor on this host" + ), + }, + Err(e) => tracing::warn!( + error = %format!("{e:#}"), + monitor = %want, + "PUNKTFUNK_CAPTURE_MONITOR is set but the monitors could not be enumerated" + ), + } + } + } + // Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a // neutral DisplayEvent; the orchestrator owns the bus type — plan §W6). Set once, ignore re-set. let _ = pf_vdisplay::DISPLAY_EVENT_SINK.set(Box::new(|ev| match ev { diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 120e67e3..38010bd7 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -188,6 +188,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(display::get_display_settings)) .routes(routes!(display::set_display_settings)) .routes(routes!(display::get_display_state)) + .routes(routes!(display::get_display_monitors)) .routes(routes!(display::release_display)) .routes(routes!(display::set_display_layout)) .routes(routes!( diff --git a/crates/punktfunk-host/src/mgmt/display.rs b/crates/punktfunk-host/src/mgmt/display.rs index 383db1d8..e1248a4e 100644 --- a/crates/punktfunk-host/src/mgmt/display.rs +++ b/crates/punktfunk-host/src/mgmt/display.rs @@ -181,6 +181,100 @@ pub(crate) struct DisplayStateResponse { displays: Vec, } +/// One physical monitor this host has, as the compositor reports it. +#[derive(Serialize, ToSchema)] +pub(crate) struct ApiMonitorInfo { + /// Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes. + connector: String, + /// Human label for a picker (`make model`, else the connector). + description: String, + /// `WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown). + mode: String, + /// Desktop-space top-left — what makes a head identifiable when two share a size. + x: i32, + y: i32, + /// Logical scale factor. + scale: f64, + /// The compositor's primary/focused head. + primary: bool, + /// Driven right now. A disabled head is still listed, so it can be explained rather than missing. + enabled: bool, + /// Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only). + managed: bool, + /// True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor. + selected: bool, +} + +/// The host's physical monitors + which one capture is pinned to. +#[derive(Serialize, ToSchema)] +pub(crate) struct MonitorsResponse { + /// Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved. + compositor: Option, + /// The heads, ordered left-to-right by desktop position. + monitors: Vec, + /// The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing, + /// so the console can show "pinned to DP-2, which this host doesn't have". + pinned: Option, + /// Why the list is empty, when enumeration failed (compositor unreachable, unsupported + /// platform). `None` with an empty list means "asked, and there are none". + error: Option, +} + +/// Physical monitors +/// +/// The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and +/// for rendering a picker. Read-only: this never creates, moves or disables anything. Note these +/// are *not* the managed virtual displays — those are `/display/state`. See +/// `design/per-monitor-portal-capture.md` §5.1. +#[utoipa::path( + get, + path = "/display/monitors", + tag = "display", + operation_id = "getDisplayMonitors", + responses( + (status = OK, description = "The host's physical monitors", body = MonitorsResponse), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn get_display_monitors() -> Json { + let pinned = pf_host_config::config().capture_monitor.clone(); + // 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)), + Err(e) => (None, Err(e)), + }) + .await + .unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}")))); + let (monitors, error) = match listed { + Ok(ms) => ( + ms.into_iter() + .map(|m| ApiMonitorInfo { + mode: m.mode_label(), + selected: pinned + .as_deref() + .is_some_and(|p| p.eq_ignore_ascii_case(&m.connector)), + connector: m.connector, + description: m.description, + x: m.x, + y: m.y, + scale: m.scale, + primary: m.primary, + enabled: m.enabled, + managed: m.managed, + }) + .collect(), + None, + ), + Err(e) => (Vec::new(), Some(format!("{e:#}"))), + }; + Json(MonitorsResponse { + compositor, + monitors, + pinned, + error, + }) +} + /// Request body for `releaseDisplay`. #[derive(Deserialize, ToSchema)] pub(crate) struct ReleaseDisplayRequest { diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 8d8e73a8..d7d77a7e 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -1056,6 +1056,29 @@ async fn display_state_and_release_empty() { assert_eq!(body["released"], 0); } +/// `/display/monitors` is wired, auth-gated, and — the point of the test — **always answers 200 +/// with a well-formed envelope**, including on a test host with no compositor to enumerate. The +/// console renders a picker from this; an enumeration failure has to arrive as an `error` string +/// next to an empty list, never as a 5xx that reads to the UI as "the host is broken". +#[tokio::test] +async fn display_monitors_answers_even_with_no_compositor() { + let app = test_app(test_state(), None); + + let (status, body) = send(&app, get_req("/api/v1/display/monitors")).await; + assert_eq!(status, StatusCode::OK); + assert!(body["monitors"].is_array(), "monitors is always an array"); + // No compositor on the test host ⇒ either a clean empty list or an explained failure, never + // both empty AND silent about why. + let listed = body["monitors"].as_array().map(|a| a.len()).unwrap_or(0); + assert!( + listed > 0 || !body["error"].is_null() || body["compositor"].is_null(), + "an empty list must carry an error or an absent compositor: {body}" + ); + // The pin is reported verbatim so the console can flag "pinned to a monitor you don't have"; + // unset on the test host. + assert!(body["pinned"].is_null(), "no PUNKTFUNK_CAPTURE_MONITOR set"); +} + #[tokio::test] async fn native_pairing_arm_show_and_unpair() { let np = Arc::new(