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}