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
|
/// 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.
|
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
|
||||||
pub fn detect() -> Result<Compositor> {
|
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() {
|
if let Some(v) = pf_host_config::config().compositor.as_deref() {
|
||||||
return compositor_from_pin(v).ok_or_else(|| {
|
return compositor_from_pin(v).ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
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) {
|
if let Some(c) = compositor_for_kind(detect_active_session().kind) {
|
||||||
return Ok(c);
|
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,
|
/// 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
|
/// 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.
|
/// 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
|
/// **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.
|
/// 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
|
/// Counting alone cannot tell "the physical is still lit" from "the physical was deactivated
|
||||||
|
|||||||
@@ -998,6 +998,25 @@ pub struct TargetInventory {
|
|||||||
pub friendly: String,
|
pub friendly: String,
|
||||||
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
|
/// Monitor device interface path — maps to the PnP instance id (`monitor_devnode`).
|
||||||
pub monitor_device_path: String,
|
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
|
/// 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);
|
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
|
// 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.
|
// 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;
|
external_physical = false;
|
||||||
tech = "punktfunk-virtual";
|
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 {
|
out.push(TargetInventory {
|
||||||
target_id: t.id,
|
target_id: t.id,
|
||||||
active: active.contains(&key),
|
active: is_active,
|
||||||
external_physical,
|
external_physical,
|
||||||
internal_panel: tech == "internal-panel",
|
internal_panel: tech == "internal-panel",
|
||||||
tech,
|
tech,
|
||||||
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
|
friendly: utf16z_str(&req.monitorFriendlyDeviceName),
|
||||||
monitor_device_path,
|
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
|
out
|
||||||
|
|||||||
@@ -250,10 +250,24 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
|
|||||||
let pinned = crate::vdisplay::capture_monitor();
|
let pinned = crate::vdisplay::capture_monitor();
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
let pinned: Option<String> = None;
|
let pinned: Option<String> = None;
|
||||||
// Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker.
|
// Enumeration shells out / round-trips D-Bus + Wayland (and on Windows walks the CCD
|
||||||
let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() {
|
// 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)),
|
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
|
||||||
Err(e) => (None, Err(e)),
|
Err(e) => (None, Err(e)),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}"))));
|
.unwrap_or_else(|e| (None, Err(anyhow::anyhow!("enumeration task failed: {e}"))));
|
||||||
|
|||||||
Reference in New Issue
Block a user