Skip to main content

pixelflux/wayland/
dcclient.rs

1//! Data-control CLIENT: the native clipboard bridge to a NESTED app compositor
2//! (labwc/kwin running under pixelflux).
3//!
4//! Apps in a nested session use the inner compositor's selection, which
5//! pixelflux's own clipboard machinery never sees. selkies bridges it over the
6//! ScreenCapture ABI backed by this module instead of forking wl-copy/wl-paste
7//! per operation: one-shot [`list_types`]/[`read`], a [`write`] that returns with
8//! the selection taken (a detached thread then serves paste requests until
9//! another client takes the selection), and [`watch`] (a thread reporting
10//! selection changes to a Python callback). The compositor is spoken to through
11//! `ext_data_control_manager_v1` (KWin since Plasma 6.3, wlroots since 0.19) or
12//! `zwlr_data_control_manager_v1` (earlier wlroots and KWin), preferring the
13//! standardized ext form when both are advertised. Every compositor round-trip
14//! is deadline-bounded via [`wlclient::bounded_roundtrip`].
15
16use std::collections::HashMap;
17use std::os::fd::AsFd;
18use std::os::unix::net::UnixStream;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use pyo3::{Py, PyAny, Python};
24use wayland_client::backend::ObjectId;
25use wayland_client::protocol::{wl_registry, wl_seat};
26use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, delegate_noop};
27use wayland_protocols::ext::data_control::v1::client::{
28    ext_data_control_device_v1::{self, ExtDataControlDeviceV1},
29    ext_data_control_manager_v1::ExtDataControlManagerV1,
30    ext_data_control_offer_v1::{self, ExtDataControlOfferV1},
31    ext_data_control_source_v1::{self, ExtDataControlSourceV1},
32};
33use wayland_protocols_wlr::data_control::v1::client::{
34    zwlr_data_control_device_v1::{self, ZwlrDataControlDeviceV1},
35    zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
36    zwlr_data_control_offer_v1::{self, ZwlrDataControlOfferV1},
37    zwlr_data_control_source_v1::{self, ZwlrDataControlSourceV1},
38};
39
40use crate::wayland::wlclient::{
41    IO_TIMEOUT, SyncState, bounded_roundtrip, impl_sync_callback, pipe_cloexec, read_fd_to_end,
42    wait_readable, write_fd_all,
43};
44
45/// How often a background thread wakes from its socket poll to check its stop
46/// flag, bounding unwatch/shutdown latency.
47const STOP_POLL: Duration = Duration::from_millis(500);
48
49/// The two data-control protocol families behind one shape: both define the
50/// same manager/device/source/offer object graph with identical semantics, so
51/// each wrapper dispatches the one request set to whichever family the
52/// compositor advertised. Objects never mix families: everything descends from
53/// the single manager chosen at bind time.
54enum DcManager {
55    Ext(ExtDataControlManagerV1),
56    Wlr(ZwlrDataControlManagerV1),
57}
58
59enum DcDevice {
60    Ext(ExtDataControlDeviceV1),
61    Wlr(ZwlrDataControlDeviceV1),
62}
63
64enum DcSource {
65    Ext(ExtDataControlSourceV1),
66    Wlr(ZwlrDataControlSourceV1),
67}
68
69#[derive(Clone)]
70enum DcOffer {
71    Ext(ExtDataControlOfferV1),
72    Wlr(ZwlrDataControlOfferV1),
73}
74
75impl DcManager {
76    fn get_data_device(&self, seat: &wl_seat::WlSeat, qh: &QueueHandle<DcState>) -> DcDevice {
77        match self {
78            DcManager::Ext(m) => DcDevice::Ext(m.get_data_device(seat, qh, ())),
79            DcManager::Wlr(m) => DcDevice::Wlr(m.get_data_device(seat, qh, ())),
80        }
81    }
82
83    fn create_data_source(&self, qh: &QueueHandle<DcState>) -> DcSource {
84        match self {
85            DcManager::Ext(m) => DcSource::Ext(m.create_data_source(qh, ())),
86            DcManager::Wlr(m) => DcSource::Wlr(m.create_data_source(qh, ())),
87        }
88    }
89}
90
91impl DcDevice {
92    fn set_selection(&self, source: Option<&DcSource>) {
93        match self {
94            DcDevice::Ext(d) => d.set_selection(source.map(|s| match s {
95                DcSource::Ext(s) => s,
96                DcSource::Wlr(_) => unreachable!("mixed data-control families"),
97            })),
98            DcDevice::Wlr(d) => d.set_selection(source.map(|s| match s {
99                DcSource::Wlr(s) => s,
100                DcSource::Ext(_) => unreachable!("mixed data-control families"),
101            })),
102        }
103    }
104
105    fn destroy(&self) {
106        match self {
107            DcDevice::Ext(d) => d.destroy(),
108            DcDevice::Wlr(d) => d.destroy(),
109        }
110    }
111}
112
113impl DcSource {
114    fn offer(&self, mime: String) {
115        match self {
116            DcSource::Ext(s) => s.offer(mime),
117            DcSource::Wlr(s) => s.offer(mime),
118        }
119    }
120
121    fn destroy(&self) {
122        match self {
123            DcSource::Ext(s) => s.destroy(),
124            DcSource::Wlr(s) => s.destroy(),
125        }
126    }
127}
128
129impl DcOffer {
130    fn id(&self) -> ObjectId {
131        match self {
132            DcOffer::Ext(o) => o.id(),
133            DcOffer::Wlr(o) => o.id(),
134        }
135    }
136
137    fn receive(&self, mime: String, fd: std::os::fd::BorrowedFd<'_>) {
138        match self {
139            DcOffer::Ext(o) => o.receive(mime, fd),
140            DcOffer::Wlr(o) => o.receive(mime, fd),
141        }
142    }
143
144    fn destroy(&self) {
145        match self {
146            DcOffer::Ext(o) => o.destroy(),
147            DcOffer::Wlr(o) => o.destroy(),
148        }
149    }
150}
151
152#[derive(Default)]
153struct DcState {
154    seat: Option<wl_seat::WlSeat>,
155    manager_ext: Option<ExtDataControlManagerV1>,
156    manager_wlr: Option<ZwlrDataControlManagerV1>,
157    /// Advertised mimes per live offer.
158    offer_mimes: HashMap<ObjectId, Vec<String>>,
159    selection: Option<DcOffer>,
160    /// Set on every `selection` event (the watch loop's change edge).
161    selection_changed: bool,
162    /// Compositor told this device it is done (seat gone).
163    finished: bool,
164    /// The write path's source lost the selection to another client.
165    canceled: bool,
166    /// Mime -> bytes served by the write path's source.
167    serve: Vec<(String, Vec<u8>)>,
168    sync_done: bool,
169}
170
171impl SyncState for DcState {
172    fn sync_done_mut(&mut self) -> &mut bool {
173        &mut self.sync_done
174    }
175}
176impl_sync_callback!(DcState);
177
178impl DcState {
179    /// The bound manager, preferring ext over wlr; the loser is released.
180    fn take_manager(&mut self) -> Result<DcManager, String> {
181        if let Some(m) = self.manager_ext.take() {
182            if let Some(w) = self.manager_wlr.take() {
183                w.destroy();
184            }
185            return Ok(DcManager::Ext(m));
186        }
187        self.manager_wlr.take().map(DcManager::Wlr).ok_or_else(|| {
188            "app compositor advertises neither ext_data_control_manager_v1 \
189             nor zwlr_data_control_manager_v1"
190                .to_string()
191        })
192    }
193
194    fn on_data_offer(&mut self, id: ObjectId) {
195        self.offer_mimes.entry(id).or_default();
196    }
197
198    fn on_offer_mime(&mut self, id: ObjectId, mime_type: String) {
199        self.offer_mimes.entry(id).or_default().push(mime_type);
200    }
201
202    fn on_selection(&mut self, offer: Option<DcOffer>) {
203        // Replaced offers are dead objects; drop their proxy and mimes so a
204        // long-lived watch connection doesn't accumulate them.
205        if let Some(old) = self.selection.take()
206            && offer.as_ref().map(|o| o.id()) != Some(old.id())
207        {
208            self.offer_mimes.remove(&old.id());
209            old.destroy();
210        }
211        self.selection = offer;
212        self.selection_changed = true;
213    }
214
215    /// This bridge carries the regular selection only, but ext v1 still
216    /// introduces an offer per primary-selection change; release it (unless the
217    /// compositor reused the regular selection's object) so it can't pile up.
218    fn on_primary_selection(&mut self, offer: Option<DcOffer>) {
219        if let Some(o) = offer
220            && self.selection.as_ref().map(|s| s.id()) != Some(o.id())
221        {
222            self.offer_mimes.remove(&o.id());
223            o.destroy();
224        }
225    }
226
227    fn on_send(&mut self, mime_type: &str, fd: std::os::fd::OwnedFd) {
228        if let Some((_, data)) = self.serve.iter().find(|(m, _)| m == mime_type) {
229            let _ = write_fd_all(&fd, data, IO_TIMEOUT);
230        }
231        // fd drops here, closing the pipe so the paster sees EOF.
232    }
233}
234
235impl Dispatch<wl_registry::WlRegistry, ()> for DcState {
236    fn event(
237        state: &mut Self,
238        registry: &wl_registry::WlRegistry,
239        event: wl_registry::Event,
240        _: &(),
241        _: &Connection,
242        qh: &QueueHandle<Self>,
243    ) {
244        if let wl_registry::Event::Global {
245            name, interface, ..
246        } = event
247        {
248            // Version 1 of each suffices: the seat is only an argument, and v1
249            // data-control carries the regular selection this bridge needs.
250            match interface.as_str() {
251                "wl_seat" if state.seat.is_none() => {
252                    state.seat = Some(registry.bind(name, 1, qh, ()));
253                }
254                "ext_data_control_manager_v1" if state.manager_ext.is_none() => {
255                    state.manager_ext = Some(registry.bind(name, 1, qh, ()));
256                }
257                "zwlr_data_control_manager_v1" if state.manager_wlr.is_none() => {
258                    state.manager_wlr = Some(registry.bind(name, 1, qh, ()));
259                }
260                _ => {}
261            }
262        }
263    }
264}
265
266delegate_noop!(DcState: ignore wl_seat::WlSeat);
267delegate_noop!(DcState: ExtDataControlManagerV1);
268delegate_noop!(DcState: ZwlrDataControlManagerV1);
269
270impl Dispatch<ExtDataControlOfferV1, ()> for DcState {
271    fn event(
272        state: &mut Self,
273        offer: &ExtDataControlOfferV1,
274        event: ext_data_control_offer_v1::Event,
275        _: &(),
276        _: &Connection,
277        _: &QueueHandle<Self>,
278    ) {
279        if let ext_data_control_offer_v1::Event::Offer { mime_type } = event {
280            state.on_offer_mime(offer.id(), mime_type);
281        }
282    }
283}
284
285impl Dispatch<ZwlrDataControlOfferV1, ()> for DcState {
286    fn event(
287        state: &mut Self,
288        offer: &ZwlrDataControlOfferV1,
289        event: zwlr_data_control_offer_v1::Event,
290        _: &(),
291        _: &Connection,
292        _: &QueueHandle<Self>,
293    ) {
294        if let zwlr_data_control_offer_v1::Event::Offer { mime_type } = event {
295            state.on_offer_mime(offer.id(), mime_type);
296        }
297    }
298}
299
300impl Dispatch<ExtDataControlDeviceV1, ()> for DcState {
301    fn event(
302        state: &mut Self,
303        _: &ExtDataControlDeviceV1,
304        event: ext_data_control_device_v1::Event,
305        _: &(),
306        _: &Connection,
307        _: &QueueHandle<Self>,
308    ) {
309        match event {
310            ext_data_control_device_v1::Event::DataOffer { id } => {
311                state.on_data_offer(id.id());
312            }
313            ext_data_control_device_v1::Event::Selection { id } => {
314                state.on_selection(id.map(DcOffer::Ext));
315            }
316            ext_data_control_device_v1::Event::PrimarySelection { id } => {
317                state.on_primary_selection(id.map(DcOffer::Ext));
318            }
319            ext_data_control_device_v1::Event::Finished => {
320                state.finished = true;
321            }
322            _ => {}
323        }
324    }
325
326    wayland_client::event_created_child!(DcState, ExtDataControlDeviceV1, [
327        ext_data_control_device_v1::EVT_DATA_OFFER_OPCODE => (ExtDataControlOfferV1, ()),
328    ]);
329}
330
331impl Dispatch<ZwlrDataControlDeviceV1, ()> for DcState {
332    fn event(
333        state: &mut Self,
334        _: &ZwlrDataControlDeviceV1,
335        event: zwlr_data_control_device_v1::Event,
336        _: &(),
337        _: &Connection,
338        _: &QueueHandle<Self>,
339    ) {
340        match event {
341            zwlr_data_control_device_v1::Event::DataOffer { id } => {
342                state.on_data_offer(id.id());
343            }
344            zwlr_data_control_device_v1::Event::Selection { id } => {
345                state.on_selection(id.map(DcOffer::Wlr));
346            }
347            zwlr_data_control_device_v1::Event::Finished => {
348                state.finished = true;
349            }
350            _ => {}
351        }
352    }
353
354    wayland_client::event_created_child!(DcState, ZwlrDataControlDeviceV1, [
355        zwlr_data_control_device_v1::EVT_DATA_OFFER_OPCODE => (ZwlrDataControlOfferV1, ()),
356    ]);
357}
358
359impl Dispatch<ExtDataControlSourceV1, ()> for DcState {
360    fn event(
361        state: &mut Self,
362        _: &ExtDataControlSourceV1,
363        event: ext_data_control_source_v1::Event,
364        _: &(),
365        _: &Connection,
366        _: &QueueHandle<Self>,
367    ) {
368        match event {
369            ext_data_control_source_v1::Event::Send { mime_type, fd } => {
370                state.on_send(&mime_type, fd);
371            }
372            ext_data_control_source_v1::Event::Cancelled => {
373                state.canceled = true;
374            }
375            _ => {}
376        }
377    }
378}
379
380impl Dispatch<ZwlrDataControlSourceV1, ()> for DcState {
381    fn event(
382        state: &mut Self,
383        _: &ZwlrDataControlSourceV1,
384        event: zwlr_data_control_source_v1::Event,
385        _: &(),
386        _: &Connection,
387        _: &QueueHandle<Self>,
388    ) {
389        match event {
390            zwlr_data_control_source_v1::Event::Send { mime_type, fd } => {
391                state.on_send(&mime_type, fd);
392            }
393            zwlr_data_control_source_v1::Event::Cancelled => {
394                state.canceled = true;
395            }
396            _ => {}
397        }
398    }
399}
400
401/// Connect to `socket_path` and return (connection, queue, state, device) with
402/// the current selection already delivered.
403fn open_device(
404    socket_path: &str,
405) -> Result<
406    (
407        Connection,
408        wayland_client::EventQueue<DcState>,
409        DcState,
410        DcDevice,
411    ),
412    String,
413> {
414    let stream =
415        UnixStream::connect(socket_path).map_err(|e| format!("connect {socket_path}: {e}"))?;
416    let conn = Connection::from_socket(stream).map_err(|e| format!("wayland setup: {e}"))?;
417    let mut queue = conn.new_event_queue();
418    let qh = queue.handle();
419    let _registry = conn.display().get_registry(&qh, ());
420    let mut state = DcState::default();
421    bounded_roundtrip(&conn, &mut queue, &mut state)?;
422    let seat = state
423        .seat
424        .clone()
425        .ok_or("app compositor advertises no wl_seat")?;
426    let manager = state.take_manager()?;
427    let device = manager.get_data_device(&seat, &qh);
428    bounded_roundtrip(&conn, &mut queue, &mut state)?;
429    Ok((conn, queue, state, device))
430}
431
432/// Mimes offered by the current selection (empty when nothing is copied).
433pub(crate) fn list_types(socket_path: &str) -> Result<Vec<String>, String> {
434    let (_conn, _queue, state, device) = open_device(socket_path)?;
435    let out = state
436        .selection
437        .as_ref()
438        .and_then(|o| state.offer_mimes.get(&o.id()).cloned())
439        .unwrap_or_default();
440    device.destroy();
441    Ok(out)
442}
443
444/// The current selection's payload for `mime`, or None when there is no
445/// selection or it does not offer that mime.
446pub(crate) fn read(socket_path: &str, mime: &str) -> Result<Option<Vec<u8>>, String> {
447    let (conn, mut queue, mut state, device) = open_device(socket_path)?;
448    let Some(offer) = state.selection.clone() else {
449        device.destroy();
450        return Ok(None);
451    };
452    let offered = state
453        .offer_mimes
454        .get(&offer.id())
455        .is_some_and(|m| m.iter().any(|x| x == mime));
456    if !offered {
457        device.destroy();
458        return Ok(None);
459    }
460    let (rd, wr) = pipe_cloexec()?;
461    offer.receive(mime.to_string(), wr.as_fd());
462    queue.flush().map_err(|e| format!("flush: {e}"))?;
463    drop(wr);
464    // The source app writes into the pipe as it pleases; dispatch is not needed
465    // for the bytes, only the fd read.
466    let data = read_fd_to_end(&rd, IO_TIMEOUT)?;
467    let _ = bounded_roundtrip(&conn, &mut queue, &mut state);
468    device.destroy();
469    Ok(Some(data))
470}
471
472/// Take the selection, serving `entries` (mime, bytes) to every paster from a
473/// detached thread until another client takes the selection (or the compositor
474/// goes away). The selection is compositor-acknowledged when this returns, so a
475/// caller may immediately trigger a paste against it. Replacing a previous
476/// write is implicit: the compositor cancels the old source when the new one
477/// takes the selection.
478pub(crate) fn write(socket_path: &str, entries: Vec<(String, Vec<u8>)>) -> Result<(), String> {
479    let stream =
480        UnixStream::connect(socket_path).map_err(|e| format!("connect {socket_path}: {e}"))?;
481    let conn = Connection::from_socket(stream).map_err(|e| format!("wayland setup: {e}"))?;
482    let mut queue = conn.new_event_queue();
483    let qh = queue.handle();
484    let _registry = conn.display().get_registry(&qh, ());
485    let mut state = DcState::default();
486    bounded_roundtrip(&conn, &mut queue, &mut state)?;
487    let seat = state
488        .seat
489        .clone()
490        .ok_or("app compositor advertises no wl_seat")?;
491    let manager = state.take_manager()?;
492    let device = manager.get_data_device(&seat, &qh);
493    let source = manager.create_data_source(&qh);
494    for (mime, _) in &entries {
495        source.offer(mime.clone());
496    }
497    state.serve = entries;
498    device.set_selection(Some(&source));
499    bounded_roundtrip(&conn, &mut queue, &mut state)?;
500    std::thread::Builder::new()
501        .name("pf-dc-selection".into())
502        .spawn(move || {
503            if let Err(e) = serve_selection(conn, queue, state, device, source) {
504                eprintln!("[Clipboard] app-compositor selection serve ended: {e}");
505            }
506        })
507        .map_err(|e| format!("spawn: {e}"))?;
508    Ok(())
509}
510
511fn serve_selection(
512    conn: Connection,
513    mut queue: wayland_client::EventQueue<DcState>,
514    mut state: DcState,
515    device: DcDevice,
516    source: DcSource,
517) -> Result<(), String> {
518    while !state.canceled && !state.finished {
519        // Sends are served inside dispatch; block until the compositor has
520        // something (with a poll so a dead compositor can't pin the thread).
521        queue.flush().map_err(|e| format!("flush: {e}"))?;
522        if let Some(guard) = conn.prepare_read() {
523            use std::os::fd::AsRawFd;
524            if wait_readable(guard.connection_fd().as_raw_fd(), STOP_POLL)? {
525                guard.read().map_err(|e| format!("read: {e}"))?;
526            }
527        }
528        queue
529            .dispatch_pending(&mut state)
530            .map_err(|e| format!("dispatch: {e}"))?;
531    }
532    source.destroy();
533    device.destroy();
534    let _ = queue.flush();
535    Ok(())
536}
537
538/// Drop the selection (the compositor also cancels whatever source held it).
539pub(crate) fn clear(socket_path: &str) -> Result<(), String> {
540    let (conn, mut queue, mut state, device) = open_device(socket_path)?;
541    device.set_selection(None);
542    bounded_roundtrip(&conn, &mut queue, &mut state)?;
543    device.destroy();
544    Ok(())
545}
546
547struct WatchHandle {
548    stop: Arc<AtomicBool>,
549}
550
551static WATCHERS: Mutex<Option<HashMap<String, WatchHandle>>> = Mutex::new(None);
552
553/// Report every selection change on `socket_path` (including the one current at
554/// start) to `callback(mimes: list[str])` from a background thread, a cleared
555/// selection as no mimes. A second watch on the same socket replaces the first.
556pub(crate) fn watch(socket_path: &str, callback: Py<PyAny>) -> Result<(), String> {
557    let stop = Arc::new(AtomicBool::new(false));
558    {
559        let mut reg = WATCHERS.lock().unwrap();
560        let map = reg.get_or_insert_with(HashMap::new);
561        if let Some(old) = map.insert(socket_path.to_string(), WatchHandle { stop: stop.clone() }) {
562            old.stop.store(true, Ordering::Relaxed);
563        }
564    }
565    let path = socket_path.to_string();
566    std::thread::Builder::new()
567        .name("pf-dc-watch".into())
568        .spawn(move || {
569            if let Err(e) = watch_loop(&path, callback, &stop) {
570                eprintln!("[Clipboard] app-compositor watch ended: {e}");
571            }
572        })
573        .map_err(|e| format!("spawn: {e}"))?;
574    Ok(())
575}
576
577/// Stop every clipboard watch (process teardown sweep; watches are not tied to
578/// captures, so the global stop helper must reach them explicitly).
579pub(crate) fn unwatch_all() {
580    let mut reg = WATCHERS.lock().unwrap();
581    if let Some(map) = reg.as_mut() {
582        for (_, handle) in map.drain() {
583            handle.stop.store(true, Ordering::Relaxed);
584        }
585    }
586}
587
588/// Stop the watch on `socket_path` (no-op when none is running).
589pub(crate) fn unwatch(socket_path: &str) {
590    let mut reg = WATCHERS.lock().unwrap();
591    if let Some(map) = reg.as_mut()
592        && let Some(handle) = map.remove(socket_path)
593    {
594        handle.stop.store(true, Ordering::Relaxed);
595    }
596}
597
598fn watch_loop(socket_path: &str, callback: Py<PyAny>, stop: &AtomicBool) -> Result<(), String> {
599    let (conn, mut queue, mut state, device) = open_device(socket_path)?;
600    while !stop.load(Ordering::Relaxed) && !state.finished {
601        if state.selection_changed {
602            state.selection_changed = false;
603            let mimes = state
604                .selection
605                .as_ref()
606                .and_then(|o| state.offer_mimes.get(&o.id()).cloned())
607                .unwrap_or_default();
608            if crate::PY_SHUTDOWN.load(Ordering::Relaxed) {
609                break;
610            }
611            Python::attach(|py| {
612                if let Err(e) = callback.call1(py, (mimes,)) {
613                    e.print(py);
614                }
615            });
616        }
617        queue.flush().map_err(|e| format!("flush: {e}"))?;
618        if let Some(guard) = conn.prepare_read() {
619            use std::os::fd::AsRawFd;
620            if wait_readable(guard.connection_fd().as_raw_fd(), STOP_POLL)? {
621                guard.read().map_err(|e| format!("read: {e}"))?;
622            }
623        }
624        queue
625            .dispatch_pending(&mut state)
626            .map_err(|e| format!("dispatch: {e}"))?;
627    }
628    device.destroy();
629    let _ = queue.flush();
630    Ok(())
631}