Skip to main content

teksilo_platform/x11/
xdnd.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! XDND wire protocol — pure functions, no X connection.
5//!
6//! Everything here is deliberately connection-free so the protocol can be
7//! tested exhaustively in `cargo test` on any machine, with no display server.
8//! `crate::external_dnd::x11` holds the I/O that drives it.
9//!
10//! Reference: "Drag-and-Drop Protocol for the X Window System", version 5
11//! (<https://freedesktop.org/wiki/Specifications/XDND/>). Field layouts below
12//! were cross-checked against Paul Sheer's reference `xdnd.c`, GTK's
13//! `gdkdnd-x11.c`, and Qt's `qxcbdrag.cpp`, which agree on every field.
14//!
15//! # Coordinate packing
16//!
17//! `XdndPosition` and `XdndStatus` pack a root-window point into a single
18//! 32-bit word as `(x << 16) | (y & 0xFFFF)`. The halves are **signed 16-bit**,
19//! so a virtual root wider or taller than ±32767 px wraps. That is a limit of
20//! the wire format, not of this implementation; there is no protocol-level fix.
21
22/// Highest XDND version this implementation speaks, advertised in `XdndAware`.
23pub const XDND_VERSION: u32 = 5;
24
25/// Lowest source version we will talk to. Versions below 3 put `XdndAware` on
26/// subwindows and predate the timestamp fields; no live toolkit still emits
27/// them. GTK and Qt both draw the line here too.
28pub const MIN_SUPPORTED_VERSION: u32 = 3;
29
30/// `XdndStatus.data[1]` bit 0 — the target will accept a drop here.
31const STATUS_ACCEPT: u32 = 1;
32/// `XdndStatus.data[1]` bit 1 — send `XdndPosition` again even inside the
33/// rectangle in `data[2..3]`.
34const STATUS_WANT_POSITION: u32 = 2;
35
36/// `XdndEnter.data[1]` bit 0 — the source offers more than three types, so the
37/// full list must be read from its `XdndTypeList` property.
38const ENTER_MORE_TYPES: u32 = 1;
39
40// ============================================================
41// Coordinate packing
42// ============================================================
43
44/// Pack a root-relative point into the single 32-bit word XDND uses.
45pub fn pack_coords(x: i16, y: i16) -> u32 {
46    ((x as u16 as u32) << 16) | (y as u16 as u32)
47}
48
49/// Unpack the point packed by [`pack_coords`]. Both halves are signed, so a
50/// monitor placed left of / above the primary (negative root coordinates)
51/// round-trips correctly.
52pub fn unpack_coords(packed: u32) -> (i16, i16) {
53    (
54        ((packed >> 16) & 0xFFFF) as u16 as i16,
55        (packed & 0xFFFF) as u16 as i16,
56    )
57}
58
59// ============================================================
60// Version negotiation
61// ============================================================
62
63/// Resolve the protocol version to speak with a peer advertising `advertised`.
64///
65/// The effective version is `min(ours, theirs)`; a peer below
66/// [`MIN_SUPPORTED_VERSION`] is refused outright (`None`).
67pub fn negotiate_version(advertised: u32) -> Option<u32> {
68    if advertised < MIN_SUPPORTED_VERSION {
69        return None;
70    }
71    Some(advertised.min(XDND_VERSION))
72}
73
74/// Extract the version a source put in the high byte of `XdndEnter.data[1]`.
75pub fn enter_version(data1: u32) -> u32 {
76    data1 >> 24
77}
78
79// ============================================================
80// XdndProxy validation
81// ============================================================
82
83/// Resolve a target window's `XdndProxy`, applying the spec's crash-recovery
84/// rule.
85///
86/// `window_proxy` is `XdndProxy` read from the candidate window, `proxy_proxy`
87/// is `XdndProxy` read from the window that first value points at. The spec
88/// requires the proxy to point at *itself*; if it does not — or the proxy
89/// window is gone — the property is stale (left over from a crash) and must be
90/// ignored, falling back to the original window.
91///
92/// GTK (`xdnd_check_dest`) and Qt (`xdndProxy`) both implement exactly this.
93pub fn resolve_proxy(window: u32, window_proxy: Option<u32>, proxy_proxy: Option<u32>) -> u32 {
94    match window_proxy {
95        Some(proxy) if proxy != 0 && proxy_proxy == Some(proxy) => proxy,
96        _ => window,
97    }
98}
99
100// ============================================================
101// Message encoding / decoding
102// ============================================================
103
104/// A decoded `XdndEnter`.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct Enter {
107    /// The source window, which owns `XdndSelection` and receives our replies.
108    pub source: u32,
109    /// Negotiated protocol version, already clamped to ours.
110    pub version: u32,
111    /// The (up to three) type atoms carried inline. Empty slots are dropped.
112    pub types: Vec<u32>,
113    /// When set, `types` is only a prefix — read the source's `XdndTypeList`.
114    pub more_types: bool,
115}
116
117/// Decode `XdndEnter`. Returns `None` when the source advertises a version we
118/// refuse (see [`negotiate_version`]).
119pub fn decode_enter(data: [u32; 5]) -> Option<Enter> {
120    let version = negotiate_version(enter_version(data[1]))?;
121    let types = data[2..5]
122        .iter()
123        .copied()
124        .filter(|&atom| atom != 0)
125        .collect();
126    Some(Enter {
127        source: data[0],
128        version,
129        types,
130        more_types: data[1] & ENTER_MORE_TYPES != 0,
131    })
132}
133
134/// Encode `XdndEnter` for the source side. `types` may hold any number of
135/// atoms; only the first three travel inline and the "more types" bit is set
136/// automatically when there are more (the rest go in `XdndTypeList`).
137pub fn encode_enter(source: u32, version: u32, types: &[u32]) -> [u32; 5] {
138    let mut data = [0u32; 5];
139    data[0] = source;
140    data[1] = version << 24;
141    if types.len() > 3 {
142        data[1] |= ENTER_MORE_TYPES;
143    }
144    for (slot, atom) in data[2..5].iter_mut().zip(types.iter().copied()) {
145        *slot = atom;
146    }
147    data
148}
149
150/// A decoded `XdndPosition`.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct Position {
153    pub source: u32,
154    /// Pointer position in **root** coordinates, physical pixels.
155    pub root_x: i16,
156    pub root_y: i16,
157    /// Timestamp to quote in the eventual `ConvertSelection`.
158    pub time: u32,
159    /// The action the source proposes.
160    pub action: u32,
161}
162
163/// Decode `XdndPosition`.
164pub fn decode_position(data: [u32; 5]) -> Position {
165    let (root_x, root_y) = unpack_coords(data[2]);
166    Position {
167        source: data[0],
168        root_x,
169        root_y,
170        time: data[3],
171        action: data[4],
172    }
173}
174
175/// Encode `XdndPosition` for the source side.
176pub fn encode_position(source: u32, root_x: i16, root_y: i16, time: u32, action: u32) -> [u32; 5] {
177    [source, 0, pack_coords(root_x, root_y), time, action]
178}
179
180/// Encode `XdndStatus` for the target side.
181///
182/// The "no-resend rectangle" is always sent **empty**, which the spec defines
183/// as "send another message when the mouse moves". A drop target that hit-tests
184/// per-widget cannot describe its accept region as one rectangle, so
185/// suppressing position updates would make hover feedback wrong; the cost is
186/// one small message per motion event.
187pub fn encode_status(target: u32, accept: bool, action: u32) -> [u32; 5] {
188    let mut flags = STATUS_WANT_POSITION;
189    if accept {
190        flags |= STATUS_ACCEPT;
191    }
192    [target, flags, 0, 0, if accept { action } else { 0 }]
193}
194
195/// A decoded `XdndStatus` (source side).
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Status {
198    pub target: u32,
199    pub accepted: bool,
200    /// The action the target would perform. Meaningful from version 2 on;
201    /// zero (`None` atom) when the target is not accepting.
202    pub action: u32,
203}
204
205/// Decode `XdndStatus`.
206pub fn decode_status(data: [u32; 5]) -> Status {
207    Status {
208        target: data[0],
209        accepted: data[1] & STATUS_ACCEPT != 0,
210        action: data[4],
211    }
212}
213
214/// Encode `XdndLeave`.
215pub fn encode_leave(source: u32) -> [u32; 5] {
216    [source, 0, 0, 0, 0]
217}
218
219/// Encode `XdndDrop`. `time` must be a real server timestamp — never
220/// `CurrentTime` — so the source can reject a stale conversion request.
221pub fn encode_drop(source: u32, time: u32) -> [u32; 5] {
222    [source, 0, time, 0, 0]
223}
224
225/// A decoded `XdndDrop`.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct Drop {
228    pub source: u32,
229    /// Timestamp to quote in `ConvertSelection`.
230    pub time: u32,
231}
232
233/// Decode `XdndDrop`.
234pub fn decode_drop(data: [u32; 5]) -> Drop {
235    Drop {
236        source: data[0],
237        time: data[2],
238    }
239}
240
241/// Encode `XdndFinished` for the target side.
242///
243/// The accepted flag and performed action in `data[1]`/`data[2]` are version-5
244/// additions. Against an older source we must send zeroes there: a v3/v4 source
245/// treats the drop as unconditionally accepted and reading our flags would be
246/// out of contract.
247pub fn encode_finished(target: u32, version: u32, accepted: bool, action: u32) -> [u32; 5] {
248    if version < 5 {
249        return [target, 0, 0, 0, 0];
250    }
251    [
252        target,
253        u32::from(accepted),
254        if accepted { action } else { 0 },
255        0,
256        0,
257    ]
258}
259
260/// A decoded `XdndFinished` (source side).
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct Finished {
263    pub target: u32,
264    /// Whether the target accepted **and** performed the action.
265    ///
266    /// Only version 5 reports this. Against an older target the bit is absent
267    /// and the spec says to behave as v2–v4 always did — assume success — so
268    /// `negotiated_version` decides how `data[1]` is read.
269    pub accepted: bool,
270    pub action: u32,
271}
272
273/// Decode `XdndFinished`, honouring the negotiated version.
274pub fn decode_finished(data: [u32; 5], negotiated_version: u32) -> Finished {
275    if negotiated_version < 5 {
276        return Finished {
277            target: data[0],
278            accepted: true,
279            action: 0,
280        };
281    }
282    Finished {
283        target: data[0],
284        accepted: data[1] & 1 != 0,
285        action: data[2],
286    }
287}
288
289// ============================================================
290// Type selection
291// ============================================================
292
293/// Pick the best type atom to request from `offered`, given our `preferred`
294/// list in descending priority. Returns `None` when nothing matches.
295pub fn choose_type(offered: &[u32], preferred: &[u32]) -> Option<u32> {
296    preferred
297        .iter()
298        .copied()
299        .find(|candidate| offered.contains(candidate))
300}
301
302// ============================================================
303// INCR assembly
304// ============================================================
305
306/// Reassembles an ICCCM `INCR` selection transfer.
307///
308/// The sender writes the payload in chunks, each appearing as a property
309/// change on our requestor window; we delete the property after each read,
310/// which is the signal for the next chunk. A **zero-length** chunk terminates
311/// the transfer.
312#[derive(Debug, Default)]
313pub struct IncrAssembler {
314    buffer: Vec<u8>,
315    complete: bool,
316}
317
318impl IncrAssembler {
319    /// Start an assembly. `expected` is the sender's size hint from the `INCR`
320    /// property; it is advisory (senders may over- or under-estimate) and used
321    /// only to pre-allocate.
322    pub fn new(expected: usize) -> Self {
323        Self {
324            // Cap the hint so a bogus/hostile size can't request a huge
325            // allocation up front; the buffer still grows as data arrives.
326            buffer: Vec::with_capacity(expected.min(1 << 20)),
327            complete: false,
328        }
329    }
330
331    /// Feed one chunk. Returns `true` once the terminating empty chunk has
332    /// arrived and [`Self::finish`] is meaningful.
333    pub fn push(&mut self, chunk: &[u8]) -> bool {
334        if chunk.is_empty() {
335            self.complete = true;
336        } else {
337            self.buffer.extend_from_slice(chunk);
338        }
339        self.complete
340    }
341
342    /// Whether the terminating chunk has been seen.
343    pub fn is_complete(&self) -> bool {
344        self.complete
345    }
346
347    /// Take the assembled payload.
348    pub fn finish(self) -> Vec<u8> {
349        self.buffer
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    // ---------- coordinate packing ----------
358
359    #[test]
360    fn coords_round_trip_including_negatives() {
361        for (x, y) in [
362            (0, 0),
363            (1920, 1080),
364            (-1, -1),
365            (-1920, 200),
366            (i16::MIN, i16::MAX),
367        ] {
368            assert_eq!(unpack_coords(pack_coords(x, y)), (x, y), "({x}, {y})");
369        }
370    }
371
372    #[test]
373    fn coords_pack_x_into_the_high_half() {
374        // The spec is explicit about which half is which; swapping them is the
375        // classic XDND bug (drops land on the wrong widget along one axis).
376        assert_eq!(pack_coords(0x1234, 0x5678), 0x1234_5678);
377    }
378
379    // ---------- version negotiation ----------
380
381    #[test]
382    fn negotiation_clamps_to_our_version() {
383        assert_eq!(negotiate_version(5), Some(5));
384        assert_eq!(
385            negotiate_version(9),
386            Some(5),
387            "a newer peer must be clamped down"
388        );
389        assert_eq!(negotiate_version(3), Some(3));
390        assert_eq!(negotiate_version(4), Some(4));
391    }
392
393    #[test]
394    fn negotiation_refuses_prehistoric_versions() {
395        assert_eq!(negotiate_version(2), None);
396        assert_eq!(negotiate_version(0), None);
397    }
398
399    // ---------- XdndProxy ----------
400
401    #[test]
402    fn proxy_is_honoured_when_it_points_at_itself() {
403        assert_eq!(resolve_proxy(0x100, Some(0x200), Some(0x200)), 0x200);
404    }
405
406    #[test]
407    fn stale_proxy_falls_back_to_the_original_window() {
408        // Left over after a crash: the proxy window is gone, so reading its own
409        // XdndProxy yields nothing. The spec says ignore the property.
410        assert_eq!(resolve_proxy(0x100, Some(0x200), None), 0x100);
411        // Points somewhere else entirely — equally untrustworthy.
412        assert_eq!(resolve_proxy(0x100, Some(0x200), Some(0x300)), 0x100);
413        // Zero is not a window.
414        assert_eq!(resolve_proxy(0x100, Some(0), Some(0)), 0x100);
415    }
416
417    #[test]
418    fn no_proxy_property_means_use_the_window() {
419        assert_eq!(resolve_proxy(0x100, None, None), 0x100);
420    }
421
422    // ---------- Enter ----------
423
424    #[test]
425    fn enter_round_trips_with_three_types() {
426        let encoded = encode_enter(0xAB, 5, &[10, 20, 30]);
427        let decoded = decode_enter(encoded).expect("v5 is supported");
428        assert_eq!(
429            decoded,
430            Enter {
431                source: 0xAB,
432                version: 5,
433                types: vec![10, 20, 30],
434                more_types: false
435            }
436        );
437    }
438
439    #[test]
440    fn enter_sets_the_more_types_bit_past_three() {
441        let encoded = encode_enter(1, 5, &[10, 20, 30, 40]);
442        let decoded = decode_enter(encoded).unwrap();
443        assert!(decoded.more_types, "a fourth type must flag XdndTypeList");
444        assert_eq!(decoded.types, vec![10, 20, 30], "only three travel inline");
445    }
446
447    #[test]
448    fn enter_drops_empty_type_slots() {
449        let decoded = decode_enter(encode_enter(1, 5, &[10])).unwrap();
450        assert_eq!(decoded.types, vec![10], "None atoms are not types");
451    }
452
453    #[test]
454    fn enter_from_an_ancient_source_is_refused() {
455        let mut data = encode_enter(1, 5, &[10]);
456        data[1] = (2 << 24) | (data[1] & 0x00FF_FFFF); // claim version 2
457        assert!(decode_enter(data).is_none());
458    }
459
460    #[test]
461    fn enter_from_a_newer_source_is_clamped() {
462        let mut data = encode_enter(1, 5, &[10]);
463        data[1] = (7 << 24) | (data[1] & 0x00FF_FFFF);
464        assert_eq!(decode_enter(data).unwrap().version, 5);
465    }
466
467    // ---------- Position / Status ----------
468
469    #[test]
470    fn position_round_trips() {
471        let decoded = decode_position(encode_position(0xAB, -300, 900, 12345, 77));
472        assert_eq!(
473            decoded,
474            Position {
475                source: 0xAB,
476                root_x: -300,
477                root_y: 900,
478                time: 12345,
479                action: 77
480            }
481        );
482    }
483
484    #[test]
485    fn status_always_requests_further_position_messages() {
486        // An empty rectangle plus the want-position bit: per-widget hit-testing
487        // needs every motion, and one rectangle cannot describe it.
488        let data = encode_status(0x10, true, 42);
489        assert_eq!(data[2], 0, "rectangle origin must be empty");
490        assert_eq!(data[3], 0, "rectangle extent must be empty");
491        assert_ne!(data[1] & STATUS_WANT_POSITION, 0);
492    }
493
494    #[test]
495    fn status_round_trips_accept_and_reject() {
496        let accepted = decode_status(encode_status(0x10, true, 42));
497        assert_eq!(
498            accepted,
499            Status {
500                target: 0x10,
501                accepted: true,
502                action: 42
503            }
504        );
505
506        let rejected = decode_status(encode_status(0x10, false, 42));
507        assert!(!rejected.accepted);
508        assert_eq!(
509            rejected.action, 0,
510            "a rejecting target must advertise no action"
511        );
512    }
513
514    // ---------- Drop / Finished ----------
515
516    #[test]
517    fn drop_round_trips_its_timestamp() {
518        assert_eq!(
519            decode_drop(encode_drop(0xAB, 999)),
520            Drop {
521                source: 0xAB,
522                time: 999
523            }
524        );
525    }
526
527    #[test]
528    fn finished_reports_the_action_on_v5() {
529        let decoded = decode_finished(encode_finished(0x10, 5, true, 42), 5);
530        assert_eq!(
531            decoded,
532            Finished {
533                target: 0x10,
534                accepted: true,
535                action: 42
536            }
537        );
538    }
539
540    #[test]
541    fn finished_omits_v5_fields_for_older_peers() {
542        // Writing our accept bit into a v3 exchange would be out of contract.
543        let data = encode_finished(0x10, 3, true, 42);
544        assert_eq!(data, [0x10, 0, 0, 0, 0]);
545    }
546
547    #[test]
548    fn finished_from_an_older_peer_is_read_as_success() {
549        // v2-v4 carry no accepted bit; the spec says assume the drop worked.
550        let decoded = decode_finished([0x10, 0, 0, 0, 0], 3);
551        assert!(
552            decoded.accepted,
553            "pre-v5 has no bit to clear, so it means success"
554        );
555    }
556
557    #[test]
558    fn finished_rejection_round_trips_on_v5() {
559        let decoded = decode_finished(encode_finished(0x10, 5, false, 42), 5);
560        assert!(!decoded.accepted);
561        assert_eq!(decoded.action, 0);
562    }
563
564    // ---------- type selection ----------
565
566    #[test]
567    fn type_selection_follows_our_preference_not_theirs() {
568        // The source lists types in arbitrary order; our ranking decides.
569        assert_eq!(choose_type(&[30, 20, 10], &[10, 20, 30]), Some(10));
570        assert_eq!(choose_type(&[30, 20], &[10, 20, 30]), Some(20));
571    }
572
573    #[test]
574    fn type_selection_returns_none_without_overlap() {
575        assert_eq!(choose_type(&[99], &[10, 20]), None);
576        assert_eq!(choose_type(&[], &[10]), None);
577    }
578
579    // `text/uri-list` encoding and decoding are not duplicated here: they are
580    // shared with every other backend via `ExternalDropData::from_uri_list` /
581    // `OutboundDragData::to_uri_list` in teksilo-core, and tested there.
582
583    // ---------- INCR ----------
584
585    #[test]
586    fn incr_assembles_chunks_until_the_empty_terminator() {
587        let mut incr = IncrAssembler::new(6);
588        assert!(!incr.push(b"abc"));
589        assert!(!incr.push(b"def"));
590        assert!(incr.push(b""), "an empty chunk terminates the transfer");
591        assert!(incr.is_complete());
592        assert_eq!(incr.finish(), b"abcdef".to_vec());
593    }
594
595    #[test]
596    fn incr_handles_an_immediately_empty_payload() {
597        let mut incr = IncrAssembler::new(0);
598        assert!(incr.push(b""));
599        assert_eq!(incr.finish(), Vec::<u8>::new());
600    }
601
602    #[test]
603    fn incr_ignores_a_wild_size_hint() {
604        // A bogus hint must not translate into a huge up-front allocation.
605        let mut incr = IncrAssembler::new(usize::MAX);
606        incr.push(b"x");
607        incr.push(b"");
608        assert_eq!(incr.finish(), b"x".to_vec());
609    }
610}