//! 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) }