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