Skip to main content

teksilo_platform/x11/
connection.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! A private X11 connection, plus the atom cache and property helpers the
5//! title-bar probe and the XDND backend both need.
6//!
7//! # Why a private connection
8//!
9//! winit owns its own X11 connection and pumps it from its event loop. X11 has
10//! no equivalent of libwayland's multi-queue model — two connection objects
11//! wrapping one file descriptor would race on sequence numbers and
12//! reply/event demultiplexing — so we never touch winit's. We open our own
13//! [`RustConnection`] (pure Rust, no `libxcb` linkage, no `unsafe`) and use the
14//! raw window handle only to learn which XID to talk *about*.
15//!
16//! This is what makes the `XdndProxy` indirection necessary on the inbound
17//! path: XDND `ClientMessage`s are sent with an empty event mask, which the X
18//! protocol delivers only to the client that *created* the destination window
19//! — winit, not us. See `crate::external_dnd::x11`.
20
21use std::cell::RefCell;
22use std::collections::VecDeque;
23
24use x11rb::connection::Connection;
25use x11rb::errors::{ConnectError, ConnectionError, ReplyError};
26use x11rb::protocol::Event;
27use x11rb::protocol::xproto::{
28    Atom, AtomEnum, ConnectionExt as _, EventMask, PropMode, Timestamp, Window,
29};
30use x11rb::rust_connection::RustConnection;
31use x11rb::wrapper::ConnectionExt as _;
32
33/// Anything that can go wrong talking to the X server.
34#[derive(Debug, thiserror::Error)]
35pub enum X11Error {
36    #[error("could not connect to the X server: {0}")]
37    Connect(#[from] ConnectError),
38    #[error("X11 connection error: {0}")]
39    Connection(#[from] ConnectionError),
40    #[error("X11 request failed: {0}")]
41    Reply(#[from] ReplyError),
42    #[error("could not allocate an X11 resource id: {0}")]
43    IdAllocation(#[from] x11rb::errors::ReplyOrIdError),
44    #[error("timed out waiting for {0}")]
45    Timeout(&'static str),
46}
47
48/// Fire a void request and discard both failure modes.
49///
50/// Used for best-effort operations — teardown, replies to a peer that may have
51/// already exited — where a `BadWindow` from a window that vanished mid-drag is
52/// the expected case, not an error worth propagating.
53pub fn ignore_errors<C: x11rb::connection::RequestConnection>(
54    result: Result<x11rb::cookie::VoidCookie<'_, C>, ConnectionError>,
55) {
56    if let Ok(cookie) = result {
57        let _ = cookie.check();
58    }
59}
60
61/// The interned atoms used by the title-bar probe, the taskbar hint and the
62/// XDND backend.
63///
64/// Interned once per connection: an X round trip per atom per use would put a
65/// server round trip in the middle of every drag motion.
66#[derive(Debug, Clone)]
67pub struct Atoms {
68    // --- XDND protocol ---
69    pub xdnd_aware: Atom,
70    pub xdnd_proxy: Atom,
71    pub xdnd_selection: Atom,
72    pub xdnd_enter: Atom,
73    pub xdnd_position: Atom,
74    pub xdnd_status: Atom,
75    pub xdnd_leave: Atom,
76    pub xdnd_drop: Atom,
77    pub xdnd_finished: Atom,
78    pub xdnd_type_list: Atom,
79    pub xdnd_action_copy: Atom,
80    pub xdnd_action_move: Atom,
81    pub xdnd_action_link: Atom,
82    pub xdnd_action_private: Atom,
83    pub xdnd_action_list: Atom,
84
85    // --- selection transfer ---
86    pub incr: Atom,
87    pub targets: Atom,
88    pub timestamp: Atom,
89    /// The property we ask sources to write converted selection data into.
90    /// Namespaced so it cannot collide with a toolkit's own scratch property.
91    pub teksilo_transfer: Atom,
92    /// Scratch property for the server-timestamp round trip.
93    ///
94    /// Deliberately **not** [`Self::teksilo_transfer`]: the timestamp trick
95    /// appends to the property with type `STRING`, which would hit `BadMatch`
96    /// against an in-flight `INCR` chunk of a different type, and its
97    /// `PropertyNotify` would drive the INCR reader to consume the property out
98    /// from under the running transfer.
99    pub teksilo_timestamp: Atom,
100
101    // --- MIME types we exchange ---
102    pub text_uri_list: Atom,
103    pub text_plain_utf8: Atom,
104    pub text_plain: Atom,
105    pub utf8_string: Atom,
106    pub string: Atom,
107
108    // --- EWMH / Motif (custom title bar) ---
109    pub net_supported: Atom,
110    pub net_supporting_wm_check: Atom,
111    pub net_wm_moveresize: Atom,
112    pub motif_wm_hints: Atom,
113
114    // --- EWMH window state (taskbar, dialogs, minimised) ---
115    pub net_wm_state: Atom,
116    pub net_wm_state_skip_taskbar: Atom,
117    pub net_wm_state_modal: Atom,
118    pub net_wm_state_hidden: Atom,
119
120    // --- EWMH window type (dialogs) ---
121    pub net_wm_window_type: Atom,
122    pub net_wm_window_type_dialog: Atom,
123}
124
125impl Atoms {
126    fn intern(conn: &RustConnection) -> Result<Self, X11Error> {
127        // Fire every InternAtom request before reading any reply, so the whole
128        // set costs one round trip instead of ~30.
129        const NAMES: &[&[u8]] = &[
130            b"XdndAware",
131            b"XdndProxy",
132            b"XdndSelection",
133            b"XdndEnter",
134            b"XdndPosition",
135            b"XdndStatus",
136            b"XdndLeave",
137            b"XdndDrop",
138            b"XdndFinished",
139            b"XdndTypeList",
140            b"XdndActionCopy",
141            b"XdndActionMove",
142            b"XdndActionLink",
143            b"XdndActionPrivate",
144            b"XdndActionList",
145            b"INCR",
146            b"TARGETS",
147            b"TIMESTAMP",
148            b"_TEKSILO_DND_TRANSFER",
149            b"_TEKSILO_DND_TIMESTAMP",
150            b"text/uri-list",
151            b"text/plain;charset=utf-8",
152            b"text/plain",
153            b"UTF8_STRING",
154            b"STRING",
155            b"_NET_SUPPORTED",
156            b"_NET_SUPPORTING_WM_CHECK",
157            b"_NET_WM_MOVERESIZE",
158            b"_MOTIF_WM_HINTS",
159            b"_NET_WM_STATE",
160            b"_NET_WM_STATE_SKIP_TASKBAR",
161            b"_NET_WM_STATE_MODAL",
162            b"_NET_WM_STATE_HIDDEN",
163            b"_NET_WM_WINDOW_TYPE",
164            b"_NET_WM_WINDOW_TYPE_DIALOG",
165        ];
166
167        let cookies = NAMES
168            .iter()
169            .map(|name| conn.intern_atom(false, name))
170            .collect::<Result<Vec<_>, _>>()?;
171        let mut atoms = Vec::with_capacity(cookies.len());
172        for cookie in cookies {
173            atoms.push(cookie.reply()?.atom);
174        }
175        let mut next = atoms.into_iter();
176        let mut take = || next.next().expect("one atom per interned name");
177
178        Ok(Self {
179            xdnd_aware: take(),
180            xdnd_proxy: take(),
181            xdnd_selection: take(),
182            xdnd_enter: take(),
183            xdnd_position: take(),
184            xdnd_status: take(),
185            xdnd_leave: take(),
186            xdnd_drop: take(),
187            xdnd_finished: take(),
188            xdnd_type_list: take(),
189            xdnd_action_copy: take(),
190            xdnd_action_move: take(),
191            xdnd_action_link: take(),
192            xdnd_action_private: take(),
193            xdnd_action_list: take(),
194            incr: take(),
195            targets: take(),
196            timestamp: take(),
197            teksilo_transfer: take(),
198            teksilo_timestamp: take(),
199            text_uri_list: take(),
200            text_plain_utf8: take(),
201            text_plain: take(),
202            utf8_string: take(),
203            string: take(),
204            net_supported: take(),
205            net_supporting_wm_check: take(),
206            net_wm_moveresize: take(),
207            motif_wm_hints: take(),
208            net_wm_state: take(),
209            net_wm_state_skip_taskbar: take(),
210            net_wm_state_modal: take(),
211            net_wm_state_hidden: take(),
212            net_wm_window_type: take(),
213            net_wm_window_type_dialog: take(),
214        })
215    }
216
217    /// The MIME target atoms we accept from a drop source, most preferred
218    /// first. `text/uri-list` leads because it is the only one that yields
219    /// real file paths.
220    pub fn preferred_targets(&self) -> [Atom; 5] {
221        [
222            self.text_uri_list,
223            self.text_plain_utf8,
224            self.utf8_string,
225            self.text_plain,
226            self.string,
227        ]
228    }
229
230    /// Map a MIME type string to its atom, for the atoms we know natively.
231    /// Unknown types are interned on demand by the caller.
232    pub fn atom_for_mime(&self, mime: &str) -> Option<Atom> {
233        match mime {
234            "text/uri-list" => Some(self.text_uri_list),
235            "text/plain;charset=utf-8" => Some(self.text_plain_utf8),
236            "text/plain" => Some(self.text_plain),
237            "UTF8_STRING" => Some(self.utf8_string),
238            "STRING" => Some(self.string),
239            _ => None,
240        }
241    }
242}
243
244/// A property read back from the server, with its type and format preserved so
245/// the caller can validate what it got.
246#[derive(Debug, Clone)]
247pub struct PropertyValue {
248    pub type_: Atom,
249    pub format: u8,
250    pub bytes: Vec<u8>,
251}
252
253impl PropertyValue {
254    /// Reinterpret a 32-bit-format property as `u32`s. Returns an empty vector
255    /// for any other format, so a malformed property degrades to "absent"
256    /// rather than to garbage values.
257    pub fn as_u32s(&self) -> Vec<u32> {
258        if self.format != 32 {
259            return Vec::new();
260        }
261        self.bytes
262            .as_chunks::<4>()
263            .0
264            .iter()
265            .map(|chunk| u32::from_ne_bytes(*chunk))
266            .collect()
267    }
268
269    /// The single `u32` of a scalar 32-bit property, if that is what it is.
270    pub fn as_u32(&self) -> Option<u32> {
271        self.as_u32s().first().copied()
272    }
273}
274
275/// Our own connection to the X server, with atoms and an event pushback queue.
276///
277/// Not `Sync`: it holds a pushback buffer behind a `RefCell` and is used from a
278/// single thread (the per-window DnD thread, or transiently the main thread for
279/// the EWMH probe).
280pub struct X11Connection {
281    conn: RustConnection,
282    root: Window,
283    atoms: Atoms,
284    /// Events pulled off the wire while waiting for a specific reply, to be
285    /// handed back to the main loop in order. Without this, the
286    /// server-timestamp round trip below would silently swallow a
287    /// `ClientMessage` that arrived at the same moment.
288    pending: RefCell<VecDeque<Event>>,
289}
290
291impl X11Connection {
292    /// Open a fresh connection to the display named by `$DISPLAY`.
293    pub fn open() -> Result<Self, X11Error> {
294        let (conn, screen_num) = x11rb::connect(None)?;
295        let root = conn.setup().roots[screen_num].root;
296        let atoms = Atoms::intern(&conn)?;
297        Ok(Self {
298            conn,
299            root,
300            atoms,
301            pending: RefCell::new(VecDeque::new()),
302        })
303    }
304
305    pub fn conn(&self) -> &RustConnection {
306        &self.conn
307    }
308
309    pub fn root(&self) -> Window {
310        self.root
311    }
312
313    pub fn atoms(&self) -> &Atoms {
314        &self.atoms
315    }
316
317    pub fn flush(&self) -> Result<(), X11Error> {
318        self.conn.flush()?;
319        Ok(())
320    }
321
322    /// Read a whole property, following `bytes_after` until the server has no
323    /// more to give.
324    ///
325    /// A single `GetProperty` is capped by `long_length`; properties that
326    /// exceed it (a long `text/uri-list`, a big `_NET_SUPPORTED`) come back
327    /// truncated with `bytes_after > 0`. Reading only the first chunk is a
328    /// classic source of "the last few files vanished" bugs.
329    pub fn get_property_full(
330        &self,
331        window: Window,
332        property: Atom,
333        type_: Atom,
334    ) -> Result<Option<PropertyValue>, X11Error> {
335        // 4 KiB of 32-bit units per round trip.
336        const CHUNK_UNITS: u32 = 1024;
337
338        let mut offset = 0u32;
339        let mut out: Option<PropertyValue> = None;
340        loop {
341            let reply = self
342                .conn
343                .get_property(false, window, property, type_, offset, CHUNK_UNITS)?
344                .reply()?;
345            if reply.type_ == x11rb::NONE {
346                return Ok(out);
347            }
348            let more = reply.bytes_after > 0;
349            let format = reply.format;
350            let reply_type = reply.type_;
351            let len = reply.value.len();
352            match &mut out {
353                Some(acc) => acc.bytes.extend_from_slice(&reply.value),
354                None => {
355                    out = Some(PropertyValue {
356                        type_: reply_type,
357                        format,
358                        bytes: reply.value,
359                    })
360                }
361            }
362            if !more || len == 0 {
363                return Ok(out);
364            }
365            // `long_offset` counts 32-bit units, whatever the actual format.
366            offset += (len as u32).div_ceil(4);
367        }
368    }
369
370    /// Read a property and delete it in the same request — the ICCCM idiom for
371    /// selection transfers. Deleting is what tells an `INCR` sender we are
372    /// ready for the next chunk, so the read and the delete must be atomic.
373    pub fn get_property_and_delete(
374        &self,
375        window: Window,
376        property: Atom,
377    ) -> Result<Option<PropertyValue>, X11Error> {
378        // Ask for everything in one go: the server caps the reply at
379        // `long_length` units and reports the rest via `bytes_after`, but an
380        // INCR chunk is sized to fit a single request by construction.
381        let reply = self
382            .conn
383            .get_property(true, window, property, AtomEnum::ANY, 0, u32::MAX / 4)?
384            .reply()?;
385        if reply.type_ == x11rb::NONE {
386            return Ok(None);
387        }
388        Ok(Some(PropertyValue {
389            type_: reply.type_,
390            format: reply.format,
391            bytes: reply.value,
392        }))
393    }
394
395    /// Write a 32-bit property.
396    pub fn set_property32(
397        &self,
398        window: Window,
399        property: Atom,
400        type_: Atom,
401        data: &[u32],
402    ) -> Result<(), X11Error> {
403        self.conn
404            .change_property32(PropMode::REPLACE, window, property, type_, data)?
405            .check()?;
406        Ok(())
407    }
408
409    /// Write an 8-bit property.
410    pub fn set_property8(
411        &self,
412        window: Window,
413        property: Atom,
414        type_: Atom,
415        data: &[u8],
416    ) -> Result<(), X11Error> {
417        self.conn
418            .change_property8(PropMode::REPLACE, window, property, type_, data)?
419            .check()?;
420        Ok(())
421    }
422
423    /// Obtain a real server timestamp.
424    ///
425    /// The ICCCM-sanctioned trick: append **zero** bytes to a property on a
426    /// window we own, which changes nothing but still makes the server emit a
427    /// `PropertyNotify` stamped with the current time. `CurrentTime` is not a
428    /// substitute — XDND and selection ownership both need a comparable
429    /// timestamp so a stale request can be told from a fresh one.
430    ///
431    /// `window` must have `PROPERTY_CHANGE` selected. Events seen while
432    /// waiting are pushed back for the caller's loop, in order.
433    pub fn fetch_timestamp(&self, window: Window) -> Result<Timestamp, X11Error> {
434        self.conn
435            .change_property8(
436                PropMode::APPEND,
437                window,
438                self.atoms.teksilo_timestamp,
439                AtomEnum::STRING,
440                &[],
441            )?
442            .check()?;
443        self.conn.flush()?;
444
445        // Bounded so a server that never answers cannot wedge the thread.
446        for _ in 0..64 {
447            let event = self.conn.wait_for_event()?;
448            if let Event::PropertyNotify(ref notify) = event
449                && notify.window == window
450                && notify.atom == self.atoms.teksilo_timestamp
451            {
452                return Ok(notify.time);
453            }
454            self.pending.borrow_mut().push_back(event);
455        }
456        Err(X11Error::Timeout(
457            "a PropertyNotify carrying a server timestamp",
458        ))
459    }
460
461    /// Next event, draining anything [`Self::fetch_timestamp`] pushed back
462    /// first. Blocks.
463    pub fn next_event(&self) -> Result<Event, X11Error> {
464        if let Some(event) = self.pending.borrow_mut().pop_front() {
465            return Ok(event);
466        }
467        Ok(self.conn.wait_for_event()?)
468    }
469
470    /// Next event if one is already available, else `None`. Never blocks.
471    pub fn poll_event(&self) -> Result<Option<Event>, X11Error> {
472        if let Some(event) = self.pending.borrow_mut().pop_front() {
473            return Ok(Some(event));
474        }
475        Ok(self.conn.poll_for_event()?)
476    }
477
478    /// Send a 32-bit `ClientMessage`.
479    ///
480    /// XDND and `_NET_WM_MOVERESIZE` both specify `propagate = false`. The
481    /// event mask differs: XDND messages go to a specific client with an empty
482    /// mask (the X protocol then delivers to that window's *creator*), whereas
483    /// root-window messages must carry the substructure masks so the window
484    /// manager sees them.
485    pub fn send_client_message(
486        &self,
487        destination: Window,
488        window_field: Window,
489        type_: Atom,
490        data: [u32; 5],
491        mask: EventMask,
492    ) -> Result<(), X11Error> {
493        use x11rb::protocol::xproto::ClientMessageEvent;
494
495        let event = ClientMessageEvent::new(32, window_field, type_, data);
496        self.conn
497            .send_event(false, destination, mask, event)?
498            .check()?;
499        Ok(())
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn property_value_reads_32_bit_words() {
509        let value = PropertyValue {
510            type_: 1,
511            format: 32,
512            bytes: 5u32
513                .to_ne_bytes()
514                .into_iter()
515                .chain(7u32.to_ne_bytes())
516                .collect(),
517        };
518        assert_eq!(value.as_u32s(), vec![5, 7]);
519        assert_eq!(value.as_u32(), Some(5));
520    }
521
522    #[test]
523    fn property_value_rejects_a_mismatched_format() {
524        // A window id claimed to be 8-bit is a malformed property; reading it
525        // as words would produce plausible-looking garbage, so we read nothing.
526        let value = PropertyValue {
527            type_: 1,
528            format: 8,
529            bytes: vec![1, 2, 3, 4],
530        };
531        assert!(value.as_u32s().is_empty());
532        assert_eq!(value.as_u32(), None);
533    }
534
535    #[test]
536    fn property_value_ignores_a_trailing_partial_word() {
537        let value = PropertyValue {
538            type_: 1,
539            format: 32,
540            bytes: vec![1, 2, 3, 4, 5],
541        };
542        assert_eq!(value.as_u32s().len(), 1);
543    }
544}