diff --git a/Cargo.lock b/Cargo.lock index 37e0ea4e..ac8af439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2860,6 +2860,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pf-win-display" +version = "0.12.0" +dependencies = [ + "anyhow", + "pf-paths", + "punktfunk-core", + "serde_json", + "tracing", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "pf-zerocopy" version = "0.12.0" @@ -3177,6 +3189,7 @@ dependencies = [ "pf-gpu", "pf-host-config", "pf-paths", + "pf-win-display", "pf-zerocopy", "pipewire", "punktfunk-core", diff --git a/Cargo.toml b/Cargo.toml index 926c8d6e..30a3db07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/pf-gpu", "crates/pf-zerocopy", "crates/pf-frame", + "crates/pf-win-display", "crates/pyrowave-sys", "clients/probe", "clients/linux", diff --git a/crates/pf-win-display/Cargo.toml b/crates/pf-win-display/Cargo.toml new file mode 100644 index 00000000..596d01d2 --- /dev/null +++ b/crates/pf-win-display/Cargo.toml @@ -0,0 +1,32 @@ +# The Windows display-topology cluster (plan §W6): CCD/GDI path activation, mode-setting, HDR +# advanced-colour toggles, source-rect geometry ([`win_display`]); PnP monitor devnode enable/disable +# ([`monitor_devnode`]); and the WM_DISPLAYCHANGE / device-arrival watch ([`display_events`]). A leaf +# so the IDD-push capturer (pf-capture) and the pf-vdisplay backend (host) depend on it as a PEER +# instead of the capturer reaching back into the host for display utilities. Windows-only content; +# compiles to an empty lib elsewhere. +[package] +name = "pf-win-display" +version = "0.12.0" +edition = "2021" +rust-version.workspace = true +license = "MIT OR Apache-2.0" +description = "punktfunk host Windows display-topology helpers: CCD/GDI mode-set + path activation, HDR advanced colour, PnP monitor devnodes, and the display-change event watch." +publish = false + +[target.'cfg(target_os = "windows")'.dependencies] +# `Mode` (the negotiated display mode) is the core wire type; `pf-paths` for the pnp-disabled-monitors +# state file. +punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } +pf-paths = { path = "../pf-paths" } +anyhow = "1" +tracing = "0.1" +# The pnp-disabled-monitors state file (a `Vec` of instance ids) is serialized as JSON. +serde_json = "1" +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Display", + "Win32_Graphics_Gdi", + "Win32_UI_WindowsAndMessaging", + "Win32_System_LibraryLoader", +] } diff --git a/crates/punktfunk-host/src/windows/display_events.rs b/crates/pf-win-display/src/display_events.rs similarity index 97% rename from crates/punktfunk-host/src/windows/display_events.rs rename to crates/pf-win-display/src/display_events.rs index 126f41d3..05001b95 100644 --- a/crates/punktfunk-host/src/windows/display_events.rs +++ b/crates/pf-win-display/src/display_events.rs @@ -1,6 +1,6 @@ //! OS display-event listener — the attribution sensor for the periodic-stutter disturbance class. //! -//! The capture-stall watch ([`crate::capture::windows::idd_push`]) can SAY "DWM stopped composing +//! The capture-stall watch (the IDD-push capturer in `pf-capture`) can SAY "DWM stopped composing //! on a stable period", but not WHY. Field evidence (Apollo's Stuttering Clinic, Apollo #384, //! Tom's HW "stutter from disabled-but-connected monitors") points at a connected-but-idle sink //! (standby TV/monitor, active HDMI cable, KVM/AVR) re-probing the link every few seconds; the GPU @@ -49,7 +49,7 @@ use windows::Win32::UI::WindowsAndMessaging::{ /// One OS-visible display event, timestamped at receipt. #[derive(Clone)] -pub(crate) struct DisplayEvent { +pub struct DisplayEvent { pub at: Instant, pub kind: DisplayEventKind, /// Monitor device instance id for arrival/removal (e.g. `DISPLAY\GSM83CD\...`), else `None`. @@ -57,7 +57,7 @@ pub(crate) struct DisplayEvent { } #[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum DisplayEventKind { +pub enum DisplayEventKind { /// A monitor device interface ARRIVED — a sink (re)connected as Windows sees it. MonitorArrival, /// A monitor device interface was REMOVED — a sink dropped as Windows sees it. @@ -103,7 +103,7 @@ fn state() -> &'static Mutex { /// Start the listener thread (idempotent). Degraded-not-fatal: if window/registration creation /// fails the ring just stays empty — the stall log then reports "listener unavailable" naturally /// via empty summaries, and streaming is unaffected. -pub(crate) fn spawn_once() { +pub fn spawn_once() { static ONCE: Once = Once::new(); ONCE.call_once(|| { let spawned = std::thread::Builder::new() @@ -119,7 +119,7 @@ pub(crate) fn spawn_once() { } /// Events with `from <= at <= to`, oldest-first. -pub(crate) fn events_between(from: Instant, to: Instant) -> Vec { +pub fn events_between(from: Instant, to: Instant) -> Vec { let st = state().lock().unwrap(); st.events .iter() @@ -130,7 +130,7 @@ pub(crate) fn events_between(from: Instant, to: Instant) -> Vec { /// Compact one-line summary for log fields: `"monitor-removal x2 (DISPLAY\GSM83CD\...), /// devnodes-changed x1"`; `"none"` when empty. -pub(crate) fn summarize(events: &[DisplayEvent]) -> String { +pub fn summarize(events: &[DisplayEvent]) -> String { if events.is_empty() { return "none".into(); } @@ -159,7 +159,7 @@ pub(crate) fn summarize(events: &[DisplayEvent]) -> String { /// The prime suspects for link-probe disturbances, from the cached inventory: external physical /// displays that are CONNECTED but not part of the desktop (standby TV / input-switched monitor). /// Rendered as `" ()"`. Never blocks on the CCD lock. -pub(crate) fn connected_inactive_externals() -> Vec { +pub fn connected_inactive_externals() -> Vec { let st = state().lock().unwrap(); st.inventory .iter() diff --git a/crates/pf-win-display/src/lib.rs b/crates/pf-win-display/src/lib.rs new file mode 100644 index 00000000..57fbe565 --- /dev/null +++ b/crates/pf-win-display/src/lib.rs @@ -0,0 +1,17 @@ +//! Windows display-topology helpers (plan §W6), extracted from the host's `windows/{win_display, +//! monitor_devnode,display_events}.rs` so the IDD-push capturer (`pf-capture`) and the pf-vdisplay +//! backend (the host) depend on them as a leaf PEER instead of the capturer reaching back into the +//! orchestrator. Windows-only; compiles to an empty lib elsewhere. +//! +//! - [`win_display`]: CCD/GDI path activation, mode-setting, HDR advanced-colour toggles, and the +//! source-desktop geometry the capturer duplicates. +//! - [`monitor_devnode`]: PnP monitor devnode enable/disable (the parallel-display isolation lever). +//! - [`display_events`]: the `WM_DISPLAYCHANGE` / device-arrival watch that lets a capture stall say +//! whether an OS display event coincided with it. + +#[cfg(target_os = "windows")] +pub mod display_events; +#[cfg(target_os = "windows")] +pub mod monitor_devnode; +#[cfg(target_os = "windows")] +pub mod win_display; diff --git a/crates/punktfunk-host/src/windows/monitor_devnode.rs b/crates/pf-win-display/src/monitor_devnode.rs similarity index 99% rename from crates/punktfunk-host/src/windows/monitor_devnode.rs rename to crates/pf-win-display/src/monitor_devnode.rs index b647c6ca..15f379fb 100644 --- a/crates/punktfunk-host/src/windows/monitor_devnode.rs +++ b/crates/pf-win-display/src/monitor_devnode.rs @@ -66,7 +66,7 @@ fn write_journal(ids: &[String]) { /// The standard device-interface-path → instance-id transform: strip the `\\?\` prefix and the /// trailing `#{interface-class-guid}`, then `#` separators become `\`. // pub(crate): `display_events` applies the same transform to DBT_DEVICEARRIVAL interface paths. -pub(crate) fn instance_id_from_interface_path(path: &str) -> Option { +pub fn instance_id_from_interface_path(path: &str) -> Option { let rest = path.strip_prefix(r"\\?\")?; let cut = rest.rfind("#{")?; Some(rest[..cut].replace('#', "\\")) diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/pf-win-display/src/win_display.rs similarity index 95% rename from crates/punktfunk-host/src/windows/win_display.rs rename to crates/pf-win-display/src/win_display.rs index 51aa019f..c8218501 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/pf-win-display/src/win_display.rs @@ -10,6 +10,12 @@ // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). #![deny(clippy::undocumented_unsafe_blocks)] +// These CCD/GDI FFI helpers were `pub(crate) unsafe fn` before the pf-win-display carve (plan §W6), +// where `missing_safety_doc` stays silent; crossing the crate boundary makes them `pub` and would +// demand a `# Safety` heading on each. Their callers' obligations (call on the right desktop thread +// with a live OS target id) are stated in each fn's prose doc, and this is an internal +// (publish=false) leaf — keep the pre-carve behavior rather than adding 12 formal headings. +#![allow(clippy::missing_safety_doc)] use std::mem::size_of; @@ -41,7 +47,7 @@ use windows::Win32::Graphics::Gdi::{ ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE, }; -use crate::vdisplay::Mode; +use punktfunk_core::Mode; /// Force the desktop into EXTEND topology - the programmatic equivalent of the Win+P / DisplaySwitch /// "Extend" shortcut. Windows defaults a FRESHLY-ADDED monitor into CLONE/duplicate mode when a @@ -52,7 +58,7 @@ use crate::vdisplay::Mode; /// OWN active path, so the rest of bring-up (`resolve_gdi_name` -> `set_active_mode` -> /// `isolate_displays_ccd`) proceeds. Best-effort + idempotent: a no-op on a single-display (already /// sole/extended) box, so it is safe to call unconditionally. `rc == 0` is success. -pub(crate) unsafe fn force_extend_topology() { +pub unsafe fn force_extend_topology() { // A topology flag with no supplied path/mode arrays tells the OS to recompute + apply that preset // for the currently-connected displays (the same code path DisplaySwitch.exe drives). let rc = SetDisplayConfig(None, None, SDC_APPLY | SDC_TOPOLOGY_EXTEND); @@ -78,7 +84,7 @@ pub(crate) unsafe fn force_extend_topology() { /// source not already driving another display — mode indices invalidated so `SDC_ALLOW_CHANGES` lets /// the OS pick modes for the new path. Returns `true` when the apply reports success; the caller /// still re-polls [`resolve_gdi_name`] to confirm the path actually committed. -pub(crate) unsafe fn activate_target_path(target_id: u32) -> bool { +pub unsafe fn activate_target_path(target_id: u32) -> bool { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm).is_err() { @@ -169,7 +175,7 @@ pub(crate) unsafe fn activate_target_path(target_id: u32) -> bool { /// Resolve the `\\.\DisplayN` GDI name for a virtual-display target id via the CCD API. Returns `None` /// until the OS activates the target into the desktop topology (needs a real WDDM GPU; on a /// GPU-less box this stays `None` even though ADD succeeded). -pub(crate) unsafe fn resolve_gdi_name(target_id: u32) -> Option { +pub unsafe fn resolve_gdi_name(target_id: u32) -> Option { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { @@ -212,7 +218,7 @@ pub(crate) unsafe fn resolve_gdi_name(target_id: u32) -> Option { /// /// # Safety /// Calls the GDI/CCD APIs; safe to call from any thread. -pub(crate) unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> { +pub unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> { let gdi = resolve_gdi_name(target_id)?; let wname: Vec = gdi.encode_utf16().chain(std::iter::once(0)).collect(); let mut dm = DEVMODEW { @@ -230,7 +236,7 @@ pub(crate) unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> { /// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it /// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so /// WGC keeps HDR on the normal desktop. Returns true on a successful `DisplayConfigSetDeviceInfo`. -pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool { +pub unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { @@ -283,7 +289,7 @@ pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool { /// list (both happen transiently during a display-topology re-probe): the caller decides the fallback — /// the capture loop's poller keeps the last known value, since reading a blip as "HDR off" used to cost /// an HDR session TWO spurious ring recreates (false, then true again a poll later). -pub(crate) unsafe fn advanced_color_enabled(target_id: u32) -> Option { +pub unsafe fn advanced_color_enabled(target_id: u32) -> Option { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { @@ -324,9 +330,9 @@ pub(crate) unsafe fn advanced_color_enabled(target_id: u32) -> Option { /// ADVERTISES the mode; Windows otherwise activates an IDD target at a 1280x720 default, so the /// ACTIVE mode (what DXGI Desktop Duplication captures) must be set explicitly. CDS_TEST first so a /// mode the driver didn't advertise just leaves the default instead of erroring the session. -// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper +// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper // (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged). -pub(crate) fn set_active_mode(gdi_name: &str, mode: Mode) { +pub fn set_active_mode(gdi_name: &str, mode: Mode) { let wname: Vec = gdi_name.encode_utf16().chain(std::iter::once(0)).collect(); // Enumerate the modes the driver actually advertises for this output and pick the best match for @@ -461,8 +467,8 @@ pub(crate) fn set_active_mode(gdi_name: &str, mode: Mode) { } /// Saved active display topology, for restoring on teardown. -// pub(crate) so vdisplay::pf_vdisplay's Monitor can hold the same saved-topology type. -pub(crate) type SavedConfig = (Vec, Vec); +// pub so vdisplay::pf_vdisplay's Monitor can hold the same saved-topology type. +pub type SavedConfig = (Vec, Vec); /// `DISPLAYCONFIG_PATH_ACTIVE` (wingdi.h) — the `flags` bit marking a path active. The `windows` crate /// doesn't export it, so define it here. @@ -505,7 +511,7 @@ unsafe fn query_active_config() -> Option { /// that would still be lit besides the managed virtual set. `None` on query failure. Used to VERIFY /// isolation actually took, and (in the `primary` topology) to detect a physical that is ALREADY /// active so we can skip a force-EXTEND that would reset its refresh. -pub(crate) unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option { +pub unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option { let (paths, _) = query_active_config()?; Some( paths @@ -522,7 +528,7 @@ pub(crate) unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option /// attribution inventory. `external_physical` is the load-bearing bit: a standby TV/monitor on a /// real connector is the prime suspect for the periodic link-probe stutter class, while internal /// panels and indirect/virtual targets (our own IDD included) are not. -pub(crate) struct TargetInventory { +pub struct TargetInventory { pub target_id: u32, /// Whether any active path drives this target (part of the desktop right now). pub active: bool, @@ -568,7 +574,7 @@ fn utf16z_str(buf: &[u16]) -> String { /// matrix to unique targets) with its name, connector class and active state. Read-only CCD; can /// briefly serialize on the display-config lock during topology churn — callers must keep it OFF /// the capture thread (`display_events` runs it on its own listener thread and caches). -pub(crate) unsafe fn target_inventory() -> Vec { +pub unsafe fn target_inventory() -> Vec { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm).is_err() { @@ -647,9 +653,9 @@ pub(crate) unsafe fn target_inventory() -> Vec { /// Re-issued with the grown/shrunk set on each slot add/remove while the group lives; the FIRST call's /// returned config is what teardown restores (the caller keeps it on the group record and discards /// later returns). Returns the original active config to restore on teardown. -// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper +// pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper // (it operates on real OS target ids — a pf-vdisplay monitor's target_id qualifies). -pub(crate) unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option { +pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option { // Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes. let saved = query_active_config()?; @@ -702,7 +708,7 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option Option<(i32, i32, i32, i32)> { +pub unsafe fn source_desktop_rect(target_id: u32) -> Option<(i32, i32, i32, i32)> { let (paths, modes) = query_active_config()?; for p in &paths { if p.targetInfo.id != target_id || p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 { @@ -730,7 +736,7 @@ pub(crate) unsafe fn source_desktop_rect(target_id: u32) -> Option<(i32, i32, i3 /// treats the source at `(0,0)` as primary, so auto-row's first member lands primary — the group's /// designated member. Paths not named stay where they are. Best-effort: a failure leaves the OS /// placement (mouse crossing may not match the layout table until the next apply). -pub(crate) unsafe fn apply_source_positions(positions: &[(u32, i32, i32)]) { +pub unsafe fn apply_source_positions(positions: &[(u32, i32, i32)]) { if positions.len() < 2 { return; // a single (or no) member sits at the origin — nothing to arrange } @@ -789,7 +795,7 @@ pub(crate) unsafe fn apply_source_positions(positions: &[(u32, i32, i32)]) { /// atomic CCD `SetDisplayConfig` (NOT GDI `CDS_SET_PRIMARY`, which storms /// `DXGI_ERROR_MODE_CHANGE_IN_PROGRESS` when another display is live — see [`set_active_mode`]). /// Returns the original config to restore on teardown. -pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option { +pub unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { @@ -871,8 +877,8 @@ pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option