diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index efba7777..bfad93f9 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -268,33 +268,49 @@ pub fn with_env_lock(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 { - if let Some(v) = pf_host_config::config().compositor.as_deref() { - return compositor_from_pin(v).ok_or_else(|| { - anyhow::anyhow!( - "unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)" - ) - }); + // 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(c) = compositor_for_kind(detect_active_session().kind) { - return Ok(c); - } - let desktop = std::env::var("XDG_CURRENT_DESKTOP") - .unwrap_or_default() - .to_ascii_uppercase(); - if desktop.contains("KDE") { - Ok(Compositor::Kwin) - } else if desktop.contains("GNOME") { - Ok(Compositor::Mutter) - } else if desktop.contains("HYPRLAND") { - Ok(Compositor::Hyprland) - } else if desktop.contains("SWAY") || desktop.contains("WLROOTS") { - Ok(Compositor::Wlroots) - } else { - anyhow::bail!( - "could not detect compositor: no live graphical session for this uid and \ - XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR" - ) + { + if let Some(v) = pf_host_config::config().compositor.as_deref() { + return compositor_from_pin(v).ok_or_else(|| { + anyhow::anyhow!( + "unknown PUNKTFUNK_COMPOSITOR '{v}' (kwin|wlroots|hyprland|mutter|gamescope)" + ) + }); + } + if let Some(c) = compositor_for_kind(detect_active_session().kind) { + return Ok(c); + } + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_ascii_uppercase(); + if desktop.contains("KDE") { + Ok(Compositor::Kwin) + } else if desktop.contains("GNOME") { + Ok(Compositor::Mutter) + } else if desktop.contains("HYPRLAND") { + Ok(Compositor::Hyprland) + } else if desktop.contains("SWAY") || desktop.contains("WLROOTS") { + Ok(Compositor::Wlroots) + } else { + anyhow::bail!( + "could not detect compositor: no live graphical session for this uid and \ + XDG_CURRENT_DESKTOP='{desktop}'; set PUNKTFUNK_COMPOSITOR" + ) + } } } diff --git a/crates/pf-vdisplay/src/vdisplay/monitors.rs b/crates/pf-vdisplay/src/vdisplay/monitors.rs index 54252979..bc08bfa0 100644 --- a/crates/pf-vdisplay/src/vdisplay/monitors.rs +++ b/crates/pf-vdisplay/src/vdisplay/monitors.rs @@ -111,6 +111,62 @@ pub fn list(compositor: Compositor) -> Result> { } } +/// 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> { + 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 diff --git a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs index 0ab570c8..b3b3325a 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs @@ -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 diff --git a/crates/pf-win-display/src/win_display.rs b/crates/pf-win-display/src/win_display.rs index dcb50e43..7a5e6140 100644 --- a/crates/pf-win-display/src/win_display.rs +++ b/crates/pf-win-display/src/win_display.rs @@ -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 { 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::() 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 diff --git a/crates/punktfunk-host/src/mgmt/display.rs b/crates/punktfunk-host/src/mgmt/display.rs index cea6be3f..2d6e2414 100644 --- a/crates/punktfunk-host/src/mgmt/display.rs +++ b/crates/punktfunk-host/src/mgmt/display.rs @@ -250,10 +250,24 @@ pub(crate) async fn get_display_monitors() -> Json { 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)), - Err(e) => (None, Err(e)), + // 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}"))));