feat(vdisplay): enumerate the host's physical monitors
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) <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,14 @@ pub struct HostConfig {
|
|||||||
pub perf: bool,
|
pub perf: bool,
|
||||||
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
/// `PUNKTFUNK_VIDEO_SOURCE` — GameStream video source select (`virtual` / `portal` / unset → synthetic).
|
||||||
pub video_source: Option<String>,
|
pub video_source: Option<String>,
|
||||||
|
/// `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<String>,
|
||||||
/// `PUNKTFUNK_COMPOSITOR` — explicit compositor override (operator/CI/test). NOT the runtime-detected
|
/// `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.
|
/// session — this one is a constant operator knob; `apply_session_env` never writes it.
|
||||||
pub compositor: Option<String>,
|
pub compositor: Option<String>,
|
||||||
@@ -170,6 +178,11 @@ impl HostConfig {
|
|||||||
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
chacha20: env_on("PUNKTFUNK_CHACHA20").unwrap_or(true),
|
||||||
perf: flag("PUNKTFUNK_PERF"),
|
perf: flag("PUNKTFUNK_PERF"),
|
||||||
video_source: val("PUNKTFUNK_VIDEO_SOURCE"),
|
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"),
|
compositor: val("PUNKTFUNK_COMPOSITOR"),
|
||||||
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
gamepad: val("PUNKTFUNK_GAMEPAD"),
|
||||||
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
vdisplay: val("PUNKTFUNK_VDISPLAY"),
|
||||||
|
|||||||
@@ -359,6 +359,13 @@ pub fn probe(compositor: Compositor) -> Result<()> {
|
|||||||
#[path = "vdisplay/policy.rs"]
|
#[path = "vdisplay/policy.rs"]
|
||||||
pub mod policy;
|
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
|
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
|
||||||
// property-tested; the registry executes the side effects its transitions dictate.
|
// property-tested; the registry executes the side effects its transitions dictate.
|
||||||
#[path = "vdisplay/lifecycle.rs"]
|
#[path = "vdisplay/lifecycle.rs"]
|
||||||
|
|||||||
@@ -272,6 +272,58 @@ fn hyprctl(args: &[&str]) -> Result<String> {
|
|||||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
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<Vec<crate::monitors::PhysicalMonitor>> {
|
||||||
|
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-<n>` (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
|
/// 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
|
/// 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
|
/// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the
|
||||||
|
|||||||
@@ -131,6 +131,14 @@ struct DeviceState {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
uuid: Option<String>,
|
uuid: Option<String>,
|
||||||
enabled: bool,
|
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<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
/// Logical scale from the `scale` event. `None` ⇒ the protocol's documented default of 1.
|
||||||
|
scale: Option<f64>,
|
||||||
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
|
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
|
||||||
priority: Option<u32>,
|
priority: Option<u32>,
|
||||||
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
|
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
|
||||||
@@ -237,6 +245,15 @@ impl Dispatch<OutputDevice, u32> for State {
|
|||||||
match event {
|
match event {
|
||||||
DeviceEvent::Name { name } => entry.name = Some(name),
|
DeviceEvent::Name { name } => entry.name = Some(name),
|
||||||
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
|
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::Enabled { enabled } => entry.enabled = enabled != 0,
|
||||||
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
|
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
|
||||||
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
|
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).
|
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
|
||||||
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
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<Vec<crate::monitors::PhysicalMonitor>> {
|
||||||
|
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
|
/// 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`.
|
/// 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`.
|
/// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
|
||||||
|
|||||||
@@ -811,6 +811,61 @@ fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
|
|||||||
.map(|l| l.2)
|
.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<Vec<crate::monitors::PhysicalMonitor>> {
|
||||||
|
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
|
/// 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.
|
/// 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.
|
/// Best-effort: read failures (teardown races, shell restart) are silently skipped.
|
||||||
|
|||||||
@@ -229,6 +229,68 @@ fn output_names() -> Result<Vec<String>> {
|
|||||||
.collect())
|
.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<Vec<crate::monitors::PhysicalMonitor>> {
|
||||||
|
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).
|
/// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N).
|
||||||
fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
|
fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
|
||||||
let deadline = Instant::now() + timeout;
|
let deadline = Instant::now() + timeout;
|
||||||
|
|||||||
@@ -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<Vec<PhysicalMonitor>> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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.
|
// 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 {
|
let _ = pf_vdisplay::DISPLAY_EVENT_SINK.set(Box::new(|ev| match ev {
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(display::get_display_settings))
|
.routes(routes!(display::get_display_settings))
|
||||||
.routes(routes!(display::set_display_settings))
|
.routes(routes!(display::set_display_settings))
|
||||||
.routes(routes!(display::get_display_state))
|
.routes(routes!(display::get_display_state))
|
||||||
|
.routes(routes!(display::get_display_monitors))
|
||||||
.routes(routes!(display::release_display))
|
.routes(routes!(display::release_display))
|
||||||
.routes(routes!(display::set_display_layout))
|
.routes(routes!(display::set_display_layout))
|
||||||
.routes(routes!(
|
.routes(routes!(
|
||||||
|
|||||||
@@ -181,6 +181,100 @@ pub(crate) struct DisplayStateResponse {
|
|||||||
displays: Vec<ApiDisplayInfo>,
|
displays: Vec<ApiDisplayInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// The heads, ordered left-to-right by desktop position.
|
||||||
|
monitors: Vec<ApiMonitorInfo>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<MonitorsResponse> {
|
||||||
|
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`.
|
/// Request body for `releaseDisplay`.
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub(crate) struct ReleaseDisplayRequest {
|
pub(crate) struct ReleaseDisplayRequest {
|
||||||
|
|||||||
@@ -1056,6 +1056,29 @@ async fn display_state_and_release_empty() {
|
|||||||
assert_eq!(body["released"], 0);
|
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]
|
#[tokio::test]
|
||||||
async fn native_pairing_arm_show_and_unpair() {
|
async fn native_pairing_arm_show_and_unpair() {
|
||||||
let np = Arc::new(
|
let np = Arc::new(
|
||||||
|
|||||||
Reference in New Issue
Block a user