feat(vdisplay/windows): the console can finally show the operator's real screens

`GET /display/monitors` returned an empty list on every Windows host, plus a LINUX error string:
`monitors::list` is a per-compositor dispatch whose non-Linux arm bails, and it never even got
that far because `detect()` was not cfg-gated — on Windows it fell through to an
`XDG_CURRENT_DESKTOP` sniff and failed with advice about setting `PUNKTFUNK_COMPOSITOR`, which the
handler puts VERBATIM into the response. So the console showed no physical screen and its only
explanation was Linux troubleshooting (sweep §13.17).

The data was there all along. `target_inventory()` already walks the CCD database; it now also
reports the geometry that walk had in hand — the driving source's position and mode, the GDI
device name, the refresh rational, and `primary`. No second CCD walker.

* `monitors::list_windows()` maps that onto `PhysicalMonitor`. Two fields cannot mean on Windows
  what they mean on Linux and are reported honestly rather than invented: `scale` is always 1.0
  (Windows scaling is per-monitor DPI applied per application, not a compositor-global logical
  scale — so the geometry is PIXELS), and an INACTIVE head gets zeroed geometry because the CCD
  mode indices are only valid for active paths. Inactive heads are still listed, per `list`'s own
  contract, so "why can't I pick it?" keeps an answer.
* `managed` is finally truthful. The doc says only KWin can tell a virtual output from a real head;
  Windows can too, via our own EDID manufacturer id in the device path (21eda37a).
* `detect()` gets an honest non-Linux arm, the operator pin gated with it — naming a Wayland
  compositor on Windows could never be honoured either.
* The handler reports `compositor: "windows"` instead of null-plus-a-Linux-error.

ON GLASS (.173, host 0.21.0, TV attached):

    connector=\\.\DISPLAY1  enabled=true  managed=false  primary=true
    3840x2160 @165Hz  pos=(0,0)  "LG TV SSCR2"

Pinned by an `#[ignore]`d hardware case that asserts at least one NON-managed head is visible —
the exact thing that was missing. Read-only, so it is safe against a live host.

This also gives `capture_monitor` a picker on Windows, where the handler currently hardcodes
`pinned: None`; wiring that is left for the per-monitor-capture work rather than smuggled in here.

