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>
382 lines
14 KiB
Rust
382 lines
14 KiB
Rust
//! 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)
|
|
}
|