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:
@@ -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 {
|
||||
|
||||
@@ -188,6 +188,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, 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!(
|
||||
|
||||
@@ -181,6 +181,100 @@ pub(crate) struct DisplayStateResponse {
|
||||
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`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(crate) struct ReleaseDisplayRequest {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user