diff --git a/Cargo.lock b/Cargo.lock index 1617f45..72b0378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,6 +1528,9 @@ dependencies = [ "keyring-core", "log", "thiserror 2.0.20", + "wayland-client", + "wayland-protocols", + "x11rb", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fcd0b34..154d616 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,6 +115,25 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.23" +# Display-server clients, for FR-DSP-8's per-display profile acquisition. +# +# Neither is a new cost: winit already builds both, so the versions are the +# ones Slint's backend has resolved to and pinning anything else here would +# compile a second copy. Both are pure Rust — x11rb speaks the X11 wire +# protocol itself rather than binding libxcb, and wayland-client binds +# libwayland only under a feature that is off — which keeps the Android +# cross-compile a plain Rust dependency graph, the same criterion as the TLS +# and SQLite choices above. They are declared under a target predicate that +# excludes Android, where neither display server exists. +# +# `staging` on wayland-protocols is what carries `wp_color_manager_v1`: the +# colour-management extension is still staging upstream, which is the +# protocol-level statement of the thing FR-DSP-8 anticipates when it says +# Wayland's colour management "is not universally available". +x11rb = { version = "0.13", features = ["randr"] } +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client", "staging"] } + # Platform secure storage: Secret Service on Linux, Keystore on Android # (FR-NC-2). Credentials never touch the catalog or a plain file. # keyring 4 restructured its features: `v1` is the default set and brings diff --git a/core/dr-types/src/colour.rs b/core/dr-types/src/colour.rs index a38639c..6a40bae 100644 --- a/core/dr-types/src/colour.rs +++ b/core/dr-types/src/colour.rs @@ -39,6 +39,22 @@ pub struct Chromaticities { pub white: [f64; 2], } +impl Chromaticities { + /// This gamut's linear RGB to the ICC profile connection space, row-major. + /// + /// The same three columns [`ColourSpace::to_pcs_xyz`] produces, for a set + /// of primaries that is *not* one of the four the pipeline knows. That is + /// the only reason this is public: a display's profile describes primaries + /// nobody chose, and the only way to ask which of the four it is nearest + /// is to put both through the same reduction and compare the numbers + /// (see `dr_plat::display`). Comparing raw xy pairs instead would be + /// wrong, because a profile's colorants have already been adapted to D50 + /// and a space's published chromaticities have not. + pub fn to_pcs_xyz(&self) -> [f32; 9] { + narrow(mul(adaptation(white_xyz(self), PCS_D50), rgb_to_xyz(self))) + } +} + /// How a space maps linear light onto the numbers stored in a file. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Transfer { @@ -183,8 +199,7 @@ impl ColourSpace { /// defined at D50 and nowhere else — a profile carrying unadapted D65 /// colorants describes a space nobody asked for. pub fn to_pcs_xyz(self) -> [f32; 9] { - let c = self.chromaticities(); - narrow(mul(adaptation(white_xyz(&c), PCS_D50), rgb_to_xyz(&c))) + self.chromaticities().to_pcs_xyz() } /// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major. diff --git a/platform/dr-plat/Cargo.toml b/platform/dr-plat/Cargo.toml index 11fbc00..d2ac29c 100644 --- a/platform/dr-plat/Cargo.toml +++ b/platform/dr-plat/Cargo.toml @@ -12,6 +12,12 @@ log.workspace = true [target.'cfg(all(unix, not(target_os = "android")))'.dependencies] keyring.workspace = true +# The two display servers FR-PLAT-LIN-2 names, each asked for the profile it +# knows how to state (FR-DSP-8, `display.rs`). Both are already in the tree +# via winit, so this adds compilation of nothing that was not already built. +x11rb.workspace = true +wayland-client.workspace = true +wayland-protocols.workspace = true [target.'cfg(target_os = "android")'.dependencies] android-native-keyring-store.workspace = true diff --git a/platform/dr-plat/examples/display_probe.rs b/platform/dr-plat/examples/display_probe.rs new file mode 100644 index 0000000..3e9e217 --- /dev/null +++ b/platform/dr-plat/examples/display_probe.rs @@ -0,0 +1,36 @@ +//! What this machine's display server will say about its monitors. +//! +//! The same probe the application runs at startup, printed instead of used. +//! It exists for the reason `keyring_check` does: the answer depends entirely +//! on the desktop the code is running on, so the only way to know which +//! acquisition path a given machine takes is to ask it there. +//! +//! cargo run -p dr-plat --example display_probe + +fn main() { + env_logger::init(); + + let survey = dr_plat::DisplaySurvey::probe(); + println!("display server: {}", survey.server.label()); + println!(); + for (index, display) in survey.displays.iter().enumerate() { + println!("[{index}] {}", display.name); + println!(" space: {}", display.profile.space.label()); + println!(" reading: {}", display.profile.describe()); + println!( + " source: {:?} measured: {} approximated: {}", + display.profile.source, + display.profile.source.is_measured(), + display.profile.approximated + ); + match display.bounds { + Some(b) => println!( + " bounds: {}×{} at ({}, {})", + b.width, b.height, b.x, b.y + ), + // Expected on Wayland; see `DisplaySurvey::containing`. + None => println!(" bounds: not stated by this display server"), + } + println!(); + } +} diff --git a/platform/dr-plat/src/display.rs b/platform/dr-plat/src/display.rs new file mode 100644 index 0000000..c920a24 --- /dev/null +++ b/platform/dr-plat/src/display.rs @@ -0,0 +1,561 @@ +//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2 | NFR-PORT-1 +//! What colour the screen in front of the photographer actually is. +//! +//! The display path is colour-managed by encoding the render into the output +//! device's space (FR-DSP-6); that space is a parameter of composition, so +//! getting it right is a question of *asking the platform* rather than of +//! doing anything to the pipeline. This module is the asking. +//! +//! # Why this is a correctness problem and not a polish one +//! +//! FR-DSP-8 says so itself: "on a multi-monitor desktop with differing +//! profiles, showing wrong colours on the second display is a correctness +//! defect, not a polish item". Every other display requirement fails by being +//! slow. This one fails by being *quietly wrong* — a photographer grades a +//! skin tone on a wide-gamut panel, the app encodes sRGB into it, and the +//! result is oversaturated on screen and correct in the file, or the reverse. +//! Nothing about the image looks broken. That is the whole danger. +//! +//! # Acquisition is stated per display server, because it differs entirely +//! +//! - **X11** publishes ICC profiles as root-window properties: `_ICC_PROFILE` +//! for the first output and `_ICC_PROFILE_` for the rest, a convention +//! from the ICC Profiles in X specification that colord, xiccd and +//! `xcalib` all write to. The bytes are a whole ICC profile. +//! - **Wayland** has no such back door — a client cannot read another +//! client's or the compositor's state — and the protocol that replaces it, +//! `wp_color_manager_v1`, is still *staging* upstream. Where the compositor +//! advertises it we ask it; where it does not, there is no mechanism at all +//! and we say so. +//! +//! FR-DSP-8 anticipates exactly that gap and demands "a defined fallback where +//! Wayland provides no profile". The fallback is sRGB, and the requirement's +//! wording — *defined*, not *silent* — is why [`ProfileSource`] carries the +//! reason as data rather than logging it: the About page renders it, so a +//! photographer being shown sRGB because the compositor would not say +//! otherwise can find that out rather than wonder. +//! +//! # Four spaces, and no ICC engine +//! +//! The pipeline can encode into four spaces ([`ColourSpace`]). A real display +//! profile is a measurement of one panel and is none of them. Rather than +//! grow a general ICC transform engine — a much larger piece of work, with a +//! CMM, rendering intents, LUT-based profiles and a per-frame cost to argue +//! about — this reduces the profile to its primaries and picks the nearest of +//! the four, and then *says that it did*. See [`nearest_space`]. +//! +//! An approximation the photographer can see beats an exact answer they +//! cannot, and beats a silent approximation by a much larger margin. + +use dr_types::colour::Chromaticities; +use dr_types::ColourSpace; + +#[cfg(all(unix, not(target_os = "android")))] +mod icc; +#[cfg(all(unix, not(target_os = "android")))] +mod wayland; +#[cfg(all(unix, not(target_os = "android")))] +mod x11; + +#[cfg(all(unix, not(target_os = "android")))] +pub use icc::{read_profile, IccSummary}; + +/// Which display server the session is running on. +/// +/// Decided from the environment rather than by trying to connect, because the +/// answer decides *which* connection to attempt: `DISPLAY` is set inside a +/// Wayland session too (Xwayland sets it), so probing X11 first would put +/// every GNOME and KDE user on the X11 path and read an Xwayland root window +/// that no colour manager necessarily writes to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisplayServer { + Wayland, + X11, + /// Neither — a headless test runner, Android, or a build with no display + /// integration. Not an error: it is the case the fallback exists for. + None, +} + +impl DisplayServer { + /// What the session says it is. + pub fn detect() -> Self { + // `WAYLAND_DISPLAY` first and unconditionally. See the type comment: + // both variables are set in a Wayland session, and only this order + // gets such a session onto the path its compositor actually owns. + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + Self::Wayland + } else if std::env::var_os("DISPLAY").is_some() { + Self::X11 + } else { + Self::None + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Wayland => "Wayland", + Self::X11 => "X11", + Self::None => "no display server", + } + } +} + +/// Why a display ended up on the sRGB fallback. +/// +/// Carried as data because the About page renders it. FR-DSP-8 asks for a +/// *defined* fallback, and a fallback whose reason cannot be shown is +/// indistinguishable from a display that genuinely is sRGB. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FallbackReason { + /// Wayland, and the compositor does not advertise `wp_color_manager_v1`. + /// The common case today and the one FR-DSP-8's wording exists for. + NoWaylandProtocol, + /// The compositor or X server was asked and had nothing to say about this + /// display — no `_ICC_PROFILE` atom, or an image description that failed. + NoProfileForDisplay, + /// Something was found and could not be read. Distinguished from "nothing + /// was there" because it is a bug report rather than a configuration. + Unreadable(String), + /// The connection itself failed, or there is no display server. + NoConnection(String), +} + +impl FallbackReason { + /// A phrase for the About page, reading after "sRGB assumed:". + pub fn describe(&self) -> String { + match self { + Self::NoWaylandProtocol => { + "the compositor does not offer colour management".to_string() + } + Self::NoProfileForDisplay => "no profile is assigned to this display".to_string(), + Self::Unreadable(why) => format!("the profile could not be read ({why})"), + Self::NoConnection(why) => format!("no display server ({why})"), + } + } +} + +/// How a display's colour was established. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileSource { + /// An ICC profile read from an X11 root-window property, named here so a + /// bug report can quote which one. + X11RootProperty(String), + /// The compositor's own answer, over `wp_color_manager_v1`. + WaylandColourManagement, + /// FR-DSP-8's stated fallback. + FallbackSrgb(FallbackReason), +} + +impl ProfileSource { + /// Whether this is a profile the platform stated, rather than an assumption. + pub fn is_measured(&self) -> bool { + !matches!(self, Self::FallbackSrgb(_)) + } + + /// A short phrase naming the acquisition path, for the About page. + pub fn label(&self) -> String { + match self { + Self::X11RootProperty(atom) => format!("X11 {atom}"), + Self::WaylandColourManagement => "Wayland colour management".to_string(), + Self::FallbackSrgb(_) => "fallback".to_string(), + } + } +} + +/// The colour of one display, reduced to something the pipeline can encode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisplayProfile { + /// The space to compose the canvas into while this display is showing it. + pub space: ColourSpace, + /// Where `space` came from. + pub source: ProfileSource, + /// The profile's own name, where it carried one. A photographer who + /// calibrated this panel recognises the string their calibrator wrote, + /// which is the quickest way to confirm we are reading the right profile. + pub described_as: Option, + /// Whether `space` is the *nearest* of the four rather than a match. + /// + /// True for essentially every measured panel, and that is the honest + /// answer rather than a defect: a calibration profile describes one piece + /// of glass and no standard space describes it exactly. + pub approximated: bool, +} + +impl DisplayProfile { + /// The sRGB fallback FR-DSP-8 defines, carrying why it was reached. + pub fn fallback(reason: FallbackReason) -> Self { + Self { + space: ColourSpace::Srgb, + source: ProfileSource::FallbackSrgb(reason), + described_as: None, + // Not an approximation of anything. sRGB is being *assumed*, which + // is a different claim and reads differently on the About page. + approximated: false, + } + } + + /// One line for the About page: the space, then how we know. + /// + /// Written here rather than in the interface because the three cases — + /// measured and exact, measured and approximated, assumed — differ in + /// what they are claiming, and a format string assembled in Slint would + /// lose that distinction the first time someone edited it. + pub fn describe(&self) -> String { + let space = self.space.label(); + match &self.source { + ProfileSource::FallbackSrgb(reason) => { + format!("{space} assumed — {}", reason.describe()) + } + source if self.approximated => { + let named = self + .described_as + .as_deref() + .map_or_else(String::new, |d| format!("{d}, ")); + format!("nearest to {space} ({named}via {})", source.label()) + } + source => format!("{space} (via {})", source.label()), + } + } +} + +/// A display, as the platform describes it. +#[derive(Debug, Clone, PartialEq)] +pub struct DisplayInfo { + /// The connector or output name — "DP-1", "eDP-1" — where the server gave + /// one, otherwise an index. Shown in About so a two-monitor user can tell + /// which row is which. + pub name: String, + /// Where this display sits in the desktop's coordinate space, in logical + /// pixels, if the server said. `None` on Wayland, which deliberately does + /// not tell a client where anything is; see [`DisplaySurvey::containing`]. + pub bounds: Option, + pub profile: DisplayProfile, +} + +/// A display's rectangle in the desktop's logical coordinate space. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bounds { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +impl Bounds { + pub fn contains(&self, x: i32, y: i32) -> bool { + x >= self.x + && y >= self.y + && x < self.x.saturating_add_unsigned(self.width) + && y < self.y.saturating_add_unsigned(self.height) + } +} + +/// Every display the session has, and how each was asked. +#[derive(Debug, Clone, PartialEq)] +pub struct DisplaySurvey { + pub server: DisplayServer, + pub displays: Vec, +} + +impl DisplaySurvey { + /// Ask the platform. Never fails: a failure *is* the fallback. + /// + /// Cheap enough to repeat — an X11 property fetch or a Wayland roundtrip — + /// but not free, so callers hold the result and re-survey on a display + /// change rather than per frame. + pub fn probe() -> Self { + let server = DisplayServer::detect(); + #[cfg(all(unix, not(target_os = "android")))] + let displays = match server { + DisplayServer::Wayland => wayland::probe(), + DisplayServer::X11 => x11::probe(), + DisplayServer::None => Err(FallbackReason::NoConnection( + "neither WAYLAND_DISPLAY nor DISPLAY is set".to_string(), + )), + }; + // Android draws through the platform compositor and has its own + // wide-gamut path (FR-DSP-6); neither display server exists there, so + // the crates are not even compiled in. sRGB, stated as such. + #[cfg(not(all(unix, not(target_os = "android"))))] + let displays: Result, FallbackReason> = Err(FallbackReason::NoConnection( + "no display-server integration on this platform".to_string(), + )); + + let displays = match displays { + // A server that connected but listed nothing is the same situation + // as one that would not connect: there is no display to describe, + // and the canvas still has to be composed into *something*. + Ok(found) if !found.is_empty() => found, + Ok(_) => vec![Self::assumed(FallbackReason::NoProfileForDisplay)], + Err(reason) => vec![Self::assumed(reason)], + }; + Self { server, displays } + } + + fn assumed(reason: FallbackReason) -> DisplayInfo { + DisplayInfo { + name: "display".to_string(), + bounds: None, + profile: DisplayProfile::fallback(reason), + } + } + + /// A survey with no platform in it, for tests and for the headless case. + pub fn assumed_srgb(reason: FallbackReason) -> Self { + Self { + server: DisplayServer::None, + displays: vec![Self::assumed(reason)], + } + } + + /// Which display is showing a window whose top-left is at `(x, y)` in + /// logical desktop coordinates, as an index into [`Self::displays`]. + /// + /// **The point is the window's centre, and the caller computes it.** A + /// window straddling two monitors is showing more of itself on one of + /// them, and its centre is the cheapest statement of which. + /// + /// # Wayland returns 0, and that is the protocol's answer, not a bug + /// + /// A Wayland client is not told where its window is — there is no + /// equivalent of `XGetGeometry` against the root, deliberately, and + /// `xdg_toplevel` carries no position. The protocol's own answer to "which + /// output am I on" is `wl_surface.enter`, or better, + /// `wp_color_management_surface_feedback_v1`, which hands the client the + /// preferred image description for *its surface* and re-sends it when the + /// window moves. Both require the application's `wl_surface`, which Slint + /// owns and does not expose, so this falls back to the first display and + /// the About page shows every display's profile rather than one. + /// + /// The consequence is worth stating plainly: on a multi-monitor Wayland + /// desktop the canvas is composed for the *first* output. That is a + /// smaller error than composing for the wrong space entirely — the + /// profiles are read correctly, and a single-monitor session, which is + /// most of them, is exactly right — and the fix is a Slint surface handle + /// rather than anything in this module. + pub fn containing(&self, x: i32, y: i32) -> usize { + self.displays + .iter() + .position(|d| d.bounds.is_some_and(|b| b.contains(x, y))) + // Off every known rectangle: a window dragged past the edge of the + // desktop, or a server that gave no geometry. The first display is + // the primary one on both servers we speak to. + .unwrap_or(0) + } + + /// The profile for a display index, tolerating an index that has gone + /// stale because a monitor was unplugged between survey and render. + pub fn profile(&self, index: usize) -> &DisplayProfile { + self.displays + .get(index) + .or_else(|| self.displays.first()) + .map(|d| &d.profile) + .expect("probe always yields at least one display") + } +} + +/// The nearest of the four spaces the pipeline can encode, and whether it is +/// near enough to call a match. +/// +/// # Compared as colorants, not as chromaticity pairs +/// +/// The obvious comparison — eight xy numbers against eight xy numbers — is +/// wrong, and wrong in a way that would pass a casual test. A profile's +/// colorants have already been chromatically adapted to the D50 connection +/// space; a colour space's *published* chromaticities have not. Comparing the +/// two directly makes every D65 space look like it has the wrong white point +/// and pushes matches towards ProPhoto, the only D50 member of the four. +/// +/// So both sides go through [`Chromaticities::to_pcs_xyz`] and are compared as +/// the nine numbers a matrix/TRC profile would carry. That is the same +/// reduction `dr-export` writes into a file, which means a profile this +/// pipeline produced round-trips back to the space it was produced from — the +/// test at the bottom of this module. +/// +/// # Primaries and not the tone curve +/// +/// The four spaces are distinguished by gamut first: sRGB and Display P3 +/// share a transfer function entirely. A measured panel's TRC is a tabulated +/// curve fitted to a target gamma and matching it against three analytic +/// curves would decide nothing the primaries have not already decided. The +/// transfer function comes along with whichever space the primaries choose, +/// which is the right pairing — encoding P3 primaries through an Adobe RGB +/// gamma would be a space nobody has. +pub fn nearest_space(measured: &Chromaticities) -> (ColourSpace, bool) { + nearest_by_colorants(&measured.to_pcs_xyz()) +} + +/// [`nearest_space`], entered from the nine numbers directly. +/// +/// The two acquisition paths arrive with different halves of the same thing: +/// an ICC profile carries D50-adapted colorants and nothing else, while +/// Wayland's `primaries` event carries xy chromaticities and no matrix. Both +/// reduce to this, so there is one metric and not two that can drift apart. +pub fn nearest_by_colorants(want: &[f32; 9]) -> (ColourSpace, bool) { + let (space, distance) = ColourSpace::ALL + .into_iter() + .map(|candidate| { + let have = candidate.to_pcs_xyz(); + let d: f32 = want + .iter() + .zip(have.iter()) + .map(|(a, b)| (a - b) * (a - b)) + .sum(); + (candidate, d) + }) + // `f32` and no NaN: every input is a finite colorant matrix, so + // `total_cmp` here is a formality that avoids `partial_cmp().unwrap()`. + .min_by(|a, b| a.1.total_cmp(&b.1)) + .expect("ColourSpace::ALL is not empty"); + + // The threshold is a sum of nine squared differences over numbers of order + // one, so 1e-6 is roughly "every colorant agrees to three decimal places" + // — tight enough that a genuinely different gamut never passes, loose + // enough that a profile written from these same numbers and read back + // through s15Fixed16 rounding does. It is not a perceptual threshold and + // is not trying to be: this decides whether to *say* "approximated", and + // saying it when in doubt is the honest direction to err. + (space, distance < 1e-6) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chromaticities_of(space: ColourSpace) -> Chromaticities { + space.chromaticities() + } + + #[test] + fn each_known_space_recognises_itself_exactly() { + for space in ColourSpace::ALL { + let (found, exact) = nearest_space(&chromaticities_of(space)); + assert_eq!(found, space, "{} matched the wrong space", space.label()); + assert!(exact, "{} was not recognised as itself", space.label()); + } + } + + #[test] + fn a_panel_between_two_gamuts_is_reported_as_an_approximation() { + // A plausible measured wide-gamut panel: near P3's red and green, not + // on them. The requirement is not that it picks P3 — it is that it + // picks *something* and admits it is not exact. + let measured = Chromaticities { + red: [0.6790, 0.3190], + green: [0.2680, 0.6880], + blue: [0.1490, 0.0570], + white: [0.3127, 0.3290], + }; + let (found, exact) = nearest_space(&measured); + assert_eq!(found, ColourSpace::DisplayP3); + assert!(!exact, "a measured panel must not claim to be a standard"); + } + + #[test] + fn a_d50_wide_gamut_panel_does_not_collapse_onto_srgb() { + // The failure this guards: comparing raw xy pairs rather than adapted + // colorants makes white-point differences dominate, and everything + // lands on whichever space shares its illuminant. ProPhoto's gamut is + // unmistakable, so if the metric is right this cannot be anything else. + let (found, _) = nearest_space(&chromaticities_of(ColourSpace::ProPhoto)); + assert_eq!(found, ColourSpace::ProPhoto); + } + + #[test] + fn adobe_rgb_and_srgb_are_told_apart_despite_sharing_two_primaries() { + // Adobe RGB (1998) differs from sRGB in the green corner alone. A + // metric summing over all three colorants must still separate them, + // or a wide-gamut photo monitor is driven as sRGB. + let (found, exact) = nearest_space(&chromaticities_of(ColourSpace::AdobeRgb)); + assert_eq!(found, ColourSpace::AdobeRgb); + assert!(exact); + } + + #[test] + fn the_fallback_says_srgb_and_says_why() { + let profile = DisplayProfile::fallback(FallbackReason::NoWaylandProtocol); + assert_eq!(profile.space, ColourSpace::Srgb); + assert!(!profile.source.is_measured()); + let described = profile.describe(); + assert!(described.contains("sRGB"), "{described}"); + assert!(described.contains("assumed"), "{described}"); + // The specific reason has to survive into the string the About page + // renders, or the fallback is defined in the code and invisible on + // screen — which is the half of FR-DSP-8 that is easy to skip. + assert!(described.contains("colour management"), "{described}"); + } + + #[test] + fn an_approximated_profile_says_nearest_rather_than_claiming_the_space() { + let profile = DisplayProfile { + space: ColourSpace::DisplayP3, + source: ProfileSource::X11RootProperty("_ICC_PROFILE".to_string()), + described_as: Some("Studio Display calibrated".to_string()), + approximated: true, + }; + let described = profile.describe(); + assert!(described.contains("nearest to Display P3"), "{described}"); + assert!( + described.contains("Studio Display calibrated"), + "{described}" + ); + } + + #[test] + fn probing_a_platform_that_cannot_answer_still_yields_a_display() { + // Whatever happens, composition needs a space. The one thing this + // module may never do is hand back nothing. + let survey = DisplaySurvey::probe(); + assert!(!survey.displays.is_empty()); + assert_eq!(survey.profile(99).space, survey.displays[0].profile.space); + } + + #[test] + fn a_window_is_located_on_the_display_its_centre_falls_in() { + let survey = DisplaySurvey { + server: DisplayServer::X11, + displays: vec![ + DisplayInfo { + name: "DP-1".to_string(), + bounds: Some(Bounds { + x: 0, + y: 0, + width: 2560, + height: 1440, + }), + profile: DisplayProfile::fallback(FallbackReason::NoProfileForDisplay), + }, + DisplayInfo { + name: "DP-2".to_string(), + bounds: Some(Bounds { + x: 2560, + y: 0, + width: 1920, + height: 1080, + }), + profile: DisplayProfile { + space: ColourSpace::AdobeRgb, + source: ProfileSource::X11RootProperty("_ICC_PROFILE_1".to_string()), + described_as: None, + approximated: true, + }, + }, + ], + }; + + assert_eq!(survey.containing(100, 100), 0); + assert_eq!(survey.containing(3000, 500), 1); + // The seam: the first pixel of the second monitor belongs to it, and + // the last pixel of the first does not. An inclusive upper bound here + // would flip the transform one pixel early on every drag across. + assert_eq!(survey.containing(2559, 0), 0); + assert_eq!(survey.containing(2560, 0), 1); + // Dragged off the desktop entirely — the primary, not a panic. + assert_eq!(survey.containing(-4000, 0), 0); + + // And the point of the whole exercise: the two displays yield + // different output spaces. + assert_eq!(survey.profile(0).space, ColourSpace::Srgb); + assert_eq!(survey.profile(1).space, ColourSpace::AdobeRgb); + } +} diff --git a/platform/dr-plat/src/display/icc.rs b/platform/dr-plat/src/display/icc.rs new file mode 100644 index 0000000..2a00ea4 --- /dev/null +++ b/platform/dr-plat/src/display/icc.rs @@ -0,0 +1,372 @@ +//! TRACES: FR-DSP-8 +//! Reading just enough of an ICC profile to say which of four spaces it is. +//! +//! # Deliberately not a profile parser +//! +//! ICC.1 is a large specification, and a complete reader of it is the front +//! half of a colour management module: LUT-based transforms, rendering +//! intents, per-intent tag sets, v2 and v4 divergence. None of that helps +//! here, because the pipeline does not *apply* a profile — it encodes into one +//! of four spaces it already knows the numbers for (`dr_types::colour`). The +//! only question a display profile has to answer is which of the four it is +//! nearest to, and three tags answer it. +//! +//! So this reads the tag table, pulls the three colorants and the description, +//! and refuses everything else with a reason the About page can show. A +//! profile it refuses is not a failure of the photographer's setup and must +//! not read like one; it means "this panel is described in a way we do not +//! approximate", and sRGB is assumed as FR-DSP-8 defines. +//! +//! # What it does read +//! +//! `rXYZ`, `gXYZ`, `bXYZ` — the three colorant tags of a matrix/TRC profile, +//! which are the space's primaries already adapted to the D50 connection +//! space. That is the same reduction `ColourSpace::to_pcs_xyz` produces, which +//! is what makes the comparison in [`super::nearest_by_colorants`] an +//! apples-to-apples one, and what makes a profile this project *wrote* +//! round-trip back to the space it was written from. +//! +//! `desc` (v2) or `mluc` (v4) for the profile's own name, which exists only to +//! be shown: a photographer recognises the string their calibrator wrote and +//! can tell at a glance whether we are reading the profile they think we are. + +/// What a display profile was reduced to. +#[derive(Debug, Clone, PartialEq)] +pub struct IccSummary { + /// The three colorant columns, row-major, in the D50 connection space. + pub colorants: [f32; 9], + /// The profile's own description, where it carried a readable one. + pub description: Option, +} + +/// The ICC header's fixed size. Everything before the tag count. +const HEADER: usize = 128; + +/// Reduce a profile to the parts that decide an output space. +/// +/// `Err` carries a phrase for [`super::FallbackReason::Unreadable`], so it is +/// written to read after "the profile could not be read (…)" on the About page +/// rather than as a developer's error string. +pub fn read_profile(bytes: &[u8]) -> Result { + if bytes.len() < HEADER + 4 { + return Err("it is too short to be a profile".to_string()); + } + // Offset 36 is `acsp`, the profile file signature. Checked because the X11 + // property is a byte array with no type discipline at all: anything can + // write anything to a root window, and a profile-shaped blob that is not + // one would otherwise be read as colorants of arbitrary magnitude. + if &bytes[36..40] != b"acsp" { + return Err("it does not carry the ICC file signature".to_string()); + } + // The header's own length field. A profile whose declared size exceeds the + // property is truncated — X11 properties have a fetch length, and reading + // colorants out of a half-transferred profile would be silently wrong. + let declared = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; + if declared > bytes.len() { + return Err(format!( + "it declares {declared} bytes and only {} arrived", + bytes.len() + )); + } + + let count = be_u32(bytes, HEADER)? as usize; + // 12 bytes per tag table entry. The bound is not paranoia: `count` comes + // from the profile, and a corrupt one multiplied out is how a reader ends + // up indexing megabytes past the end. + let table_end = HEADER + 4 + count.checked_mul(12).ok_or("its tag table is absurd")?; + if table_end > bytes.len() { + return Err("its tag table runs past the end of the profile".to_string()); + } + + let mut tags: Vec<([u8; 4], usize, usize)> = Vec::with_capacity(count); + for i in 0..count { + let at = HEADER + 4 + i * 12; + let sig = [bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]; + let offset = be_u32(bytes, at + 4)? as usize; + let size = be_u32(bytes, at + 8)? as usize; + // Silently skipping a tag that does not fit rather than rejecting the + // whole profile: the three we want may all be well-formed while some + // fourth tag we will never look at is not. + if offset + .checked_add(size) + .is_some_and(|end| end <= bytes.len()) + { + tags.push((sig, offset, size)); + } + } + + let find = |want: &[u8; 4]| { + tags.iter() + .find(|(sig, _, _)| sig == want) + .map(|&(_, offset, size)| &bytes[offset..offset + size]) + }; + + let (Some(r), Some(g), Some(b)) = (find(b"rXYZ"), find(b"gXYZ"), find(b"bXYZ")) else { + // The honest description of a LUT-based profile, which is what a + // hardware calibrator often produces. It describes the panel more + // accurately than a matrix could, and approximating it would need the + // CMM this module exists to avoid. Named as a shape rather than as an + // error, because nothing is wrong with such a profile. + return Err("it is not a matrix/TRC profile".to_string()); + }; + let r = xyz_tag(r)?; + let g = xyz_tag(g)?; + let b = xyz_tag(b)?; + + Ok(IccSummary { + // Row-major, columns R/G/B — the layout `to_pcs_xyz` produces, so the + // two can be subtracted entry by entry. Transposing one of them is the + // single most plausible bug in this file, which is why the round-trip + // test asserts a written-then-read profile lands back on its own space + // rather than merely on *some* space. + colorants: [r[0], g[0], b[0], r[1], g[1], b[1], r[2], g[2], b[2]], + description: find(b"desc").and_then(text_tag), + }) +} + +/// An `XYZType` tag: signature, four reserved bytes, then s15Fixed16 triples. +/// +/// Only the first triple is read. A colorant tag carries exactly one; the +/// array form exists for other tags that share the type. +fn xyz_tag(data: &[u8]) -> Result<[f32; 3], String> { + if data.len() < 20 || &data[0..4] != b"XYZ " { + return Err("a colorant tag is not an XYZ value".to_string()); + } + Ok([ + s15_fixed16(be_i32(data, 8)?), + s15_fixed16(be_i32(data, 12)?), + s15_fixed16(be_i32(data, 16)?), + ]) +} + +/// A profile's description, from either of the two types that carry one. +/// +/// Returns `None` rather than an error throughout: the name is decoration. A +/// profile with unreadable text still has perfectly good colorants, and +/// refusing it over a string would put a correctly-described display on the +/// fallback for a cosmetic reason. +fn text_tag(data: &[u8]) -> Option { + match data.get(0..4)? { + // ICC v2 `textDescriptionType`: signature, reserved, an ASCII byte + // count, then that many bytes with a trailing NUL included in the + // count. + b"desc" => { + let count = be_u32(data, 8).ok()? as usize; + let text = data.get(12..12 + count)?; + let text = text.split(|&b| b == 0).next().unwrap_or(text); + Some(String::from_utf8_lossy(text).trim().to_string()).filter(|s| !s.is_empty()) + } + // ICC v4 `multiLocalizedUnicodeType`: UTF-16BE records. The first + // record is taken rather than the one matching the user's locale — a + // profile name is an identifier here, shown so it can be recognised, + // and picking a locale would be answering a question nobody asked. + b"mluc" => { + let records = be_u32(data, 8).ok()? as usize; + let size = be_u32(data, 12).ok()? as usize; + if records == 0 || size < 12 { + return None; + } + let length = be_u32(data, 16 + 4).ok()? as usize; + let offset = be_u32(data, 16 + 8).ok()? as usize; + let raw = data.get(offset..offset + length)?; + let units: Vec = raw + .chunks_exact(2) + .map(|p| u16::from_be_bytes([p[0], p[1]])) + .collect(); + Some(String::from_utf16_lossy(&units).trim().to_string()).filter(|s| !s.is_empty()) + } + _ => None, + } +} + +fn be_u32(bytes: &[u8], at: usize) -> Result { + bytes + .get(at..at + 4) + .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + .ok_or_else(|| "it ends in the middle of a field".to_string()) +} + +fn be_i32(bytes: &[u8], at: usize) -> Result { + be_u32(bytes, at).map(|v| v as i32) +} + +/// ICC's fixed-point number: sixteen integer bits, sixteen fractional. +fn s15_fixed16(v: i32) -> f32 { + v as f32 / 65536.0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::display::{nearest_by_colorants, nearest_space}; + use dr_types::ColourSpace; + + /// A minimal matrix/TRC profile carrying one space's colorants. + /// + /// Assembled here rather than taken from `dr-export`, deliberately. That + /// crate writes profiles from the same `to_pcs_xyz` this reads back, so a + /// round-trip through it would confirm the two halves of *one* set of + /// assumptions agree with each other and would still pass if both were + /// transposed. Laying the bytes out by hand against ICC.1's field offsets + /// is the independent statement. + fn profile_for(space: ColourSpace, name: &str) -> Vec { + let m = space.to_pcs_xyz(); + let colorant = |col: usize| { + let mut tag = b"XYZ \0\0\0\0".to_vec(); + for row in 0..3 { + let v = (m[row * 3 + col] * 65536.0).round() as i32; + tag.extend_from_slice(&v.to_be_bytes()); + } + tag + }; + let mut desc = b"desc\0\0\0\0".to_vec(); + let text = format!("{name}\0"); + desc.extend_from_slice(&(text.len() as u32).to_be_bytes()); + desc.extend_from_slice(text.as_bytes()); + + let tags: Vec<(&[u8; 4], Vec)> = vec![ + (b"rXYZ", colorant(0)), + (b"gXYZ", colorant(1)), + (b"bXYZ", colorant(2)), + (b"desc", desc), + ]; + + let mut header = vec![0u8; HEADER]; + header[36..40].copy_from_slice(b"acsp"); + let mut table = (tags.len() as u32).to_be_bytes().to_vec(); + let mut body = Vec::new(); + let base = HEADER + 4 + tags.len() * 12; + for (sig, data) in &tags { + table.extend_from_slice(*sig); + table.extend_from_slice(&((base + body.len()) as u32).to_be_bytes()); + table.extend_from_slice(&(data.len() as u32).to_be_bytes()); + body.extend_from_slice(data); + } + let mut out = header; + out.extend_from_slice(&table); + out.extend_from_slice(&body); + let len = out.len() as u32; + out[0..4].copy_from_slice(&len.to_be_bytes()); + out + } + + #[test] + fn a_profile_is_recognised_as_the_space_it_describes() { + // The whole acquisition path in one assertion: bytes that a colour + // manager would publish for a P3 panel must reach the pipeline as + // Display P3, and not as sRGB. + for space in ColourSpace::ALL { + let bytes = profile_for(space, space.label()); + let summary = read_profile(&bytes).expect("a well-formed profile"); + let (found, exact) = nearest_by_colorants(&summary.colorants); + assert_eq!(found, space, "{} was read as {found:?}", space.label()); + assert!( + exact, + "{} did not survive s15Fixed16 rounding", + space.label() + ); + } + } + + #[test] + fn the_colorant_matrix_is_not_transposed() { + // Reading the three tags into rows rather than columns produces a + // matrix that is still plausible, still finite, and describes a + // different gamut. Adobe RGB is the case that catches it: it differs + // from sRGB in one primary, so a transposition moves it onto a space + // that is genuinely close and the nearest-match would hide it. + let bytes = profile_for(ColourSpace::AdobeRgb, "Adobe RGB"); + let summary = read_profile(&bytes).expect("a well-formed profile"); + let want = ColourSpace::AdobeRgb.to_pcs_xyz(); + + // Not `assert_eq`: the values went out through s15Fixed16 and came + // back, so they agree to about 1e-5 and never exactly. The tolerance + // is three orders of magnitude tighter than the smallest off-diagonal + // difference below, so it still catches the thing it is here for. + for (have, want) in summary.colorants.iter().zip(want.iter()) { + assert!((have - want).abs() < 1e-4, "{:?}", summary.colorants); + } + + // And the guard that makes the assertion above mean something: a + // symmetric matrix would satisfy it transposed as well. + assert!( + (want[1] - want[3]).abs() > 1e-2, + "the colorant matrix must not be symmetric or this proves nothing" + ); + } + + #[test] + fn a_profile_names_itself_where_it_can() { + let bytes = profile_for(ColourSpace::Srgb, "EIZO CG279X calibrated 2024-03"); + let summary = read_profile(&bytes).expect("a well-formed profile"); + assert_eq!( + summary.description.as_deref(), + Some("EIZO CG279X calibrated 2024-03") + ); + } + + #[test] + fn a_v4_profile_names_itself_from_its_unicode_records() { + let mut mluc = b"mluc\0\0\0\0".to_vec(); + let text: Vec = "LG UltraFine" + .encode_utf16() + .flat_map(u16::to_be_bytes) + .collect(); + mluc.extend_from_slice(&1u32.to_be_bytes()); // one record + mluc.extend_from_slice(&12u32.to_be_bytes()); // record size + mluc.extend_from_slice(b"enUS"); + mluc.extend_from_slice(&(text.len() as u32).to_be_bytes()); + mluc.extend_from_slice(&28u32.to_be_bytes()); // offset within the tag + mluc.extend_from_slice(&text); + assert_eq!(text_tag(&mluc).as_deref(), Some("LG UltraFine")); + } + + #[test] + fn a_lut_profile_is_refused_by_shape_and_not_by_crashing() { + // A hardware calibrator's `mAB `-based profile. There is nothing wrong + // with it; we simply cannot approximate it without the CMM this module + // exists to avoid, and the About page has to be able to say so. + let mut header = vec![0u8; HEADER]; + header[36..40].copy_from_slice(b"acsp"); + header.extend_from_slice(&0u32.to_be_bytes()); + let len = header.len() as u32; + header[0..4].copy_from_slice(&len.to_be_bytes()); + let err = read_profile(&header).expect_err("no colorants to read"); + assert!(err.contains("matrix/TRC"), "{err}"); + } + + #[test] + fn rubbish_on_a_root_window_is_refused_rather_than_read_as_colour() { + // An X11 property is untyped and world-writable by convention. None of + // these may panic, and none may produce a colour space. + assert!(read_profile(&[]).is_err()); + assert!(read_profile(&[0u8; 200]).is_err()); + + let mut truncated = profile_for(ColourSpace::Srgb, "sRGB"); + truncated.truncate(truncated.len() / 2); + assert!(read_profile(&truncated).is_err()); + + // A profile whose tag table claims far more tags than there are bytes. + let mut absurd = profile_for(ColourSpace::Srgb, "sRGB"); + absurd[HEADER..HEADER + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(read_profile(&absurd).is_err()); + } + + #[test] + fn the_two_entry_points_agree() { + // `nearest_space` (chromaticities, from Wayland) and + // `nearest_by_colorants` (a matrix, from ICC) must not drift apart: + // they are the same decision reached from the two halves of the same + // definition, and a session that answered differently depending on + // which display server it was on would be the worst kind of bug to + // reproduce. + for space in ColourSpace::ALL { + let bytes = profile_for(space, "x"); + let summary = read_profile(&bytes).expect("a well-formed profile"); + assert_eq!( + nearest_by_colorants(&summary.colorants).0, + nearest_space(&space.chromaticities()).0 + ); + } + } +} diff --git a/platform/dr-plat/src/display/wayland.rs b/platform/dr-plat/src/display/wayland.rs new file mode 100644 index 0000000..eed1484 --- /dev/null +++ b/platform/dr-plat/src/display/wayland.rs @@ -0,0 +1,381 @@ +//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2 +//! Acquisition on Wayland: ask the compositor, or admit that we cannot. +//! +//! # Why there is no back door here +//! +//! X11's mechanism works because any client may read any root-window +//! property. Wayland has no such shared state by design — a client sees its +//! own surfaces and nothing else — so the profile has to be *given* to us, and +//! the protocol that gives it, `wp_color_manager_v1`, is still staging +//! upstream. That is the concrete form of FR-DSP-8's "a defined fallback where +//! Wayland provides no profile": on a compositor that does not advertise the +//! global there is no mechanism to fall back *from*, and pretending otherwise +//! would mean reading the Xwayland root window and presenting a value the +//! compositor never agreed to. +//! +//! So: bind the global if it is there, ask each `wl_output` for its image +//! description, and where it is not there return +//! [`FallbackReason::NoWaylandProtocol`], which the About page renders in +//! words. +//! +//! # Two forms of the same answer +//! +//! The compositor may describe an output either by handing over an ICC profile +//! on a file descriptor or by stating primaries as CIE xy chromaticities. +//! Both are accepted, and both reduce to the same nearest-space decision — see +//! `super::nearest_by_colorants` for why that reduction has to happen in one +//! place. The ICC form is preferred where both arrive, because it carries the +//! profile's own name, which is the string a photographer recognises. +//! +//! # Position is deliberately not asked for +//! +//! [`super::DisplaySurvey::containing`] explains the consequence at length: +//! `bounds` is `None` on every Wayland output, not because `wl_output` refuses +//! to state its position but because a client is never told where its *own* +//! window is, so a rectangle to test against would have no point to test. + +use std::io::{Read, Seek, SeekFrom}; + +use wayland_client::protocol::{wl_output, wl_registry}; +use wayland_client::{Connection, Dispatch, Proxy, QueueHandle}; +use wayland_protocols::wp::color_management::v1::client::{ + wp_color_management_output_v1, wp_color_manager_v1, wp_image_description_info_v1, + wp_image_description_v1, +}; + +use dr_types::colour::Chromaticities; + +use super::{ + nearest_by_colorants, nearest_space, DisplayInfo, DisplayProfile, FallbackReason, ProfileSource, +}; + +/// The highest protocol version this client knows how to receive. +/// +/// Bound as high as the compositor offers rather than at 1, because +/// `get_image_description` fails with `low_version` when the client cannot +/// receive every event the output's description needs — so asking for less +/// than we understand turns a describable display into a fallback. +const MAX_MANAGER_VERSION: u32 = 3; + +/// `wl_output.name` arrived in version 4. Below it an output has no stable +/// identifier and is listed by index, which is cosmetic only. +const MAX_OUTPUT_VERSION: u32 = 4; + +/// What one output told us, accumulated across the events that describe it. +#[derive(Default)] +struct Pending { + name: Option, + description: Option, + /// The ICC profile the compositor handed over, if it did. + icc: Option>, + /// Primaries as CIE xy, if it stated them instead. + primaries: Option, + /// Set when the image description could not be produced at all. + failed: Option, + /// Whether anything at all came back for this output. + answered: bool, +} + +struct State { + manager: Option, + outputs: Vec, + pending: Vec, +} + +/// Every output the compositor has, with whatever colour it will state. +pub fn probe() -> Result, FallbackReason> { + let conn = Connection::connect_to_env() + .map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}")))?; + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + conn.display().get_registry(&qh, ()); + + let mut state = State { + manager: None, + outputs: Vec::new(), + pending: Vec::new(), + }; + + // First roundtrip: the registry's globals, which is where both the manager + // and the outputs are bound. + roundtrip(&mut queue, &mut state)?; + // Second: the events those newly bound objects emit — `wl_output.name`, + // and the manager's capability advertisements. A single roundtrip cannot + // carry them, because the objects did not exist when the first request + // went out. + roundtrip(&mut queue, &mut state)?; + + let Some(manager) = state.manager.clone() else { + // The case FR-DSP-8's fallback clause is written for, and today the + // common one. Reported as its own reason rather than folded into + // "no profile", because the two lead a user somewhere different: this + // one is the compositor's feature set, not their calibration. + return Err(FallbackReason::NoWaylandProtocol); + }; + if state.outputs.is_empty() { + return Err(FallbackReason::NoConnection( + "Wayland: the compositor listed no outputs".to_string(), + )); + } + + // Ask every output at once and let one roundtrip settle them all. Asking + // serially would be a roundtrip per monitor on a path that runs at startup + // and on every display change. + for (index, output) in state.outputs.clone().iter().enumerate() { + let cm_output = manager.get_output(output, &qh, index); + cm_output.get_image_description(&qh, index); + } + roundtrip(&mut queue, &mut state)?; + // `get_information` is only legal once the description is ready, so it + // cannot be batched with the request above; this is the second and last + // roundtrip of the exchange. + roundtrip(&mut queue, &mut state)?; + + Ok(state + .pending + .iter() + .enumerate() + .map(|(index, pending)| DisplayInfo { + name: pending + .name + .clone() + .unwrap_or_else(|| format!("display {}", index + 1)), + // See the module comment: a rectangle with no point to test. + bounds: None, + profile: resolve(pending), + }) + .collect()) +} + +/// Turn one output's accumulated events into an output space. +fn resolve(pending: &Pending) -> DisplayProfile { + if let Some(why) = &pending.failed { + return DisplayProfile::fallback(FallbackReason::Unreadable(format!("Wayland: {why}"))); + } + + // The ICC form first: it is the only one that carries a name, and a named + // profile is what lets a photographer confirm we are reading theirs. + if let Some(bytes) = &pending.icc { + return match super::icc::read_profile(bytes) { + Ok(summary) => { + let (space, exact) = nearest_by_colorants(&summary.colorants); + DisplayProfile { + space, + source: ProfileSource::WaylandColourManagement, + described_as: summary.description.or_else(|| pending.description.clone()), + approximated: !exact, + } + } + Err(why) => DisplayProfile::fallback(FallbackReason::Unreadable(why)), + }; + } + + if let Some(primaries) = pending.primaries { + let (space, exact) = nearest_space(&primaries); + return DisplayProfile { + space, + source: ProfileSource::WaylandColourManagement, + described_as: pending.description.clone(), + approximated: !exact, + }; + } + + // The protocol was there and the output had nothing to say through it. + DisplayProfile::fallback(if pending.answered { + FallbackReason::NoProfileForDisplay + } else { + FallbackReason::Unreadable("Wayland: the compositor did not answer".to_string()) + }) +} + +fn roundtrip( + queue: &mut wayland_client::EventQueue, + state: &mut State, +) -> Result<(), FallbackReason> { + queue + .roundtrip(state) + .map(|_| ()) + .map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}"))) +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + // `GlobalRemove` is a monitor being unplugged mid-probe. Ignored + // here: the survey is re-run on a display change anyway, and + // reacting to it inside a two-roundtrip exchange would only make + // the indices disagree with the vector. + return; + }; + if interface == wp_color_manager_v1::WpColorManagerV1::interface().name { + state.manager = Some(registry.bind(name, version.min(MAX_MANAGER_VERSION), qh, ())); + } else if interface == wl_output::WlOutput::interface().name { + let index = state.outputs.len(); + state.pending.push(Pending::default()); + state + .outputs + .push(registry.bind(name, version.min(MAX_OUTPUT_VERSION), qh, index)); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wl_output::WlOutput, + event: wl_output::Event, + index: &usize, + _: &Connection, + _: &QueueHandle, + ) { + let Some(pending) = state.pending.get_mut(*index) else { + return; + }; + match event { + // The connector name — "DP-1", "eDP-1" — which is what a user with + // two monitors recognises in a list. + wl_output::Event::Name { name } => pending.name = Some(name), + wl_output::Event::Description { description } => { + pending.description = Some(description) + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + _: &mut Self, + _: &wp_color_manager_v1::WpColorManagerV1, + _: wp_color_manager_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + // The manager advertises which intents, features, transfer functions + // and named primaries it supports. All of that constrains what a + // client may *create*; this one only reads what the outputs already + // are, so none of it applies. + } +} + +impl Dispatch for State { + fn event( + _: &mut Self, + _: &wp_color_management_output_v1::WpColorManagementOutputV1, + _: wp_color_management_output_v1::Event, + _: &usize, + _: &Connection, + _: &QueueHandle, + ) { + // `image_description_changed` says the output's colour has changed — + // a user switching their monitor's picture mode, or assigning a new + // profile. Not acted on here: this object lives for the length of one + // probe and is dropped with it. The application re-surveys instead, + // which also covers the X11 side, where there is no such event. + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + description: &wp_image_description_v1::WpImageDescriptionV1, + event: wp_image_description_v1::Event, + index: &usize, + _: &Connection, + qh: &QueueHandle, + ) { + let Some(pending) = state.pending.get_mut(*index) else { + return; + }; + match event { + // `ready` at protocol version 1, `ready2` from version 2. Both + // mean the same thing and both are handled, because the version + // bound depends on the compositor. + wp_image_description_v1::Event::Ready { .. } + | wp_image_description_v1::Event::Ready2 { .. } => { + pending.answered = true; + description.get_information(qh, *index); + } + wp_image_description_v1::Event::Failed { cause, msg } => { + pending.answered = true; + pending.failed = Some(format!("{cause:?}: {msg}")); + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wp_image_description_info_v1::WpImageDescriptionInfoV1, + event: wp_image_description_info_v1::Event, + index: &usize, + _: &Connection, + _: &QueueHandle, + ) { + let Some(pending) = state.pending.get_mut(*index) else { + return; + }; + match event { + wp_image_description_info_v1::Event::IccFile { icc, icc_size } => { + match read_fd(icc, icc_size as usize) { + Ok(bytes) => pending.icc = Some(bytes), + Err(e) => log::warn!("reading the compositor's ICC profile: {e}"), + } + } + wp_image_description_info_v1::Event::Primaries { + r_x, + r_y, + g_x, + g_y, + b_x, + b_y, + w_x, + w_y, + } => { + // The protocol carries six decimals by multiplying by a + // million; undoing it here rather than anywhere downstream + // keeps `Chromaticities` meaning one thing everywhere. + let xy = |x: i32, y: i32| [f64::from(x) / 1e6, f64::from(y) / 1e6]; + pending.primaries = Some(Chromaticities { + red: xy(r_x, r_y), + green: xy(g_x, g_y), + blue: xy(b_x, b_y), + white: xy(w_x, w_y), + }); + } + _ => {} + } + } +} + +/// Read a profile out of the file descriptor the compositor passed. +/// +/// Seeks to the start first. The descriptor is a dup of the compositor's own, +/// so it shares a file offset with it; assuming zero is the kind of assumption +/// that works on every compositor until it does not. +fn read_fd(fd: std::os::fd::OwnedFd, size: usize) -> std::io::Result> { + let mut file = std::fs::File::from(fd); + file.seek(SeekFrom::Start(0))?; + let mut bytes = Vec::with_capacity(size.min(1 << 20)); + // Bounded by what the compositor declared, and separately by a megabyte: + // a display profile is a few kilobytes, and the size is a number from + // another process. + file.take(size.min(1 << 20) as u64) + .read_to_end(&mut bytes)?; + Ok(bytes) +} diff --git a/platform/dr-plat/src/display/x11.rs b/platform/dr-plat/src/display/x11.rs new file mode 100644 index 0000000..e00c777 --- /dev/null +++ b/platform/dr-plat/src/display/x11.rs @@ -0,0 +1,161 @@ +//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2 +//! Acquisition on X11: ICC profiles as root-window properties. +//! +//! The mechanism is the ICC Profiles in X specification, and it predates +//! everything else in this module by two decades. A colour manager — colord +//! under GNOME, `xiccd`, `xcalib`, or the desktop's own — loads the profile +//! for each output and publishes its bytes on the *root window* as a property: +//! `_ICC_PROFILE` for the first output, `_ICC_PROFILE_1`, `_ICC_PROFILE_2` and +//! so on for the rest, numbered by the output's position in RandR's list. +//! +//! Any client can read them, which is exactly why this works and exactly why +//! [`super::icc::read_profile`] validates so carefully: a root-window property +//! is untyped shared state that anything on the display may write. +//! +//! # Geometry comes from RandR, and it is what makes a move detectable +//! +//! X11 will also say where each output sits in the desktop's coordinate space, +//! and will tell a client where its own window is. Those two facts together +//! are the whole of FR-DSP-8's "updates when the window moves between +//! displays" on this display server — the window's centre falls in one +//! rectangle or another, and the answer changes as it is dragged. Wayland +//! gives neither, which is why `wayland.rs` says what it says. + +use x11rb::connection::Connection; +use x11rb::protocol::randr::ConnectionExt as RandrExt; +use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as XExt}; + +use super::{ + nearest_by_colorants, Bounds, DisplayInfo, DisplayProfile, FallbackReason, ProfileSource, +}; + +/// Every output the X server has, with whatever profile is published for it. +pub fn probe() -> Result, FallbackReason> { + let (conn, screen_num) = + x11rb::connect(None).map_err(|e| FallbackReason::NoConnection(format!("X11: {e}")))?; + let root = conn + .setup() + .roots + .get(screen_num) + .ok_or_else(|| FallbackReason::NoConnection("X11: no screen".to_string()))? + .root; + + // RandR describes the outputs. Its absence is survivable: a server without + // it still has a root window with `_ICC_PROFILE` on it, so fall through to + // a single unnamed display carrying the first property rather than giving + // up on colour management entirely. + let outputs = enumerate(&conn, root).unwrap_or_default(); + let outputs = if outputs.is_empty() { + vec![(String::from("display"), None)] + } else { + outputs + }; + + Ok(outputs + .into_iter() + .enumerate() + .map(|(index, (name, bounds))| DisplayInfo { + name, + bounds, + profile: profile_for(&conn, root, index), + }) + .collect()) +} + +/// An output's connector name and the rectangle it occupies, where RandR +/// would say. Named so that the order of the two is fixed in one place. +type Output = (String, Option); + +/// The connected outputs, in RandR order, with their desktop rectangles. +/// +/// Order is load-bearing rather than cosmetic: it is what numbers the +/// `_ICC_PROFILE_` properties, so reordering this list would hand every +/// monitor its neighbour's profile. +fn enumerate(conn: &impl Connection, root: u32) -> Result, Box> { + let resources = conn.randr_get_screen_resources_current(root)?.reply()?; + let stamp = resources.config_timestamp; + let mut found = Vec::new(); + for output in resources.outputs { + let Ok(info) = conn.randr_get_output_info(output, stamp)?.reply() else { + continue; + }; + // `crtc == 0` is an output with no CRTC driving it: a port with + // nothing plugged in, or a disabled monitor. It occupies no part of + // the desktop and takes no place in the `_ICC_PROFILE_` numbering. + if info.crtc == 0 { + continue; + } + let name = String::from_utf8_lossy(&info.name).to_string(); + let bounds = conn + .randr_get_crtc_info(info.crtc, stamp) + .ok() + .and_then(|c| c.reply().ok()) + .map(|c| Bounds { + x: i32::from(c.x), + y: i32::from(c.y), + width: u32::from(c.width), + height: u32::from(c.height), + }); + found.push((name, bounds)); + } + Ok(found) +} + +/// The property this output's profile would be published on, read and reduced. +fn profile_for(conn: &impl Connection, root: u32, index: usize) -> DisplayProfile { + // The specification's numbering: the first output's property carries no + // suffix. Writing `_ICC_PROFILE_0` instead reads nothing on every desktop. + let name = if index == 0 { + "_ICC_PROFILE".to_string() + } else { + format!("_ICC_PROFILE_{index}") + }; + + let bytes = match fetch(conn, root, &name) { + Ok(Some(bytes)) => bytes, + // No property. The overwhelmingly common case on a stock desktop with + // no calibration installed, and not a fault of any kind. + Ok(None) => return DisplayProfile::fallback(FallbackReason::NoProfileForDisplay), + Err(e) => return DisplayProfile::fallback(FallbackReason::Unreadable(format!("X11: {e}"))), + }; + + match super::icc::read_profile(&bytes) { + Ok(summary) => { + let (space, exact) = nearest_by_colorants(&summary.colorants); + DisplayProfile { + space, + source: ProfileSource::X11RootProperty(name), + described_as: summary.description, + approximated: !exact, + } + } + Err(why) => DisplayProfile::fallback(FallbackReason::Unreadable(why)), + } +} + +/// Read a root-window property whole. +/// +/// The length is asked for in 32-bit units, which is X11's unit for this call +/// and a trap worth naming: a display profile is a few kilobytes and a request +/// stated in bytes would truncate it into a shape `read_profile` then rejects +/// as corrupt. `u32::MAX / 4` asks for all of it. +fn fetch( + conn: &impl Connection, + root: u32, + name: &str, +) -> Result>, Box> { + let atom = conn.intern_atom(true, name.as_bytes())?.reply()?.atom; + // `only_if_exists` above: an atom that no one has interned is a property + // no one has set, and interning it ourselves would litter the server with + // atoms for the monitors this desktop does not have. + if atom == 0 { + return Ok(None); + } + let reply = conn + .get_property(false, root, atom, AtomEnum::CARDINAL, 0, u32::MAX / 4)? + .reply()?; + if reply.value.is_empty() { + return Ok(None); + } + Ok(Some(reply.value)) +} diff --git a/platform/dr-plat/src/lib.rs b/platform/dr-plat/src/lib.rs index a64ae44..808ed80 100644 --- a/platform/dr-plat/src/lib.rs +++ b/platform/dr-plat/src/lib.rs @@ -4,10 +4,15 @@ //! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1, //! ARCH §10). +pub mod display; pub mod secrets; pub mod storage; pub mod volumes; +pub use display::{ + Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason, + ProfileSource, +}; pub use secrets::{ EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore, };