Verified on .173: `clippy -p punktfunk-host -p pf-vdisplay -p pf-win-display --all-targets
-- -D warnings` clean; both xcheck targets clean.
This commit is contained in:
2026-07-29 00:18:27 +02:00
parent 735cd99e8f
commit 22f2686680
5 changed files with 218 additions and 31 deletions
+17 -1
View File
@@ -268,6 +268,22 @@ pub fn with_env_lock<R>(f: impl FnOnce() -> R) -> R {
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
pub fn detect() -> Result<Compositor> {
// Compositor detection is a Linux question — the variants ARE the Linux backends. Asked
// anywhere else this used to fall through to the XDG sniff below and fail with advice about
// `XDG_CURRENT_DESKTOP` and `PUNKTFUNK_COMPOSITOR`, which `mgmt/display.rs` puts VERBATIM into
// the `/display/monitors` response — so on a Windows host the console's only explanation for an
// empty monitor picker was Linux troubleshooting (sweep §13.17). The operator pin is gated with
// it: naming a Wayland compositor on Windows cannot be honoured either.
#[cfg(not(target_os = "linux"))]
{
anyhow::bail!(
"compositor detection is Linux-only; on {} the host enumerates displays through the OS \
display API instead (`vdisplay::monitors::list_windows`)",
std::env::consts::OS
)
}
#[cfg(target_os = "linux")]
{
if let Some(v) = pf_host_config::config().compositor.as_deref() {
return compositor_from_pin(v).ok_or_else(|| {
anyhow::anyhow!(
@@ -275,7 +291,6 @@ pub fn detect() -> Result<Compositor> {
)
});
}
#[cfg(target_os = "linux")]
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
return Ok(c);
}
@@ -297,6 +312,7 @@ pub fn detect() -> Result<Compositor> {
)
}
}
}
/// Attach-only probes: while any scope is held, backend `create` paths must not stop, relaunch,
/// or take over box sessions — they may only attach to an already-live output, and fail fast
@@ -111,6 +111,62 @@ pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
}
}
/// Every head Windows reports — the non-compositor counterpart to [`list`].
///
/// Windows has no compositor to ask, so this reads the same CCD database the rest of the Windows
/// backend drives and reports what it finds. Until this existed the mgmt API answered
/// `/display/monitors` on Windows with an empty list and a LINUX error string (`detect()` fell
/// through to an `XDG_CURRENT_DESKTOP` sniff), so the console could neither show the operator's
/// screen nor honestly say why.
///
/// INACTIVE heads are listed too, with zeroed geometry and `enabled: false` — the same contract
/// [`list`] documents, so "why can't I pick it?" still has an answer.
///
/// Two fields cannot mean here what they mean on Linux, and are reported honestly rather than
/// invented:
/// * `scale` is always `1.0`. Windows scaling is per-monitor DPI applied by each application, not
/// a compositor-global logical scale, so there is no factor that would make these coordinates
/// "logical" the way the module doc means. The geometry below is therefore PIXELS.
/// * `refresh_mhz` comes from the path's own rational rate, which keeps 59.94 distinct from 60.
#[cfg(windows)]
pub fn list_windows() -> Result<Vec<PhysicalMonitor>> {
let inv = pf_win_display::win_display::target_inventory();
if inv.is_empty() {
// Distinguish "reached it, nothing there" from a failure, exactly as [`list`] promises:
// an empty CCD database is a real state (every panel off — measured on .173 with the TV
// powered down), not an error.
return Ok(Vec::new());
}
Ok(inv
.into_iter()
.map(|t| {
// The GDI name is what an operator recognises and what capture pins on; an inactive
// path has none, so fall back to the stable target id rather than an empty string —
// `resolve` matches on this, and a blank id can never be pinned.
let connector = if t.gdi_name.is_empty() {
format!("target-{}", t.target_id)
} else {
t.gdi_name
};
PhysicalMonitor {
description: describe("", &t.friendly, &connector),
connector,
width: t.width,
height: t.height,
refresh_mhz: t.refresh_mhz,
x: t.x,
y: t.y,
scale: 1.0,
primary: t.primary,
enabled: t.active,
// Unlike the Linux backends, Windows CAN say this reliably: our IddCx monitors
// carry our own EDID manufacturer id in their device path.
managed: t.ours,
}
})
.collect())
}
/// 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
@@ -1057,6 +1057,40 @@ mod tests {
);
}
/// What `/display/monitors` will now answer on Windows — the operator's real screens.
///
/// Read-only, so it is safe against a live host. Before `monitors::list_windows` existed this
/// endpoint returned an empty list plus a LINUX error string on every Windows box (`detect()`
/// fell through to an `XDG_CURRENT_DESKTOP` sniff), so the console could show no physical
/// screen and could not honestly say why.
#[test]
#[ignore = "hardware: reads the live display topology"]
fn live_windows_monitor_enumeration_reports_the_physical_screens() {
let ms = crate::monitors::list_windows().expect("list_windows");
for m in &ms {
println!(
"connector={:<14} enabled={:<5} managed={:<5} primary={:<5} {:>5}x{:<5} @{:>3}Hz \
pos=({},{}) {:?}",
m.connector,
m.enabled,
m.managed,
m.primary,
m.width,
m.height,
m.refresh_mhz / 1000,
m.x,
m.y,
m.description
);
}
assert!(!ms.is_empty(), "no monitors enumerated at all");
// The point of the change: a real, non-managed head is visible to the console.
assert!(
ms.iter().any(|m| !m.managed),
"every enumerated head is one of OURS — the operator's physical screen is still missing"
);
}
/// The ACTIVE display targets, as `(target_id, friendly)` — not just a count.
///
/// Counting alone cannot tell "the physical is still lit" from "the physical was deactivated
+69 -2
View File
@@ -998,6 +998,25 @@ pub struct TargetInventory {
pub friendly: String,
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
pub monitor_device_path: String,
/// One of OUR virtual displays (see [`is_our_virtual_display`]) — the reliable answer to
/// "is this the operator's screen or something we made?", which the connector class cannot give.
pub ours: bool,
/// GDI device name (`\\.\DISPLAY1`) of the SOURCE driving this target; empty when inactive
/// (an inactive path has no source). This is the id a Windows operator recognises and the one
/// capture pins on.
pub gdi_name: String,
/// Desktop position + mode of the driving source, in PIXELS. All zero when inactive: the CCD
/// mode indices are only valid for active paths, and inventing geometry for a dark head would
/// be worse than reporting none.
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
/// Refresh in mHz (60000 = 60 Hz), from the path's own `refreshRate` rational. 0 when the
/// path reports no rate (inactive, or a target that does not drive one).
pub refresh_mhz: u32,
/// The desktop origin sits on this head — Windows' notion of "primary".
pub primary: bool,
}
/// EDID manufacturer id of punktfunk's own IddCx monitors, as it appears in the PnP hardware id and
@@ -1131,18 +1150,66 @@ pub fn target_inventory() -> Vec<TargetInventory> {
let (mut external_physical, mut tech) = output_tech_class(req.outputTechnology);
// Our own IddCx monitor claims HDMI, so the connector class alone would call it one of the
// operator's panels — see `is_our_virtual_display` for what that broke.
if is_our_virtual_display(&monitor_device_path) {
let ours = is_our_virtual_display(&monitor_device_path);
if ours {
external_physical = false;
tech = "punktfunk-virtual";
}
let is_active = active.contains(&key);
// Geometry + the GDI name come from the SOURCE this path drives, and only an ACTIVE path
// has one — `modeInfoIdx` is the INVALID sentinel otherwise, so everything stays zeroed
// rather than indexing the mode table with 0xffffffff.
let (mut gdi_name, mut x, mut y, mut width, mut height) =
(String::new(), 0i32, 0i32, 0u32, 0u32);
if is_active {
// SAFETY: POD union read (header) — `modeInfoIdx` overlays a same-sized bitfield
// struct, both valid for every bit pattern. Used only as a bounds-checked index below.
let idx = unsafe { p.sourceInfo.Anonymous.modeInfoIdx } as usize;
if let Some(m) = modes.get(idx) {
if m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE {
// SAFETY: discriminated union read — the `infoType` test directly above is the
// discriminant the CCD contract defines for `sourceMode`.
let sm = unsafe { m.Anonymous.sourceMode };
x = sm.position.x;
y = sm.position.y;
width = sm.width;
height = sm.height;
}
}
let mut src = DISPLAYCONFIG_SOURCE_DEVICE_NAME::default();
src.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
src.header.size = size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32;
src.header.adapterId = p.sourceInfo.adapterId;
src.header.id = p.sourceInfo.id;
// SAFETY: `src.header` is a live local whose `size` was just set to the enclosing
// struct's own `size_of`, which is the contract telling the OS how many bytes it may
// write; the struct outlives this synchronous call.
if unsafe { DisplayConfigGetDeviceInfo(&mut src.header) } == 0 {
gdi_name = utf16z_str(&src.viewGdiDeviceName);
}
}
// A rational, not a scalar: mHz keeps 59.94 distinguishable from 60 without a float.
let refresh_mhz = match t.refreshRate.Denominator {
0 => 0,
d => (u64::from(t.refreshRate.Numerator) * 1000 / u64::from(d)) as u32,
};
out.push(TargetInventory {
target_id: t.id,
active: active.contains(&key),
active: is_active,
external_physical,
internal_panel: tech == "internal-panel",
tech,
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
monitor_device_path,
ours,
gdi_name,
// Windows' primary is the head at the desktop origin.
primary: is_active && x == 0 && y == 0,
x,
y,
width,
height,
refresh_mhz,
});
}
out
+16 -2
View File
@@ -250,10 +250,24 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
let pinned = crate::vdisplay::capture_monitor();
#[cfg(not(target_os = "linux"))]
let pinned: Option<String> = 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() {
// Enumeration shells out / round-trips D-Bus + Wayland (and on Windows walks the CCD
// database, which can serialize on the display-config lock), so keep it off the async worker.
let (compositor, listed) = tokio::task::spawn_blocking(|| {
// Windows has no compositor to detect — asking used to fail with Linux advice about
// XDG_CURRENT_DESKTOP, which landed verbatim in `error` below and was the console's only
// explanation for an empty picker. Report the display API we actually used instead.
#[cfg(windows)]
{
(
Some("windows".to_string()),
crate::vdisplay::monitors::list_windows(),
)
}
#[cfg(not(windows))]
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}"))));