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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"))));
|
||||
|
||||
Reference in New Issue
Block a user