use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
Atom, AtomEnum, ClientMessageEvent, Colormap, ConfigureWindowAux, ConnectionExt as _,
CreateGCAux, CreateWindowAux, EventMask, PropMode, Segment, SelectionNotifyEvent,
SelectionRequestEvent, StackMode, Window, WindowClass, SELECTION_NOTIFY_EVENT,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as _;
const DRAG_PATH_LIMIT: usize = 1_024;
const URI_LIST_BYTES_LIMIT: usize = 128 * 1024;
const EVENT_LIMIT: usize = 64;
const WINDOW_HIERARCHY_LIMIT: usize = 64;
const STATUS_RESPONSE_TIMEOUT: Duration = Duration::from_secs(1);
const DROP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
const POSITION_RESPONSE_RETRY: Duration = Duration::from_millis(150);
const DRAG_ICON_WIDTH: u16 = 48;
const DRAG_ICON_HEIGHT: u16 = 32;
x11rb::atom_manager! {
Atoms: AtomsCookie {
TARGETS,
TIMESTAMP,
GUTH_DRAG_TIMESTAMP,
_NET_WM_WINDOW_TYPE,
_NET_WM_WINDOW_TYPE_DND,
XdndActionCopy,
XdndActionPrivate,
XdndActionList,
XdndAware,
XdndDrop,
XdndEnter,
XdndFinished,
XdndLeave,
XdndPosition,
XdndProxy,
XdndSelection,
XdndStatus,
XdndTypeList,
TextUriList: b"text/uri-list",
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DragOutcome {
None,
Initiated(usize),
Completed(usize),
Rejected,
}
pub struct NativeFileDrag {
x11: Option<X11DragSource>,
unavailable_reason: Option<String>,
}
impl NativeFileDrag {
pub fn new(context: &eframe::CreationContext<'_>) -> Self {
let raw_window = context.window_handle().ok().map(|handle| handle.as_raw());
let app_window = raw_window.and_then(|handle| match handle {
RawWindowHandle::Xlib(handle) => u32::try_from(handle.window).ok(),
RawWindowHandle::Xcb(handle) => Some(handle.window.get()),
_ => None,
});
let (x11, unavailable_reason) = match app_window {
Some(window) => match X11DragSource::new(window) {
Ok(source) => (Some(source), None),
Err(error) => (None, Some(format!("X11 drag backend failed: {error}"))),
},
None if matches!(raw_window, Some(RawWindowHandle::Wayland(_))) => (None, Some(
"direct Wayland file drag is unavailable because winit does not expose the pointer-button serial required to start a data-device drag; use Guth through XWayland"
.to_string(),
)),
None => (None, Some("native file drag requires an X11/XWayland window".to_string())),
};
Self {
x11,
unavailable_reason,
}
}
pub fn update(
&mut self,
paths: &[PathBuf],
pointer_down: bool,
pointer_released: bool,
cancelled: bool,
) -> DragOutcome {
self.x11.as_mut().map_or(DragOutcome::None, |source| {
source.update(paths, pointer_down, pointer_released, cancelled)
})
}
pub fn is_active(&self) -> bool {
self.x11.as_ref().is_some_and(X11DragSource::is_active)
}
pub fn unavailable_reason(&self) -> Option<&str> {
self.unavailable_reason.as_deref()
}
pub fn desktop_pointer_position(&self) -> Option<(i16, i16)> {
self.x11
.as_ref()
.and_then(|source| source.root_pointer_position().ok())
}
}
struct X11DragSource {
connection: RustConnection,
root: Window,
app_window: Window,
source_window: Window,
icon_accent: u32,
atoms: Atoms,
drag: Option<ActiveDrag>,
pending_outcome: Option<DragOutcome>,
failed_until_release: bool,
}
struct ActiveDrag {
paths: Vec<PathBuf>,
uri_list: Vec<u8>,
target: Option<DragTarget>,
accepted: bool,
position_pending: bool,
position_sent_at: Option<Instant>,
last_position: Option<(i16, i16)>,
release_pending: bool,
dropped: bool,
deadline: Option<Instant>,
timestamp: u32,
owns_selection: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct DragTarget {
window: Window,
recipient: Window,
version: u32,
}
impl X11DragSource {
fn new(app_window: Window) -> Result<Self, String> {
let (connection, screen_number) = x11rb::connect(None).map_err(display_error)?;
let screen = &connection.setup().roots[screen_number];
let root = screen.root;
let root_depth = screen.root_depth;
let root_visual = screen.root_visual;
let default_colormap = screen.default_colormap;
let white_pixel = screen.white_pixel;
let black_pixel = screen.black_pixel;
let icon_background =
allocate_color(&connection, default_colormap, [10, 19, 23]).unwrap_or(black_pixel);
let icon_accent =
allocate_color(&connection, default_colormap, [18, 165, 191]).unwrap_or(white_pixel);
let source_window = connection.generate_id().map_err(display_error)?;
connection
.create_window(
root_depth,
source_window,
root,
0,
0,
DRAG_ICON_WIDTH,
DRAG_ICON_HEIGHT,
2,
WindowClass::INPUT_OUTPUT,
root_visual,
&CreateWindowAux::new()
.override_redirect(1)
.background_pixel(icon_background)
.border_pixel(icon_accent)
.event_mask(EventMask::PROPERTY_CHANGE),
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
let atoms = Atoms::new(&connection)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
connection
.change_property32(
PropMode::REPLACE,
source_window,
atoms._NET_WM_WINDOW_TYPE,
AtomEnum::ATOM,
&[atoms._NET_WM_WINDOW_TYPE_DND],
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
connection.flush().map_err(display_error)?;
Ok(Self {
connection,
root,
app_window,
source_window,
icon_accent,
atoms,
drag: None,
pending_outcome: None,
failed_until_release: false,
})
}
fn is_active(&self) -> bool {
self.drag.is_some()
}
fn root_pointer_position(&self) -> Result<(i16, i16), String> {
let pointer = self
.connection
.query_pointer(self.root)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
Ok((pointer.root_x, pointer.root_y))
}
fn update(
&mut self,
paths: &[PathBuf],
pointer_down: bool,
pointer_released: bool,
cancelled: bool,
) -> DragOutcome {
if self.poll_events().is_err() {
self.reset(true);
return DragOutcome::Rejected;
}
if let Some(outcome) = self.pending_outcome.take() {
return outcome;
}
if self
.drag
.as_ref()
.is_some_and(|drag| drag.deadline.is_some_and(|time| time <= Instant::now()))
{
self.reset(true);
return DragOutcome::Rejected;
}
if cancelled {
self.cancel();
return DragOutcome::Rejected;
}
if self.failed_until_release {
if !pointer_down {
self.failed_until_release = false;
}
return DragOutcome::None;
}
if self.drag.as_ref().is_some_and(|drag| drag.release_pending) {
if self.update_target().is_err() {
self.cancel();
return DragOutcome::Rejected;
}
return self.advance_release();
}
if self.drag.as_ref().is_some_and(|drag| drag.dropped) {
return DragOutcome::None;
}
if paths.is_empty() {
if self.drag.is_some() {
self.cancel();
}
return DragOutcome::None;
}
if self.drag.is_some() && (pointer_released || !pointer_down) {
drag_debug("primary pointer released");
if let Some(drag) = self.drag.as_mut() {
drag.release_pending = true;
drag.deadline = Some(Instant::now() + STATUS_RESPONSE_TIMEOUT);
}
return self.advance_release();
}
if self.drag.is_none() && pointer_down && self.start(paths).is_err() {
self.reset(true);
self.failed_until_release = true;
return DragOutcome::Rejected;
}
if self.drag.is_none() {
return DragOutcome::None;
}
if self.drag.as_ref().is_some_and(|drag| drag.paths != paths) {
self.cancel();
if self.start(paths).is_err() {
self.reset(true);
self.failed_until_release = true;
return DragOutcome::Rejected;
}
}
match self.update_target() {
Ok(_) => {}
Err(_) => {
self.cancel();
return DragOutcome::Rejected;
}
}
DragOutcome::None
}
fn start(&mut self, paths: &[PathBuf]) -> Result<(), String> {
if paths.is_empty() || paths.len() > DRAG_PATH_LIMIT {
return Err(format!(
"drag must contain between 1 and {DRAG_PATH_LIMIT} paths"
));
}
if paths.iter().any(|path| !path.is_absolute()) {
return Err("all drag paths must be absolute".to_string());
}
let paths = paths.to_vec();
let path_count = paths.len();
let uri_list = encode_uri_list(&paths)?;
self.connection
.change_property32(
PropMode::REPLACE,
self.source_window,
self.atoms.XdndTypeList,
AtomEnum::ATOM,
&[self.atoms.TextUriList],
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
self.connection
.change_property32(
PropMode::REPLACE,
self.source_window,
self.atoms.XdndActionList,
AtomEnum::ATOM,
&[self.atoms.XdndActionCopy],
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
let timestamp = self.server_timestamp()?;
let set_owner = self
.connection
.set_selection_owner(self.source_window, self.atoms.XdndSelection, timestamp)
.map_err(display_error)?;
if let Err(error) = set_owner.check() {
self.relinquish_selection(timestamp);
return Err(display_error(error));
}
let owner = match self
.connection
.get_selection_owner(self.atoms.XdndSelection)
{
Ok(cookie) => match cookie.reply() {
Ok(reply) => reply.owner,
Err(error) => {
self.relinquish_selection(timestamp);
return Err(display_error(error));
}
},
Err(error) => {
self.relinquish_selection(timestamp);
return Err(display_error(error));
}
};
if owner != self.source_window {
return Err(format!(
"could not own the XDND selection (owner={owner}, timestamp={timestamp})"
));
}
self.drag = Some(ActiveDrag {
paths,
uri_list,
target: None,
accepted: false,
position_pending: false,
position_sent_at: None,
last_position: None,
release_pending: false,
dropped: false,
deadline: None,
timestamp,
owns_selection: true,
});
self.connection.flush().map_err(display_error)?;
let pointer = self
.connection
.query_pointer(self.root)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
self.move_icon(pointer.root_x, pointer.root_y)?;
self.connection
.map_window(self.source_window)
.map_err(display_error)?
.check()
.map_err(display_error)?;
self.paint_drag_icon()?;
self.connection.flush().map_err(display_error)?;
drag_debug(&format!("started {path_count} paths"));
Ok(())
}
fn server_timestamp(&self) -> Result<u32, String> {
self.connection
.change_property8(
PropMode::REPLACE,
self.source_window,
self.atoms.GUTH_DRAG_TIMESTAMP,
AtomEnum::INTEGER,
&[0],
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
self.connection.flush().map_err(display_error)?;
let deadline = Instant::now() + STATUS_RESPONSE_TIMEOUT;
while Instant::now() < deadline {
match self.connection.poll_for_event().map_err(display_error)? {
None => std::thread::sleep(Duration::from_millis(1)),
Some(Event::PropertyNotify(event))
if event.window == self.source_window
&& event.atom == self.atoms.GUTH_DRAG_TIMESTAMP =>
{
return Ok(event.time);
}
Some(Event::SelectionRequest(event)) => self.answer_selection_request(event)?,
Some(_) => {}
}
}
Err("timed out while acquiring the X11 drag timestamp".to_string())
}
fn update_target(&mut self) -> Result<bool, String> {
let now = Instant::now();
let retry_position = self.drag.as_ref().is_some_and(|drag| {
position_response_retry_due(drag.position_pending, drag.position_sent_at, now)
});
if retry_position {
if let Some(drag) = self.drag.as_mut() {
drag.position_pending = false;
drag.position_sent_at = None;
drag.accepted = false;
}
}
let pointer = self
.connection
.query_pointer(self.root)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
self.move_icon(pointer.root_x, pointer.root_y)?;
let target = self.find_target(pointer.child)?;
let previous = self.drag.as_ref().and_then(|drag| drag.target);
if target != previous {
drag_debug(&format!("target {previous:?} -> {target:?}"));
if let Some(previous) = previous {
self.send_target(
previous,
self.atoms.XdndLeave,
[self.source_window, 0, 0, 0, 0],
)?;
}
if let Some(target) = target {
self.send_target(
target,
self.atoms.XdndEnter,
[
self.source_window,
target.version.min(5) << 24,
self.atoms.TextUriList,
0,
0,
],
)?;
}
if let Some(drag) = self.drag.as_mut() {
drag.target = target;
drag.accepted = false;
drag.position_pending = false;
drag.position_sent_at = None;
drag.last_position = None;
}
}
let current_position = (pointer.root_x, pointer.root_y);
let send_position = target.is_some()
&& self.drag.as_ref().is_some_and(|drag| {
should_send_position(
drag.position_pending,
drag.last_position,
current_position,
retry_position,
)
});
if let Some(target) = target.filter(|_| send_position) {
let position = if retry_position {
self.drag
.as_ref()
.and_then(|drag| drag.last_position)
.unwrap_or(current_position)
} else {
current_position
};
let coordinates = (u32::from(position.0 as u16) << 16) | u32::from(position.1 as u16);
let timestamp = self
.drag
.as_ref()
.map(|drag| drag.timestamp)
.unwrap_or(x11rb::CURRENT_TIME);
self.send_target(
target,
self.atoms.XdndPosition,
[
self.source_window,
0,
coordinates,
timestamp,
self.atoms.XdndActionCopy,
],
)?;
if let Some(drag) = self.drag.as_mut() {
drag.accepted = false;
drag.position_pending = true;
drag.position_sent_at = Some(now);
drag.last_position = Some(position);
}
return Ok(true);
}
Ok(false)
}
fn move_icon(&self, root_x: i16, root_y: i16) -> Result<(), String> {
self.connection
.configure_window(
self.source_window,
&ConfigureWindowAux::new()
.x(i32::from(root_x) + 16)
.y(i32::from(root_y) + 16)
.stack_mode(StackMode::ABOVE),
)
.map_err(display_error)?;
self.connection.flush().map_err(display_error)
}
fn paint_drag_icon(&self) -> Result<(), String> {
let gc = self.connection.generate_id().map_err(display_error)?;
self.connection
.create_gc(
gc,
self.source_window,
&CreateGCAux::new()
.foreground(self.icon_accent)
.line_width(2),
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
self.connection
.poly_segment(
self.source_window,
gc,
&[
Segment {
x1: 8,
y1: 9,
x2: 8,
y2: 25,
},
Segment {
x1: 8,
y1: 25,
x2: 39,
y2: 25,
},
Segment {
x1: 39,
y1: 25,
x2: 39,
y2: 10,
},
Segment {
x1: 39,
y1: 10,
x2: 24,
y2: 10,
},
Segment {
x1: 24,
y1: 10,
x2: 20,
y2: 6,
},
Segment {
x1: 20,
y1: 6,
x2: 8,
y2: 6,
},
Segment {
x1: 8,
y1: 6,
x2: 8,
y2: 9,
},
Segment {
x1: 12,
y1: 15,
x2: 35,
y2: 15,
},
],
)
.map_err(display_error)?
.check()
.map_err(display_error)?;
self.connection
.free_gc(gc)
.map_err(display_error)?
.check()
.map_err(display_error)
}
fn advance_release(&mut self) -> DragOutcome {
let Some(drag) = self.drag.as_ref() else {
return DragOutcome::None;
};
if drag.position_pending {
return DragOutcome::None;
}
self.initiate_drop()
}
fn initiate_drop(&mut self) -> DragOutcome {
let Some(drag) = self.drag.as_ref() else {
return DragOutcome::None;
};
let count = drag.paths.len();
let target = drag.target;
let accepted = drag.accepted;
let timestamp = drag.timestamp;
if let Some(target) = target.filter(|_| accepted) {
if self
.send_target(
target,
self.atoms.XdndDrop,
[self.source_window, 0, timestamp, 0, 0],
)
.is_ok()
{
if let Some(drag) = self.drag.as_mut() {
drag.release_pending = false;
drag.dropped = true;
drag.deadline = Some(Instant::now() + DROP_RESPONSE_TIMEOUT);
}
return DragOutcome::Initiated(count);
}
}
self.cancel();
DragOutcome::Rejected
}
fn cancel(&mut self) {
if let Some(target) = self
.drag
.as_ref()
.filter(|drag| !drag.dropped)
.and_then(|drag| drag.target)
{
let _ = self.send_target(
target,
self.atoms.XdndLeave,
[self.source_window, 0, 0, 0, 0],
);
}
self.reset(true);
}
fn reset(&mut self, release_selection: bool) {
if release_selection {
if let Some(drag) = self.drag.as_ref().filter(|drag| drag.owns_selection) {
self.relinquish_selection(drag.timestamp);
}
}
let _ = self.connection.unmap_window(self.source_window);
let _ = self.connection.flush();
self.drag = None;
}
fn relinquish_selection(&self, timestamp: u32) {
let _ =
self.connection
.set_selection_owner(x11rb::NONE, self.atoms.XdndSelection, timestamp);
let _ = self.connection.flush();
}
fn find_target(&self, child: Window) -> Result<Option<DragTarget>, String> {
let mut current = if child == x11rb::NONE {
self.root
} else {
child
};
for _ in 0..WINDOW_HIERARCHY_LIMIT {
let reply = self
.connection
.query_pointer(current)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
if reply.child == x11rb::NONE || reply.child == current {
break;
}
current = reply.child;
}
let mut ancestors = Vec::new();
for _ in 0..WINDOW_HIERARCHY_LIMIT {
if current == self.app_window || current == self.source_window {
return Ok(None);
}
ancestors.push(current);
if current == self.root {
break;
}
let tree = self
.connection
.query_tree(current)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
if tree.parent == x11rb::NONE || tree.parent == current {
break;
}
current = tree.parent;
}
for window in ancestors {
let recipient = self.xdnd_proxy(window)?.unwrap_or(window);
if let Some(version) = self.xdnd_version(recipient)? {
return Ok(Some(DragTarget {
window,
recipient,
version,
}));
}
}
Ok(None)
}
fn xdnd_version(&self, window: Window) -> Result<Option<u32>, String> {
let property = self
.connection
.get_property(false, window, self.atoms.XdndAware, AtomEnum::ATOM, 0, 1)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
if property.type_ != u32::from(AtomEnum::ATOM) || property.format != 32 {
return Ok(None);
}
Ok(property
.value32()
.and_then(|mut values| values.next())
.filter(|version| *version >= 3)
.map(|version| version.min(5)))
}
fn xdnd_proxy(&self, window: Window) -> Result<Option<Window>, String> {
let proxy = self
.connection
.get_property(false, window, self.atoms.XdndProxy, AtomEnum::WINDOW, 0, 1)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
if proxy.type_ != u32::from(AtomEnum::WINDOW) || proxy.format != 32 {
return Ok(None);
}
let Some(proxy) = proxy.value32().and_then(|mut values| values.next()) else {
return Ok(None);
};
let confirmation = self
.connection
.get_property(false, proxy, self.atoms.XdndProxy, AtomEnum::WINDOW, 0, 1)
.map_err(display_error)?
.reply()
.map_err(display_error)?;
Ok(
(confirmation.type_ == u32::from(AtomEnum::WINDOW) && confirmation.format == 32)
.then(|| confirmation.value32().and_then(|mut values| values.next()))
.flatten()
.filter(|confirmed| *confirmed == proxy),
)
}
fn poll_events(&mut self) -> Result<(), String> {
for _ in 0..EVENT_LIMIT {
let Some(event) = self.connection.poll_for_event().map_err(display_error)? else {
break;
};
match event {
Event::ClientMessage(event)
if event.type_ == self.atoms.XdndStatus
&& event.format == 32
&& event.window == self.source_window =>
{
let data = event.data.as_data32();
if let Some(drag) = self.drag.as_mut() {
if drag.position_pending
&& drag
.target
.is_some_and(|target| target.matches_sender(data[0]))
{
drag.position_pending = false;
drag.position_sent_at = None;
drag.accepted = data[1] & 1 != 0
&& is_copy_acknowledgement(
data[4],
self.atoms.XdndActionCopy,
self.atoms.XdndActionPrivate,
);
drag_debug(&format!("status accepted={}", drag.accepted));
}
}
}
Event::ClientMessage(event)
if event.type_ == self.atoms.XdndFinished
&& event.format == 32
&& event.window == self.source_window =>
{
let data = event.data.as_data32();
if let Some((count, version)) = self.drag.as_ref().and_then(|drag| {
drag.target
.filter(|target| drag.dropped && target.matches_sender(data[0]))
.map(|target| (drag.paths.len(), target.version))
}) {
let succeeded = version < 5
|| (data[1] & 1 != 0
&& is_copy_acknowledgement(
data[2],
self.atoms.XdndActionCopy,
self.atoms.XdndActionPrivate,
));
self.pending_outcome = Some(if succeeded {
DragOutcome::Completed(count)
} else {
DragOutcome::Rejected
});
self.reset(true);
}
}
Event::SelectionRequest(event) => self.answer_selection_request(event)?,
Event::SelectionClear(event)
if event.owner == self.source_window
&& event.selection == self.atoms.XdndSelection
&& self.drag.is_some() =>
{
let owner = self
.connection
.get_selection_owner(self.atoms.XdndSelection)
.map_err(display_error)?
.reply()
.map_err(display_error)?
.owner;
if owner != self.source_window {
self.pending_outcome = Some(DragOutcome::Rejected);
self.reset(false);
}
}
_ => {}
}
}
Ok(())
}
fn answer_selection_request(&self, request: SelectionRequestEvent) -> Result<(), String> {
if request.owner != self.source_window || request.selection != self.atoms.XdndSelection {
return Ok(());
}
let property = if request.property == x11rb::NONE {
request.target
} else {
request.property
};
let active = self.drag.as_ref().filter(|drag| {
drag.owns_selection && timestamp_not_older(request.time, drag.timestamp)
});
let result = if request.target == self.atoms.TextUriList {
active.map_or(Err(()), |drag| {
self.connection
.change_property8(
PropMode::REPLACE,
request.requestor,
property,
self.atoms.TextUriList,
&drag.uri_list,
)
.map_err(|_| ())?
.check()
.map_err(|_| ())
})
} else if request.target == self.atoms.TARGETS && active.is_some() {
self.connection
.change_property32(
PropMode::REPLACE,
request.requestor,
property,
AtomEnum::ATOM,
&[
self.atoms.TextUriList,
self.atoms.TARGETS,
self.atoms.TIMESTAMP,
],
)
.map_err(|_| ())
.and_then(|cookie| cookie.check().map_err(|_| ()))
} else if request.target == self.atoms.TIMESTAMP {
active.map_or(Err(()), |drag| {
self.connection
.change_property32(
PropMode::REPLACE,
request.requestor,
property,
AtomEnum::INTEGER,
&[drag.timestamp],
)
.map_err(|_| ())?
.check()
.map_err(|_| ())
})
} else {
Err(())
};
let notify = SelectionNotifyEvent {
response_type: SELECTION_NOTIFY_EVENT,
sequence: 0,
time: request.time,
requestor: request.requestor,
selection: request.selection,
target: request.target,
property: if result.is_ok() {
property
} else {
x11rb::NONE
},
};
self.connection
.send_event(false, request.requestor, EventMask::NO_EVENT, notify)
.map_err(display_error)?
.check()
.map_err(display_error)?;
self.connection.flush().map_err(display_error)
}
fn send_target(
&self,
target: DragTarget,
message_type: Atom,
data: [u32; 5],
) -> Result<(), String> {
let event = ClientMessageEvent::new(32, target.window, message_type, data);
self.connection
.send_event(false, target.recipient, EventMask::NO_EVENT, event)
.map_err(display_error)?
.check()
.map_err(display_error)?;
self.connection.flush().map_err(display_error)
}
}
impl DragTarget {
fn matches_sender(self, sender: Window) -> bool {
sender == self.window || sender == self.recipient
}
}
impl Drop for X11DragSource {
fn drop(&mut self) {
self.cancel();
let _ = self.connection.destroy_window(self.source_window);
let _ = self.connection.flush();
}
}
fn encode_uri_list(paths: &[PathBuf]) -> Result<Vec<u8>, String> {
if paths.is_empty() || paths.len() > DRAG_PATH_LIMIT {
return Err(format!(
"drag must contain between 1 and {DRAG_PATH_LIMIT} paths"
));
}
let mut output = Vec::new();
for path in paths {
let uri = file_uri(path)?;
if output.len().saturating_add(uri.len()).saturating_add(2) > URI_LIST_BYTES_LIMIT {
return Err("drag URI list exceeds the supported size".to_string());
}
output.extend_from_slice(uri.as_bytes());
output.extend_from_slice(b"\r\n");
}
Ok(output)
}
fn timestamp_not_older(candidate: u32, reference: u32) -> bool {
candidate == x11rb::CURRENT_TIME || candidate.wrapping_sub(reference) < (1_u32 << 31)
}
fn position_response_retry_due(
position_pending: bool,
sent_at: Option<Instant>,
now: Instant,
) -> bool {
position_pending
&& sent_at.is_some_and(|sent_at| {
now.saturating_duration_since(sent_at) >= POSITION_RESPONSE_RETRY
})
}
fn should_send_position(
position_pending: bool,
last_position: Option<(i16, i16)>,
current_position: (i16, i16),
retry_position: bool,
) -> bool {
!position_pending && (retry_position || last_position != Some(current_position))
}
fn is_copy_acknowledgement(action: Atom, copy: Atom, private: Atom) -> bool {
action == copy || action == private
}
fn file_uri(path: &Path) -> Result<String, String> {
if !path.is_absolute() {
return Err("drag path must be absolute".to_string());
}
#[cfg(unix)]
let bytes = {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes()
};
#[cfg(not(unix))]
let bytes = path
.to_str()
.ok_or_else(|| "drag path is not valid UTF-8".to_string())?
.as_bytes();
let mut uri = String::from("file://");
for byte in bytes {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
uri.push(char::from(*byte));
} else {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
uri.push('%');
uri.push(char::from(HEX[usize::from(byte >> 4)]));
uri.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
Ok(uri)
}
fn allocate_color(
connection: &RustConnection,
colormap: Colormap,
[red, green, blue]: [u8; 3],
) -> Option<u32> {
connection
.alloc_color(
colormap,
u16::from(red) * 257,
u16::from(green) * 257,
u16::from(blue) * 257,
)
.ok()?
.reply()
.ok()
.map(|reply| reply.pixel)
}
fn display_error(error: impl std::fmt::Display) -> String {
error.to_string()
}
fn drag_debug(message: &str) {
if std::env::var_os("GUTH_DRAG_DEBUG").is_some() {
eprintln!("guth drag-out: {message}");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn poll_x11_event(connection: &RustConnection) -> Option<Event> {
for _ in 0..100 {
if let Ok(Some(event)) = connection.poll_for_event() {
return Some(event);
}
std::thread::sleep(Duration::from_millis(2));
}
None
}
#[test]
fn uri_list_encodes_spaces_controls_and_delimiters() {
let paths = vec![
PathBuf::from("/tmp/hello world.txt"),
PathBuf::from("/tmp/a\nb#c.txt"),
];
assert_eq!(
encode_uri_list(&paths).unwrap(),
b"file:///tmp/hello%20world.txt\r\nfile:///tmp/a%0Ab%23c.txt\r\n"
);
}
#[cfg(unix)]
#[test]
fn uri_list_preserves_non_utf8_paths() {
use std::os::unix::ffi::OsStringExt;
let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/a\xffb".to_vec()));
assert_eq!(file_uri(&path).unwrap(), "file:///tmp/a%FFb");
}
#[test]
fn uri_list_rejects_relative_paths() {
assert!(file_uri(Path::new("relative.txt")).is_err());
}
#[test]
fn uri_list_rejects_excessive_path_counts() {
let paths = (0..=DRAG_PATH_LIMIT)
.map(|index| PathBuf::from(format!("/tmp/{index}")))
.collect::<Vec<_>>();
assert!(encode_uri_list(&paths).is_err());
}
#[test]
fn copy_and_winit_private_actions_are_accepted() {
assert!(is_copy_acknowledgement(11, 11, 22));
assert!(is_copy_acknowledgement(22, 11, 22));
assert!(!is_copy_acknowledgement(0, 11, 22));
assert!(!is_copy_acknowledgement(33, 11, 22));
}
#[test]
fn timestamps_handle_current_time_and_x11_wraparound() {
assert!(timestamp_not_older(x11rb::CURRENT_TIME, u32::MAX - 3));
assert!(timestamp_not_older(42, 42));
assert!(timestamp_not_older(3, u32::MAX - 3));
assert!(!timestamp_not_older(41, 42));
}
#[test]
fn positions_are_coalesced_and_bounded_retries_are_detected() {
let now = Instant::now();
assert!(!position_response_retry_due(false, Some(now), now));
assert!(!position_response_retry_due(true, None, now));
assert!(!position_response_retry_due(true, Some(now), now));
assert!(position_response_retry_due(
true,
Some(now - POSITION_RESPONSE_RETRY),
now
));
assert!(should_send_position(false, None, (10, 20), false));
assert!(!should_send_position(
false,
Some((10, 20)),
(10, 20),
false
));
assert!(should_send_position(false, Some((10, 20)), (11, 20), false));
assert!(!should_send_position(true, Some((10, 20)), (11, 20), false));
assert!(should_send_position(false, Some((10, 20)), (10, 20), true));
}
#[test]
fn proxy_and_logical_target_ids_are_both_recognized() {
let target = DragTarget {
window: 10,
recipient: 20,
version: 5,
};
assert!(target.matches_sender(10));
assert!(target.matches_sender(20));
assert!(!target.matches_sender(30));
}
#[test]
fn xdnd_source_serves_uri_list_to_accepting_target() {
let Ok((target_connection, screen_number)) = x11rb::connect(None) else {
return;
};
let root = target_connection.setup().roots[screen_number].root;
let target = target_connection.generate_id().unwrap();
let proxy = target_connection.generate_id().unwrap();
target_connection
.create_window(
x11rb::COPY_FROM_PARENT as u8,
target,
root,
0,
0,
1,
1,
0,
WindowClass::INPUT_OUTPUT,
0,
&CreateWindowAux::new(),
)
.unwrap()
.check()
.unwrap();
target_connection
.create_window(
x11rb::COPY_FROM_PARENT as u8,
proxy,
root,
0,
0,
1,
1,
0,
WindowClass::INPUT_OUTPUT,
0,
&CreateWindowAux::new(),
)
.unwrap()
.check()
.unwrap();
let target_atoms = Atoms::new(&target_connection).unwrap().reply().unwrap();
target_connection
.change_property32(
PropMode::REPLACE,
proxy,
target_atoms.XdndAware,
AtomEnum::ATOM,
&[9],
)
.unwrap()
.check()
.unwrap();
for (window, value) in [(target, proxy), (proxy, proxy)] {
target_connection
.change_property32(
PropMode::REPLACE,
window,
target_atoms.XdndProxy,
AtomEnum::WINDOW,
&[value],
)
.unwrap()
.check()
.unwrap();
}
let property = target_connection
.intern_atom(false, b"GUTH_XDND_TEST")
.unwrap()
.reply()
.unwrap()
.atom;
target_connection.flush().unwrap();
let mut source = X11DragSource::new(x11rb::NONE).unwrap();
let drag_target = DragTarget {
window: target,
recipient: proxy,
version: 5,
};
assert_eq!(source.find_target(target).unwrap(), Some(drag_target));
let paths = vec![PathBuf::from("/tmp/drag out.txt")];
source.start(&paths).unwrap();
let stale_clear = x11rb::protocol::xproto::SelectionClearEvent {
response_type: x11rb::protocol::xproto::SELECTION_CLEAR_EVENT,
sequence: 0,
time: source.drag.as_ref().unwrap().timestamp,
owner: source.source_window,
selection: target_atoms.XdndSelection,
};
target_connection
.send_event(
false,
source.source_window,
EventMask::NO_EVENT,
stale_clear,
)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
for _ in 0..10 {
source.poll_events().unwrap();
std::thread::sleep(Duration::from_millis(2));
}
assert!(source.drag.is_some());
assert!(source.pending_outcome.is_none());
source.drag.as_mut().unwrap().target = Some(drag_target);
source.drag.as_mut().unwrap().position_pending = true;
source
.send_target(
drag_target,
source.atoms.XdndEnter,
[
source.source_window,
5_u32 << 24,
source.atoms.TextUriList,
0,
0,
],
)
.unwrap();
assert!(matches!(
poll_x11_event(&target_connection),
Some(Event::ClientMessage(_))
));
let rejected_status = ClientMessageEvent::new(
32,
source.source_window,
target_atoms.XdndStatus,
[target, 1, 0, 0, 0],
);
target_connection
.send_event(
false,
source.source_window,
EventMask::NO_EVENT,
rejected_status,
)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
for _ in 0..100 {
source.poll_events().unwrap();
if source
.drag
.as_ref()
.is_some_and(|drag| !drag.position_pending)
{
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert!(!source.drag.as_ref().unwrap().accepted);
source.drag.as_mut().unwrap().position_pending = true;
let accepted_status = ClientMessageEvent::new(
32,
source.source_window,
target_atoms.XdndStatus,
[proxy, 1, 0, 0, target_atoms.XdndActionPrivate],
);
target_connection
.send_event(
false,
source.source_window,
EventMask::NO_EVENT,
accepted_status,
)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
for _ in 0..100 {
source.poll_events().unwrap();
if source.drag.as_ref().is_some_and(|drag| drag.accepted) {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert!(source.drag.as_ref().unwrap().accepted);
target_connection
.convert_selection(
target,
target_atoms.XdndSelection,
target_atoms.TextUriList,
property,
x11rb::CURRENT_TIME,
)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
let mut notified_before_drop = false;
for _ in 0..100 {
source.poll_events().unwrap();
if matches!(
target_connection.poll_for_event().unwrap(),
Some(Event::SelectionNotify(_))
) {
notified_before_drop = true;
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert!(notified_before_drop);
let before_drop = target_connection
.get_property(true, target, property, target_atoms.TextUriList, 0, 1024)
.unwrap()
.reply()
.unwrap();
assert_eq!(before_drop.value, b"file:///tmp/drag%20out.txt\r\n");
target_connection
.convert_selection(
target,
target_atoms.XdndSelection,
target_atoms.TARGETS,
property,
x11rb::CURRENT_TIME,
)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
let mut targets_notified = false;
for _ in 0..100 {
source.poll_events().unwrap();
if matches!(
target_connection.poll_for_event().unwrap(),
Some(Event::SelectionNotify(_))
) {
targets_notified = true;
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert!(targets_notified);
let offered_targets = target_connection
.get_property(true, target, property, AtomEnum::ATOM, 0, 16)
.unwrap()
.reply()
.unwrap();
let offered_targets = offered_targets.value32().unwrap().collect::<Vec<_>>();
assert!(offered_targets.contains(&target_atoms.TextUriList));
assert!(offered_targets.contains(&target_atoms.TARGETS));
assert!(offered_targets.contains(&target_atoms.TIMESTAMP));
let drag_timestamp = source.drag.as_ref().unwrap().timestamp;
target_connection
.convert_selection(
target,
target_atoms.XdndSelection,
target_atoms.TIMESTAMP,
property,
x11rb::CURRENT_TIME,
)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
let mut timestamp_notified = false;
for _ in 0..100 {
source.poll_events().unwrap();
if matches!(
target_connection.poll_for_event().unwrap(),
Some(Event::SelectionNotify(_))
) {
timestamp_notified = true;
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert!(timestamp_notified);
let timestamp = target_connection
.get_property(true, target, property, AtomEnum::INTEGER, 0, 1)
.unwrap()
.reply()
.unwrap();
assert_eq!(
timestamp.value32().and_then(|mut values| values.next()),
Some(drag_timestamp)
);
target_connection
.delete_property(proxy, target_atoms.XdndAware)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
assert_eq!(
source.update(&paths, false, true, false),
DragOutcome::Initiated(1)
);
assert!(matches!(
poll_x11_event(&target_connection),
Some(Event::ClientMessage(_))
));
assert_eq!(source.update(&[], false, false, false), DragOutcome::None);
assert!(source.drag.as_ref().is_some_and(|drag| drag.dropped));
target_connection
.convert_selection(
target,
target_atoms.XdndSelection,
target_atoms.TextUriList,
property,
x11rb::CURRENT_TIME,
)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
let mut notified = false;
for _ in 0..100 {
source.poll_events().unwrap();
if matches!(
target_connection.poll_for_event().unwrap(),
Some(Event::SelectionNotify(_))
) {
notified = true;
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert!(notified);
let property = target_connection
.get_property(true, target, property, target_atoms.TextUriList, 0, 1024)
.unwrap()
.reply()
.unwrap();
assert_eq!(property.value, b"file:///tmp/drag%20out.txt\r\n");
let finished = ClientMessageEvent::new(
32,
source.source_window,
target_atoms.XdndFinished,
[proxy, 1, target_atoms.XdndActionPrivate, 0, 0],
);
target_connection
.send_event(false, source.source_window, EventMask::NO_EVENT, finished)
.unwrap()
.check()
.unwrap();
target_connection.flush().unwrap();
for _ in 0..100 {
source.poll_events().unwrap();
if source.pending_outcome.is_some() {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(source.pending_outcome, Some(DragOutcome::Completed(1)));
let mut old_source = X11DragSource::new(x11rb::NONE).unwrap();
old_source.start(&paths).unwrap();
let mut newer_source = X11DragSource::new(x11rb::NONE).unwrap();
newer_source.start(&paths).unwrap();
for _ in 0..100 {
old_source.poll_events().unwrap();
if old_source.drag.is_none() {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert!(old_source.drag.is_none());
let newer_window = newer_source.source_window;
drop(old_source);
assert_eq!(
target_connection
.get_selection_owner(target_atoms.XdndSelection)
.unwrap()
.reply()
.unwrap()
.owner,
newer_window
);
newer_source.cancel();
let mut internally_consumed_source = X11DragSource::new(x11rb::NONE).unwrap();
internally_consumed_source.start(&paths).unwrap();
assert_eq!(
internally_consumed_source.update(&[], false, true, false),
DragOutcome::None
);
assert!(internally_consumed_source.drag.is_none());
for _ in 0..100 {
let owner = target_connection
.get_selection_owner(target_atoms.XdndSelection)
.unwrap()
.reply()
.unwrap()
.owner;
if owner == x11rb::NONE {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(
target_connection
.get_selection_owner(target_atoms.XdndSelection)
.unwrap()
.reply()
.unwrap()
.owner,
x11rb::NONE
);
target_connection.destroy_window(target).unwrap();
target_connection.destroy_window(proxy).unwrap();
target_connection.flush().unwrap();
}
}