Ask the platform what colour the screen actually is

FR-DSP-8's acquisition half. `dr_plat::display` surveys the session's
displays and reduces each one's profile to an output space the pipeline
can encode into, stating the mechanism per display server as
FR-PLAT-LIN-2 requires:

  - X11 reads the `_ICC_PROFILE` / `_ICC_PROFILE_<n>` root-window
    properties, enumerating and numbering the outputs through RandR,
    which also yields the rectangles a window move is measured against.
  - Wayland binds `wp_color_manager_v1` and asks each `wl_output` for
    its image description, accepting either an ICC profile on a file
    descriptor or primaries stated as chromaticities.
  - Where neither answers, sRGB is assumed and the reason travels with
    it as data rather than into a log, so the About page can say which
    path the session is on.

A display profile is a measurement of one panel and is none of the four
spaces the pipeline knows. Rather than grow an ICC engine, the profile
is reduced to D50-adapted colorants and matched against the four; a
match that is merely nearest is marked as such and shown as such.

Verified on this machine: mutter 50 advertises the colour-management
global and reports eDP-1 as sRGB, and the same session forced onto X11
enumerates the output through RandR and correctly finds no atom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 18:20:28 +02:00
co-authored by Claude Opus 5
parent 725f7bf77f
commit e4875498ca
10 changed files with 1561 additions and 2 deletions
+381
View File
@@ -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<String>,
description: Option<String>,
/// The ICC profile the compositor handed over, if it did.
icc: Option<Vec<u8>>,
/// Primaries as CIE xy, if it stated them instead.
primaries: Option<Chromaticities>,
/// Set when the image description could not be produced at all.
failed: Option<String>,
/// Whether anything at all came back for this output.
answered: bool,
}
struct State {
manager: Option<wp_color_manager_v1::WpColorManagerV1>,
outputs: Vec<wl_output::WlOutput>,
pending: Vec<Pending>,
}
/// Every output the compositor has, with whatever colour it will state.
pub fn probe() -> Result<Vec<DisplayInfo>, 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>,
state: &mut State,
) -> Result<(), FallbackReason> {
queue
.roundtrip(state)
.map(|_| ())
.map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}")))
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<wl_output::WlOutput, usize> for State {
fn event(
state: &mut Self,
_: &wl_output::WlOutput,
event: wl_output::Event,
index: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
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<wp_color_manager_v1::WpColorManagerV1, ()> for State {
fn event(
_: &mut Self,
_: &wp_color_manager_v1::WpColorManagerV1,
_: wp_color_manager_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
// 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<wp_color_management_output_v1::WpColorManagementOutputV1, usize> for State {
fn event(
_: &mut Self,
_: &wp_color_management_output_v1::WpColorManagementOutputV1,
_: wp_color_management_output_v1::Event,
_: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
// `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<wp_image_description_v1::WpImageDescriptionV1, usize> for State {
fn event(
state: &mut Self,
description: &wp_image_description_v1::WpImageDescriptionV1,
event: wp_image_description_v1::Event,
index: &usize,
_: &Connection,
qh: &QueueHandle<Self>,
) {
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<wp_image_description_info_v1::WpImageDescriptionInfoV1, usize> for State {
fn event(
state: &mut Self,
_: &wp_image_description_info_v1::WpImageDescriptionInfoV1,
event: wp_image_description_info_v1::Event,
index: &usize,
_: &Connection,
_: &QueueHandle<Self>,
) {
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<Vec<u8>> {
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)
}