use pixelcoords_core::geometry::{Point, Size};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};
use crate::capture::WindowInfo;
pub fn is_wayland_env(session_type: Option<&str>, wayland_display: Option<&str>) -> bool {
if let Some(declared) = session_type.filter(|t| !t.is_empty()) {
return declared.eq_ignore_ascii_case("wayland");
}
wayland_display.is_some_and(|d| !d.is_empty())
}
pub fn is_wayland() -> bool {
let session_type = std::env::var("XDG_SESSION_TYPE").ok();
let wayland_display = std::env::var("WAYLAND_DISPLAY").ok();
is_wayland_env(session_type.as_deref(), wayland_display.as_deref())
}
pub fn explain_enumeration_failure(err: anyhow::Error) -> anyhow::Error {
annotate_enumeration_failure(err, is_wayland())
}
fn annotate_enumeration_failure(err: anyhow::Error, wayland: bool) -> anyhow::Error {
if !wayland {
return err;
}
err.context(
"monitor geometry comes from RandR even on Wayland, so Xwayland must \
be running and DISPLAY set — GNOME starts it on demand, but a \
session without Xwayland at all is unsupported",
)
}
pub fn portal_pick() -> anyhow::Result<image::RgbaImage> {
use anyhow::Context;
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::Value;
let conn = Connection::session().context("connecting to the session DBus")?;
let token = format!("pixelcoords{}", std::process::id());
let request_path = request_object_path(
conn.unique_name()
.context("the session bus assigned no unique name")?
.as_str(),
&token,
);
let request: Proxy<'static> = Proxy::new(
&conn,
"org.freedesktop.portal.Desktop",
request_path,
"org.freedesktop.portal.Request",
)
.context("building the portal request proxy")?;
let mut responses = request
.receive_signal("Response")
.context("subscribing to the portal response")?;
let screenshot = Proxy::new(
&conn,
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Screenshot",
)
.context("reaching the screenshot portal (is xdg-desktop-portal running?)")?;
let mut options: std::collections::HashMap<&str, Value> = std::collections::HashMap::new();
options.insert("handle_token", Value::from(token.as_str()));
options.insert("modal", Value::from(true));
options.insert("interactive", Value::from(true));
screenshot
.call_method("Screenshot", &("", options))
.context("asking the portal for an interactive screenshot")?;
let message = responses
.next()
.context("the portal closed without answering")?;
let (code, mut results): (
u32,
std::collections::HashMap<String, zbus::zvariant::OwnedValue>,
) = message
.body()
.deserialize()
.context("reading the portal response")?;
explain_portal_code(code)?;
let uri: String = results
.remove("uri")
.context("the portal response carried no uri")?
.try_into()
.context("the portal uri is not a string")?;
let path = uri_to_path(&uri)?;
let img = image::open(&path)
.with_context(|| format!("reading the portal screenshot {}", path.display()))?
.to_rgba8();
let _ = std::fs::remove_file(&path);
Ok(img)
}
fn request_object_path(unique_name: &str, token: &str) -> String {
let id = unique_name.trim_start_matches(':').replace('.', "_");
format!("/org/freedesktop/portal/desktop/request/{id}/{token}")
}
fn explain_portal_code(code: u32) -> anyhow::Result<()> {
match code {
0 => Ok(()),
1 => Err(anyhow::anyhow!(
"the picker was cancelled — nothing was captured"
)),
other => Err(anyhow::anyhow!(
"the portal refused the screenshot (response code {other})"
)),
}
}
fn uri_to_path(uri: &str) -> anyhow::Result<std::path::PathBuf> {
use std::os::unix::ffi::OsStringExt;
let rest = uri
.strip_prefix("file://")
.ok_or_else(|| anyhow::anyhow!("the portal returned a non-file URI: {uri}"))?;
let mut bytes = Vec::with_capacity(rest.len());
let mut input = rest.bytes();
while let Some(b) = input.next() {
if b != b'%' {
bytes.push(b);
continue;
}
let hex: [u8; 2] = [
input
.next()
.ok_or_else(|| anyhow::anyhow!("truncated percent escape in URI: {uri}"))?,
input
.next()
.ok_or_else(|| anyhow::anyhow!("truncated percent escape in URI: {uri}"))?,
];
let digit = |h: u8| -> anyhow::Result<u8> {
(h as char)
.to_digit(16)
.map(|d| d as u8)
.ok_or_else(|| anyhow::anyhow!("invalid percent escape in URI: {uri}"))
};
bytes.push(digit(hex[0])? * 16 + digit(hex[1])?);
}
Ok(std::path::PathBuf::from(std::ffi::OsString::from_vec(
bytes,
)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FrameExtents {
pub left: i32,
pub right: i32,
pub top: i32,
pub bottom: i32,
}
pub fn inset_to_visible(origin: Point, size: Size, extents: FrameExtents) -> (Point, Size) {
let width = size.w - extents.left - extents.right;
let height = size.h - extents.top - extents.bottom;
(
Point::new(origin.x + extents.left, origin.y + extents.top),
Size::new(width.max(1), height.max(1)),
)
}
pub fn strip_invisible_borders(windows: &mut [WindowInfo]) {
let Ok((conn, _)) = x11rb::connect(None) else {
return;
};
let Ok(cookie) = conn.intern_atom(true, b"_GTK_FRAME_EXTENTS") else {
return;
};
let Ok(reply) = cookie.reply() else {
return;
};
let atom = reply.atom;
if atom == 0 {
return;
}
for window in windows {
let Some(extents) = frame_extents(&conn, atom, window.id) else {
continue;
};
let (origin, size) = inset_to_visible(window.origin, window.size_native, extents);
window.origin = origin;
window.size_native = size;
}
}
fn frame_extents<C: Connection>(conn: &C, atom: u32, window: u32) -> Option<FrameExtents> {
let reply = conn
.get_property(false, window, atom, AtomEnum::CARDINAL, 0, 4)
.ok()?
.reply()
.ok()?;
let values: Vec<u32> = reply.value32()?.collect();
let [left, right, top, bottom] = values[..] else {
return None;
};
Some(FrameExtents {
left: left as i32,
right: right as i32,
top: top as i32,
bottom: bottom as i32,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_paths_follow_the_portal_convention() {
assert_eq!(
request_object_path(":1.42", "pixelcoords7"),
"/org/freedesktop/portal/desktop/request/1_42/pixelcoords7"
);
}
#[test]
fn portal_codes_map_to_actionable_errors() {
assert!(explain_portal_code(0).is_ok());
let cancelled = explain_portal_code(1).unwrap_err().to_string();
assert!(cancelled.contains("cancelled"), "got: {cancelled}");
let refused = explain_portal_code(2).unwrap_err().to_string();
assert!(refused.contains("response code 2"), "got: {refused}");
}
#[test]
fn file_uris_percent_decode_to_paths() {
assert_eq!(
uri_to_path("file:///home/user/Pictures/Screenshot%20From%202026.png").unwrap(),
std::path::PathBuf::from("/home/user/Pictures/Screenshot From 2026.png")
);
assert!(uri_to_path("https://example.com/x.png").is_err());
assert!(uri_to_path("file:///bad%2").is_err());
assert!(uri_to_path("file:///bad%zz").is_err());
}
#[test]
fn session_type_wayland_is_wayland() {
assert!(is_wayland_env(Some("wayland"), None));
}
#[test]
fn session_type_match_ignores_case() {
assert!(is_wayland_env(Some("Wayland"), None));
}
#[test]
fn session_type_x11_is_not_wayland() {
assert!(!is_wayland_env(Some("x11"), None));
}
#[test]
fn wayland_display_alone_is_wayland() {
assert!(!is_wayland_env(None, None));
assert!(is_wayland_env(None, Some("wayland-0")));
}
#[test]
fn empty_wayland_display_is_not_wayland() {
assert!(!is_wayland_env(None, Some("")));
}
const GNOME_SHADOW: FrameExtents = FrameExtents {
left: 26,
right: 26,
top: 23,
bottom: 29,
};
#[test]
fn the_visible_window_sits_inside_the_reported_frame() {
let (origin, size) =
inset_to_visible(Point::new(47, 11), Size::new(1099, 1178), GNOME_SHADOW);
assert_eq!(origin, Point::new(73, 34));
assert_eq!(size, Size::new(1047, 1126));
}
#[test]
fn zero_extents_leave_bounds_untouched() {
let extents = FrameExtents {
left: 0,
right: 0,
top: 0,
bottom: 0,
};
let (origin, size) = inset_to_visible(Point::new(10, 20), Size::new(300, 200), extents);
assert_eq!((origin, size), (Point::new(10, 20), Size::new(300, 200)));
}
#[test]
fn extents_wider_than_the_window_clamp_instead_of_inverting() {
let (_, size) = inset_to_visible(Point::new(0, 0), Size::new(40, 40), GNOME_SHADOW);
assert_eq!(size, Size::new(1, 1));
}
#[test]
fn wayland_enumeration_failures_name_xwayland() {
let err = annotate_enumeration_failure(anyhow::anyhow!("Connection closed"), true);
let chain = format!("{err:#}");
assert!(chain.contains("Xwayland"), "{chain}");
assert!(chain.contains("Connection closed"), "{chain}");
}
#[test]
fn x11_enumeration_failures_are_left_alone() {
let err = annotate_enumeration_failure(anyhow::anyhow!("Connection closed"), false);
assert_eq!(format!("{err:#}"), "Connection closed");
}
#[test]
fn x11_session_wins_over_a_stray_wayland_display() {
assert!(!is_wayland_env(Some("x11"), Some("wayland-0")));
}
}