diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c986706..6c4df419 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,6 +91,8 @@ jobs: run: cargo test --no-default-features --features wayland-data-control - name: Run tests with all features run: cargo test --all-features + - name: Run native clipboard watcher tests + run: cargo test --all-features --test watcher -- --ignored --test-threads=1 miri: needs: clippy diff --git a/Cargo.lock b/Cargo.lock index edc56e2f..1a6da86d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,9 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", "windows-sys", "wl-clipboard-rs", "x11rb", diff --git a/Cargo.toml b/Cargo.toml index a4b41ddd..a80d1523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,12 @@ image-data = [ "windows-sys", "core-graphics", ] -wayland-data-control = ["wl-clipboard-rs"] +wayland-data-control = [ + "wl-clipboard-rs", + "dep:wayland-client", + "dep:wayland-protocols", + "dep:wayland-protocols-wlr", +] # For backwards compat core-graphics = ["dep:objc2-core-graphics"] @@ -79,8 +84,11 @@ image = { version = "0.25", optional = true, default-features = false, features [target.'cfg(all(unix, not(any(target_os="macos", target_os="android", target_os="emscripten"))))'.dependencies] log = "0.4" -x11rb = { version = "0.13" } +x11rb = { version = "0.13", features = ["xfixes"] } wl-clipboard-rs = { version = "0.9.3", optional = true } +wayland-client = { version = "0.31.11", optional = true } +wayland-protocols = { version = "0.32.9", features = ["client", "staging"], optional = true } +wayland-protocols-wlr = { version = "0.3.9", features = ["client"], optional = true } image = { version = "0.25", optional = true, default-features = false, features = [ "png", ] } diff --git a/README.md b/README.md index 84a8af19..1214eb6b 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,67 @@ fn main() { } ``` +## Clipboard notifications + +`ClipboardWatcher` observes the regular clipboard without a `Clipboard` instance. +It never reads clipboard payloads or takes ownership. +The first event reports the initial state, including an empty clipboard. +Later events coalesce into one pending notification. +An event is a hint to read current data, not a snapshot of a write. + +```rust,no_run +use arboard::{ClipboardEvent, ClipboardWatcher}; +use std::time::Duration; + +# fn main() -> Result<(), Box> { +let watcher = ClipboardWatcher::new()?; +assert_eq!(watcher.wait()?, ClipboardEvent::Changed); +println!("{:?}", watcher.wait_timeout(Duration::from_secs(10))?); +watcher.stop()?; +assert_eq!(watcher.wait()?, ClipboardEvent::Stopped); +# Ok(()) +# } +``` + +`stop()` and `Drop` wake waits and join the worker. +Share the watcher through `Arc` to stop it from another thread. +A native failure reaches the next wait once as an error. +Subsequent waits return `Stopped`. +No callback runs under a clipboard lock. + +Platform coverage: + +| Platform | Mechanism | Limits | +| --- | --- | --- | +| Windows | Sequence counter, checked every 50 ms | Delayed rendering can defer changes. An unavailable counter returns an error. | +| macOS | `changeCount`, checked every 50 ms | Several changes can coalesce. | +| X11/XWayland | XFixes selection events | Requires XFixes 1.0. Data changes without ownership notifications are not observable. | +| Wayland | `ext-data-control-v1` or `wlr-data-control-unstable-v1` selection events | Requires `wayland-data-control` and a supported compositor. Observes the first advertised seat. | + +The worker checks native event queues every 50 ms. +Counter wraparound can hide changes. +Startup requires a response from the native server. +Wayland device revocation or seat removal ends the watcher with an error. +With the Wayland feature, a Wayland session never silently falls back to X11. +An unsupported compositor returns `ClipboardNotSupported`. +Without that feature, the watcher observes X11/XWayland only. +The Wayland watcher does not compare MIME lists. + +Run `cargo run --example watch`, then copy data in another application. +Run `cargo run --example watch -- --self-test` to verify changes and stop. +The self-test overwrites the clipboard. +Add `--features wayland-data-control` for native Wayland support. + +Native tests require a desktop or a test compositor. +They also require `pbcopy`, PowerShell, `xclip`, or `wl-copy`. +Run `cargo test --test watcher -- --ignored` to execute them. +They overwrite the clipboard and fail if the native service is unavailable. +The default test suite reports these tests as ignored; it does not silently skip them. +The macOS and Windows CI jobs explicitly run the native watcher test with all features. +On Linux, `bash tools/test_watchers_linux.sh` starts isolated Xvfb and Sway servers. +It tests native changes, external writes, shutdown, server disconnection, and missing XFixes support. +It requires Cargo, Xvfb, Sway, `xclip`, `wl-copy`, and `timeout`. + ## Credits This crate is a combined effort by 1Password staff and `@ArturKovacs`, the crate's past diff --git a/examples/watch.rs b/examples/watch.rs new file mode 100644 index 00000000..7d3fdd9b --- /dev/null +++ b/examples/watch.rs @@ -0,0 +1,31 @@ +//! Run `cargo run --example watch` and copy data in another application. +//! Use `--self-test` to write two text values and verify event delivery and stop. +use arboard::{Clipboard, ClipboardEvent, ClipboardWatcher}; +use std::time::Duration; + +fn main() -> Result<(), Box> { + let watcher = ClipboardWatcher::new()?; + assert_eq!(watcher.wait()?, ClipboardEvent::Changed); + println!("Initial clipboard event received."); + + if std::env::args().any(|arg| arg == "--self-test") { + let mut clipboard = Clipboard::new()?; + for text in ["arboard watcher first value", "arboard watcher second value"] { + clipboard.set_text(text)?; + assert_eq!( + watcher.wait_timeout(Duration::from_secs(5))?, + Some(ClipboardEvent::Changed) + ); + assert_eq!(clipboard.get_text()?, text); + println!("Clipboard change received: {text}"); + } + } else { + println!("Copy data within 10 seconds to receive a change event."); + println!("Event: {:?}", watcher.wait_timeout(Duration::from_secs(10))?); + } + + watcher.stop()?; + assert_eq!(watcher.wait()?, ClipboardEvent::Stopped); + println!("Watcher stopped and worker joined."); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index aea3c173..e33b1990 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,9 @@ pub use common::ImageData; mod platform; +mod watcher; +pub use watcher::{ClipboardEvent, ClipboardWatcher}; + #[cfg(all( unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten")), diff --git a/src/watcher.rs b/src/watcher.rs new file mode 100644 index 00000000..9783424a --- /dev/null +++ b/src/watcher.rs @@ -0,0 +1,458 @@ +use std::{ + sync::{mpsc, Arc, Condvar, Mutex}, + thread::{self, JoinHandle}, + time::Duration, +}; + +use crate::Error; + +#[cfg(target_os = "macos")] +#[path = "watcher/macos.rs"] +mod native; +#[cfg(windows)] +#[path = "watcher/windows.rs"] +mod native; +#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))] +#[path = "watcher/linux.rs"] +mod native; + +const INTERVAL: Duration = Duration::from_millis(50); + +/// A notification from a [`ClipboardWatcher`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClipboardEvent { + /// The clipboard may have changed. Read its current contents with [`crate::Clipboard`]. + /// + /// The first event reports the initial state, even when the clipboard is empty. + /// Events carry no payload or revision. Multiple changes can produce one event. + Changed, + /// The watcher stopped. All subsequent waits return this event. + Stopped, +} + +/// An independent watcher for the regular clipboard. +/// +/// Construction opens a native connection and starts one worker thread. No [`crate::Clipboard`] +/// instance is required. The watcher never reads clipboard payloads or invokes user callbacks. +/// It does not retain clipboard contents or take clipboard ownership. +/// +/// After successful construction, one initial [`ClipboardEvent::Changed`] event is available. +/// At most one change event remains pending: changes coalesce until a wait consumes that event. +/// A notification is a hint to read the current clipboard, not a snapshot or a count of writes. +/// A write of identical data can cause a notification. A read after an event can see newer data. +/// +/// [`stop`](Self::stop) and `Drop` stop and join the worker. A stop discards pending changes and +/// wakes all waits. Share the watcher through `Arc` to stop it from another thread. +/// Concurrent waits compete for change events; they do not each receive a copy. +/// +/// # Platform support +/// +/// - Windows checks `GetClipboardSequenceNumber` every 50 ms without opening the clipboard. +/// Zero means that the sequence is unavailable and causes an error. Delayed rendering can +/// defer an update. Counter wraparound or several changes between checks can hide changes. +/// - macOS checks `NSPasteboard.changeCount` every 50 ms. Counter wraparound can hide changes. +/// - X11 uses XFixes selection notifications on a separate connection. XFixes 1.0 is required. +/// Changes to data without a selection ownership notification are not observable. +/// - With `wayland-data-control`, a session with `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` uses +/// Wayland. The watcher requires `ext-data-control-v1` or `wlr-data-control-unstable-v1`. +/// It observes selection events for the first advertised seat, including empty selections. +/// It does not compare MIME lists. A removed seat or a revoked device terminates the watcher. +/// An unsupported compositor returns [`Error::ClipboardNotSupported`]; there is no silent +/// X11 fallback. Without this feature, the watcher observes X11/XWayland only. +/// +/// Native event queues are checked every 50 ms. This interval is not a delivery deadline. +/// Startup can block while the native server responds. After startup, stop interrupts the +/// interval wait; it does not need a clipboard change. Native failures reach the next wait +/// once as an error, then waits return [`ClipboardEvent::Stopped`]. +/// A terminal error takes precedence over a pending change, including the initial event. +/// +/// # Example +/// +/// ```no_run +/// use arboard::{ClipboardEvent, ClipboardWatcher}; +/// use std::time::Duration; +/// +/// # fn main() -> Result<(), Box> { +/// let watcher = ClipboardWatcher::new()?; +/// assert_eq!(watcher.wait()?, ClipboardEvent::Changed); // Initial state. +/// if watcher.wait_timeout(Duration::from_secs(10))? == Some(ClipboardEvent::Changed) { +/// println!("The clipboard changed."); +/// } +/// watcher.stop()?; +/// assert_eq!(watcher.wait()?, ClipboardEvent::Stopped); +/// # Ok(()) +/// # } +/// ``` +pub struct ClipboardWatcher { + shared: Arc, + worker: Mutex>>, +} + +#[derive(Default)] +struct State { + changed: bool, + stopped: bool, + error: Option, +} + +#[derive(Default)] +struct Shared { + state: Mutex, + wake: Condvar, +} + +fn unknown(error: impl std::fmt::Display) -> Error { + Error::Unknown { description: error.to_string() } +} + +impl ClipboardWatcher { + /// Starts a watcher and waits for native initialization. + /// + /// Returns an error if the clipboard, notification protocol, or worker is unavailable. + pub fn new() -> Result { + Self::spawn(|| { + let mut backend = native::Backend::new()?; + Ok(move || backend.poll()) + }) + } + + fn spawn(initialize: F) -> Result + where + F: FnOnce() -> Result + Send + 'static, + P: FnMut() -> Result, + { + let shared = Arc::new(Shared::default()); + let worker_shared = shared.clone(); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let worker = thread::Builder::new() + .name("arboard-watcher".into()) + .spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut poll = match initialize() { + Ok(poll) => poll, + Err(error) => { + let _ = ready_tx.send(Err(error)); + return; + } + }; + { + let mut state = worker_shared.state.lock().unwrap(); + state.changed = true; + } + let _ = ready_tx.send(Ok(())); + loop { + let state = worker_shared.state.lock().unwrap(); + let (state, _) = worker_shared + .wake + .wait_timeout_while(state, INTERVAL, |state| !state.stopped) + .unwrap(); + if state.stopped { + break; + } + drop(state); + match poll() { + Ok(false) => {} + Ok(true) => { + worker_shared.state.lock().unwrap().changed = true; + worker_shared.wake.notify_all(); + } + Err(error) => { + worker_shared.finish(Some(error)); + break; + } + } + } + })); + if result.is_err() { + worker_shared.finish(Some(unknown("clipboard watcher worker panicked"))); + } + }) + .map_err(unknown)?; + match ready_rx.recv() { + Ok(Ok(())) => Ok(Self { shared, worker: Mutex::new(Some(worker)) }), + result => { + let _ = worker.join(); + Err(match result { + Ok(Err(error)) => error, + _ => unknown("clipboard watcher worker failed during startup"), + }) + } + } + } + + /// Waits for a change, a terminal error, or a stop. + pub fn wait(&self) -> Result { + let state = self.shared.state.lock().unwrap(); + let mut state = + self.shared.wake.wait_while(state, |state| !state.changed && !state.stopped).unwrap(); + Self::take_event(&mut state).map(Option::unwrap) + } + + /// Waits up to `timeout`. Returns `None` only when the timeout expires without an event. + /// + /// A zero timeout checks for a pending event without a wait. + pub fn wait_timeout(&self, timeout: Duration) -> Result, Error> { + let state = self.shared.state.lock().unwrap(); + let (mut state, _) = self + .shared + .wake + .wait_timeout_while(state, timeout, |state| !state.changed && !state.stopped) + .unwrap(); + Self::take_event(&mut state) + } + + fn take_event(state: &mut State) -> Result, Error> { + if let Some(error) = state.error.take() { + return Err(error); + } + if state.stopped { + Ok(Some(ClipboardEvent::Stopped)) + } else if std::mem::take(&mut state.changed) { + Ok(Some(ClipboardEvent::Changed)) + } else { + Ok(None) + } + } + + /// Stops the worker, discards pending changes, wakes waits, and joins the worker thread. + /// + /// This method is idempotent. A pending native error remains available to the next wait. + /// If this call stops the watcher first, errors from an in-flight poll are discarded. + /// Returns an error only if the worker thread could not be joined. + pub fn stop(&self) -> Result<(), Error> { + self.shared.finish(None); + // Hold the join lock so every concurrent stop waits for the worker to exit. + if let Some(worker) = self.worker.lock().unwrap().take() { + worker.join().map_err(|_| unknown("clipboard watcher worker panicked"))?; + } + Ok(()) + } +} + +impl Shared { + fn finish(&self, error: Option) { + let mut state = self.state.lock().unwrap(); + if !state.stopped { + state.error = error; + } + state.stopped = true; + state.changed = false; + self.wake.notify_all(); + } +} + +impl Drop for ClipboardWatcher { + fn drop(&mut self) { + let _ = self.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn initial_timeout_and_repeated_stop() { + let watcher = ClipboardWatcher::spawn(|| Ok(|| Ok(false))).unwrap(); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Changed); + assert_eq!(watcher.wait_timeout(Duration::ZERO).unwrap(), None); + assert_eq!(watcher.wait_timeout(Duration::from_millis(10)).unwrap(), None); + watcher.stop().unwrap(); + watcher.stop().unwrap(); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Stopped); + } + + #[test] + fn changes_coalesce() { + let (tx, rx) = mpsc::channel(); + let enabled = Arc::new(AtomicBool::new(true)); + let worker_enabled = enabled.clone(); + let watcher = ClipboardWatcher::spawn(move || { + Ok(move || { + let changed = worker_enabled.load(Ordering::SeqCst); + tx.send(changed).unwrap(); + Ok(changed) + }) + }) + .unwrap(); + for _ in 0..3 { + rx.recv_timeout(Duration::from_secs(3)).unwrap(); + } + enabled.store(false, Ordering::SeqCst); + while rx.recv_timeout(Duration::from_secs(3)).unwrap() {} + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Changed); + assert_eq!(watcher.wait_timeout(INTERVAL * 2).unwrap(), None); + } + + #[test] + fn native_error_is_terminal_and_delivered_once() { + let (tx, rx) = mpsc::channel(); + let watcher = ClipboardWatcher::spawn(move || { + Ok(move || match rx.try_recv() { + Ok(()) => Err(Error::ClipboardOccupied), + _ => Ok(false), + }) + }) + .unwrap(); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Changed); + tx.send(()).unwrap(); + assert!(matches!(watcher.wait(), Err(Error::ClipboardOccupied))); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Stopped); + watcher.stop().unwrap(); + } + + #[test] + fn timeout_wait_wakes_on_change() { + let (tx, rx) = mpsc::channel(); + let watcher = + ClipboardWatcher::spawn(move || Ok(move || Ok(rx.try_recv().is_ok()))).unwrap(); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Changed); + tx.send(()).unwrap(); + assert_eq!( + watcher.wait_timeout(Duration::from_secs(3)).unwrap(), + Some(ClipboardEvent::Changed) + ); + assert_eq!(watcher.wait_timeout(Duration::ZERO).unwrap(), None); + } + + #[test] + fn startup_error_and_panic_reach_caller() { + assert!(matches!( + ClipboardWatcher::spawn(|| Err:: Result, _>( + Error::ClipboardNotSupported + )), + Err(Error::ClipboardNotSupported) + )); + assert!(ClipboardWatcher::spawn(|| -> Result Result, Error> { + panic!("startup failure") + }) + .is_err()); + } + + #[test] + fn worker_panic_wakes_waiter() { + let (tx, rx) = mpsc::channel(); + let watcher = ClipboardWatcher::spawn(move || { + Ok(move || { + assert!(rx.try_recv().is_err(), "poll failure"); + Ok(false) + }) + }) + .unwrap(); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Changed); + tx.send(()).unwrap(); + assert!(watcher.wait_timeout(Duration::from_secs(3)).is_err()); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Stopped); + } + + struct Dropped(Arc); + + impl Drop for Dropped { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + fn idle_watcher(dropped: Arc) -> ClipboardWatcher { + ClipboardWatcher::spawn(move || { + let guard = Dropped(dropped); + Ok(move || { + let _ = &guard; + Ok(false) + }) + }) + .unwrap() + } + + #[test] + fn stop_wakes_waiters_and_joins_worker() { + let dropped = Arc::new(AtomicBool::new(false)); + let watcher = Arc::new(idle_watcher(dropped.clone())); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Changed); + let (tx, rx) = mpsc::channel(); + let waiter = watcher.clone(); + let waiting = thread::spawn(move || tx.send(waiter.wait()).unwrap()); + watcher.stop().unwrap(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(3)).unwrap().unwrap(), + ClipboardEvent::Stopped + ); + assert!(dropped.load(Ordering::SeqCst)); + waiting.join().unwrap(); + } + + #[test] + fn concurrent_stops_wait_for_worker_cleanup() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let watcher = Arc::new( + ClipboardWatcher::spawn(move || { + Ok(move || { + entered_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + Ok(false) + }) + }) + .unwrap(), + ); + entered_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + let (stopped_tx, stopped_rx) = mpsc::channel(); + let mut threads = Vec::new(); + for _ in 0..2 { + let watcher = watcher.clone(); + let stopped_tx = stopped_tx.clone(); + threads.push(thread::spawn(move || { + watcher.stop().unwrap(); + stopped_tx.send(()).unwrap(); + })); + } + assert!(stopped_rx.recv_timeout(INTERVAL).is_err()); + release_tx.send(()).unwrap(); + for thread in threads { + stopped_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + thread.join().unwrap(); + } + } + + #[test] + fn stopped_remains_terminal_after_in_flight_poll_error() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let watcher = Arc::new( + ClipboardWatcher::spawn(move || { + Ok(move || { + entered_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + Err(Error::ClipboardOccupied) + }) + }) + .unwrap(), + ); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Changed); + entered_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + let stopping = watcher.clone(); + let stopper = thread::spawn(move || stopping.stop().unwrap()); + assert_eq!( + watcher.wait_timeout(Duration::from_secs(3)).unwrap(), + Some(ClipboardEvent::Stopped) + ); + release_tx.send(()).unwrap(); + stopper.join().unwrap(); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Stopped); + assert_eq!(watcher.wait_timeout(Duration::ZERO).unwrap(), Some(ClipboardEvent::Stopped)); + } + + #[test] + fn drop_joins_worker_and_stop_preserves_error() { + let dropped = Arc::new(AtomicBool::new(false)); + drop(idle_watcher(dropped.clone())); + assert!(dropped.load(Ordering::SeqCst)); + + let watcher = idle_watcher(Arc::new(AtomicBool::new(false))); + watcher.shared.finish(Some(Error::ClipboardOccupied)); + watcher.stop().unwrap(); + assert!(matches!(watcher.wait(), Err(Error::ClipboardOccupied))); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Stopped); + } +} diff --git a/src/watcher/linux.rs b/src/watcher/linux.rs new file mode 100644 index 00000000..1907533f --- /dev/null +++ b/src/watcher/linux.rs @@ -0,0 +1,33 @@ +use crate::Error; + +#[cfg(feature = "wayland-data-control")] +#[path = "wayland.rs"] +mod wayland; +#[path = "x11.rs"] +mod x11; + +pub(super) enum Backend { + X11(Box), + #[cfg(feature = "wayland-data-control")] + Wayland(wayland::Backend), +} + +impl Backend { + pub(super) fn new() -> Result { + #[cfg(feature = "wayland-data-control")] + if std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var_os("WAYLAND_SOCKET").is_some() + { + return wayland::Backend::new().map(Self::Wayland); + } + x11::Backend::new().map(|backend| Self::X11(Box::new(backend))) + } + + pub(super) fn poll(&mut self) -> Result { + match self { + Self::X11(backend) => backend.poll(), + #[cfg(feature = "wayland-data-control")] + Self::Wayland(backend) => backend.poll(), + } + } +} diff --git a/src/watcher/macos.rs b/src/watcher/macos.rs new file mode 100644 index 00000000..30a91e39 --- /dev/null +++ b/src/watcher/macos.rs @@ -0,0 +1,34 @@ +use crate::Error; +use objc2::{ + msg_send, + rc::{autoreleasepool, Retained}, + ClassType, +}; +use objc2_app_kit::NSPasteboard; + +pub(super) struct Backend { + pasteboard: Retained, + count: isize, +} + +impl Backend { + pub(super) fn new() -> Result { + autoreleasepool(|_| { + // Some launchd sessions have no pasteboard, despite the non-null API annotation. + let pasteboard: Option> = + unsafe { msg_send![NSPasteboard::class(), generalPasteboard] }; + let pasteboard = pasteboard.ok_or(Error::ClipboardNotSupported)?; + let count = unsafe { pasteboard.changeCount() }; + Ok(Self { pasteboard, count }) + }) + } + + pub(super) fn poll(&mut self) -> Result { + autoreleasepool(|_| { + let count = unsafe { self.pasteboard.changeCount() }; + let changed = count != self.count; + self.count = count; + Ok(changed) + }) + } +} diff --git a/src/watcher/wayland.rs b/src/watcher/wayland.rs new file mode 100644 index 00000000..89fb2db8 --- /dev/null +++ b/src/watcher/wayland.rs @@ -0,0 +1,335 @@ +use crate::{watcher::unknown, Error}; +use wayland_client::{ + backend::WaylandError, + delegate_noop, event_created_child, + globals::{registry_queue_init, GlobalListContents}, + protocol::{wl_registry, wl_seat}, + Connection, Dispatch, EventQueue, QueueHandle, +}; +use wayland_protocols::ext::data_control::v1::client::{ + ext_data_control_device_v1 as ext_device, ext_data_control_manager_v1 as ext_manager, + ext_data_control_offer_v1 as ext_offer, +}; +use wayland_protocols_wlr::data_control::v1::client::{ + zwlr_data_control_device_v1 as wlr_device, zwlr_data_control_manager_v1 as wlr_manager, + zwlr_data_control_offer_v1 as wlr_offer, +}; + +#[derive(Default)] +struct State { + seat_name: u32, + changed: bool, + finished: bool, +} + +pub(in crate::watcher) struct Backend { + connection: Connection, + queue: EventQueue, + state: State, +} + +fn allow_would_block(result: Result) -> Result<(), Error> { + match result { + Ok(_) => Ok(()), + Err(WaylandError::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock => Ok(()), + Err(error) => Err(unknown(error)), + } +} + +impl Backend { + pub(super) fn new() -> Result { + let connection = Connection::connect_to_env().map_err(unknown)?; + Self::from_connection(connection) + } + + fn from_connection(connection: Connection) -> Result { + let (globals, mut queue) = registry_queue_init::(&connection).map_err(unknown)?; + let handle = queue.handle(); + let (seat_name, seat_version, has_ext, has_wlr) = globals.contents().with_list(|list| { + let seat = list.iter().find(|global| global.interface == "wl_seat"); + ( + seat.map(|seat| seat.name), + seat.map(|seat| seat.version).unwrap_or(1), + list.iter().any(|global| global.interface == "ext_data_control_manager_v1"), + list.iter().any(|global| global.interface == "zwlr_data_control_manager_v1"), + ) + }); + let seat_name = seat_name.ok_or(Error::ClipboardNotSupported)?; + let seat: wl_seat::WlSeat = + globals.registry().bind(seat_name, seat_version.min(1), &handle, ()); + if has_ext { + let manager: ext_manager::ExtDataControlManagerV1 = + globals.bind(&handle, 1..=1, ()).map_err(unknown)?; + manager.get_data_device(&seat, &handle, ()); + manager.destroy(); + } else if has_wlr { + let manager: wlr_manager::ZwlrDataControlManagerV1 = + globals.bind(&handle, 1..=2, ()).map_err(unknown)?; + manager.get_data_device(&seat, &handle, ()); + manager.destroy(); + } else { + return Err(Error::ClipboardNotSupported); + } + let mut state = State { seat_name, ..State::default() }; + queue.roundtrip(&mut state).map_err(unknown)?; + if state.finished { + return Err(Error::ClipboardNotSupported); + } + state.changed = false; + Ok(Self { connection, queue, state }) + } + + pub(super) fn poll(&mut self) -> Result { + allow_would_block(self.connection.flush())?; + self.queue.dispatch_pending(&mut self.state).map_err(unknown)?; + if let Some(read) = self.queue.prepare_read() { + // This connection has one reader. read() returns WouldBlock if no data is available. + allow_would_block(read.read())?; + } + self.queue.dispatch_pending(&mut self.state).map_err(unknown)?; + if self.state.finished { + return Err(unknown("the Wayland clipboard device or seat is no longer available")); + } + Ok(std::mem::take(&mut self.state.changed)) + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + _handle: &QueueHandle, + ) { + if let wl_registry::Event::GlobalRemove { name } = event { + if name == state.seat_name { + state.finished = true; + } + } + } +} + +delegate_noop!(State: ignore wl_seat::WlSeat); +delegate_noop!(State: ignore ext_manager::ExtDataControlManagerV1); +delegate_noop!(State: ignore wlr_manager::ZwlrDataControlManagerV1); +delegate_noop!(State: ignore ext_offer::ExtDataControlOfferV1); +delegate_noop!(State: ignore wlr_offer::ZwlrDataControlOfferV1); + +// Both protocols have the same event semantics. No offer data is requested. +macro_rules! device_dispatch { + ($device:ident, $device_type:ident, $offer:ident, $offer_type:ident) => { + impl Dispatch<$device::$device_type, ()> for State { + fn event( + state: &mut Self, + _device: &$device::$device_type, + event: $device::Event, + _data: &(), + _connection: &Connection, + _handle: &QueueHandle, + ) { + match event { + $device::Event::Selection { id } => { + state.changed = true; + if let Some(offer) = id { + offer.destroy(); + } + } + $device::Event::PrimarySelection { id: Some(offer) } => { + offer.destroy(); + } + $device::Event::Finished => state.finished = true, + _ => {} + } + } + + event_created_child!(State, $device::$device_type, [ + $device::EVT_DATA_OFFER_OPCODE => ($offer::$offer_type, ()), + ]); + } + }; +} + +device_dispatch!(ext_device, ExtDataControlDeviceV1, ext_offer, ExtDataControlOfferV1); +device_dispatch!(wlr_device, ZwlrDataControlDeviceV1, wlr_offer, ZwlrDataControlOfferV1); + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + sync::mpsc, + thread, + time::Duration, + }; + + enum Notification { + Selection(u32), + Primary(u32), + Finished, + SeatRemoved, + Stop, + } + + struct Server { + send: mpsc::Sender, + applied: mpsc::Receiver<()>, + thread: Option>, + } + + fn word(bytes: &[u8]) -> u32 { + u32::from_ne_bytes(bytes[..4].try_into().unwrap()) + } + + fn string(value: &str) -> Vec { + let mut bytes = ((value.len() + 1) as u32).to_ne_bytes().to_vec(); + bytes.extend_from_slice(value.as_bytes()); + bytes.push(0); + while bytes.len() % 4 != 0 { + bytes.push(0); + } + bytes + } + + fn event(socket: &mut UnixStream, object: u32, opcode: u32, payload: &[u8]) { + let header = [object, (((payload.len() + 8) as u32) << 16) | opcode]; + for word in header { + socket.write_all(&word.to_ne_bytes()).unwrap(); + } + socket.write_all(payload).unwrap(); + } + + fn global(socket: &mut UnixStream, registry: u32, name: u32, interface: &str, version: u32) { + let mut payload = name.to_ne_bytes().to_vec(); + payload.extend(string(interface)); + payload.extend(version.to_ne_bytes()); + event(socket, registry, 0, &payload); + } + + impl Server { + fn start(protocol: Option<&'static str>) -> (Connection, Self) { + let (client, mut socket) = UnixStream::pair().unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let (send, receive) = mpsc::channel(); + let (applied_tx, applied) = mpsc::channel(); + let thread = thread::spawn(move || { + let (mut registry, mut manager, mut device, mut syncs) = (0, 0, 0, 0); + // Serve only the startup handshake. No clipboard payload protocol is implemented. + loop { + let mut header = [0u8; 8]; + if socket.read_exact(&mut header).is_err() { + return; + } + let object = word(&header); + let opcode = word(&header[4..]) & 0xffff; + let size = (word(&header[4..]) >> 16) as usize; + let mut payload = vec![0; size - 8]; + socket.read_exact(&mut payload).unwrap(); + match (object, opcode) { + (1, 1) => { + registry = word(&payload); + global(&mut socket, registry, 1, "wl_seat", 1); + if let Some(protocol) = protocol { + let version = if protocol.starts_with("zwlr") { 2 } else { 1 }; + global(&mut socket, registry, 2, protocol, version); + } + } + (1, 0) => { + let callback = word(&payload); + event(&mut socket, callback, 0, &0u32.to_ne_bytes()); + event(&mut socket, 1, 1, &callback.to_ne_bytes()); + syncs += 1; + if syncs == 2 { + break; + } + } + (id, 0) if id == registry && word(&payload) == 2 => { + manager = word(&payload[payload.len() - 4..]); + } + (id, 1) if id == manager => { + device = word(&payload); + event(&mut socket, device, 1, &0u32.to_ne_bytes()); + } + _ => {} + } + } + while let Ok(notification) = receive.recv() { + match notification { + Notification::Selection(offer) | Notification::Primary(offer) => { + if offer != 0 { + event(&mut socket, device, 0, &offer.to_ne_bytes()); + event(&mut socket, offer, 0, &string("text/plain")); + } + let opcode = match notification { + Notification::Selection(_) => 1, + _ => 3, + }; + event(&mut socket, device, opcode, &offer.to_ne_bytes()); + } + Notification::Finished => event(&mut socket, device, 2, &[]), + Notification::SeatRemoved => { + event(&mut socket, registry, 1, &1u32.to_ne_bytes()); + } + Notification::Stop => break, + } + applied_tx.send(()).unwrap(); + } + }); + (Connection::from_socket(client).unwrap(), Self { send, applied, thread: Some(thread) }) + } + + fn notify(&self, notification: Notification) { + self.send.send(notification).unwrap(); + self.applied.recv_timeout(Duration::from_secs(3)).unwrap(); + } + } + + impl Drop for Server { + fn drop(&mut self) { + let _ = self.send.send(Notification::Stop); + self.thread.take().unwrap().join().unwrap(); + } + } + + fn changes(protocol: &'static str) { + let (connection, server) = Server::start(Some(protocol)); + let mut backend = Backend::from_connection(connection).unwrap(); + assert!(!backend.poll().unwrap()); + // Identical MIME lists still carry distinct selection events. + for offer in [0xff00_0000, 0xff00_0001, 0] { + server.notify(Notification::Selection(offer)); + assert!(backend.poll().unwrap()); + assert!(!backend.poll().unwrap()); + } + server.notify(Notification::Primary(0xff00_0002)); + assert!(!backend.poll().unwrap()); + server.notify(Notification::Finished); + assert!(backend.poll().is_err()); + } + + #[test] + fn ext_selection_events() { + changes("ext_data_control_manager_v1"); + } + + #[test] + fn wlr_selection_events() { + changes("zwlr_data_control_manager_v1"); + } + + #[test] + fn missing_data_control_is_unsupported() { + let (connection, _server) = Server::start(None); + assert!(matches!(Backend::from_connection(connection), Err(Error::ClipboardNotSupported))); + } + + #[test] + fn removed_seat_is_terminal() { + let (connection, server) = Server::start(Some("ext_data_control_manager_v1")); + let mut backend = Backend::from_connection(connection).unwrap(); + server.notify(Notification::SeatRemoved); + assert!(backend.poll().is_err()); + } +} diff --git a/src/watcher/windows.rs b/src/watcher/windows.rs new file mode 100644 index 00000000..d6c43b02 --- /dev/null +++ b/src/watcher/windows.rs @@ -0,0 +1,29 @@ +use crate::Error; +use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber; + +pub(super) struct Backend { + sequence: u32, +} + +fn sequence() -> Result { + let sequence = unsafe { GetClipboardSequenceNumber() }; + if sequence == 0 { + // This API does not document a GetLastError value. + Err(super::unknown("the clipboard sequence number is unavailable")) + } else { + Ok(sequence) + } +} + +impl Backend { + pub(super) fn new() -> Result { + Ok(Self { sequence: sequence()? }) + } + + pub(super) fn poll(&mut self) -> Result { + let sequence = sequence()?; + let changed = sequence != self.sequence; + self.sequence = sequence; + Ok(changed) + } +} diff --git a/src/watcher/x11.rs b/src/watcher/x11.rs new file mode 100644 index 00000000..2a78eee1 --- /dev/null +++ b/src/watcher/x11.rs @@ -0,0 +1,85 @@ +use crate::{watcher::unknown, Error}; +use x11rb::{ + connection::{Connection, RequestConnection}, + protocol::{ + xfixes::{ConnectionExt as _, SelectionEventMask}, + xproto::{ConnectionExt as _, CreateWindowAux, WindowClass}, + Event, + }, + rust_connection::RustConnection, + COPY_DEPTH_FROM_PARENT, +}; + +pub(in crate::watcher) struct Backend { + connection: RustConnection, + window: u32, + selection: u32, +} + +impl Backend { + pub(super) fn new() -> Result { + let (connection, screen) = x11rb::connect(None).map_err(unknown)?; + if connection.extension_information("XFIXES").map_err(unknown)?.is_none() { + return Err(Error::ClipboardNotSupported); + } + let version = + connection.xfixes_query_version(1, 0).map_err(unknown)?.reply().map_err(unknown)?; + if version.major_version < 1 { + return Err(Error::ClipboardNotSupported); + } + let selection = connection + .intern_atom(false, b"CLIPBOARD") + .map_err(unknown)? + .reply() + .map_err(unknown)? + .atom; + let window = connection.generate_id().map_err(unknown)?; + connection + .create_window( + COPY_DEPTH_FROM_PARENT, + window, + connection.setup().roots[screen].root, + 0, + 0, + 1, + 1, + 0, + WindowClass::INPUT_ONLY, + 0, + &CreateWindowAux::new(), + ) + .map_err(unknown)? + .check() + .map_err(unknown)?; + connection + .xfixes_select_selection_input( + window, + selection, + SelectionEventMask::SET_SELECTION_OWNER + | SelectionEventMask::SELECTION_WINDOW_DESTROY + | SelectionEventMask::SELECTION_CLIENT_CLOSE, + ) + .map_err(unknown)? + .check() + .map_err(unknown)?; + Ok(Self { connection, window, selection }) + } + + pub(super) fn poll(&mut self) -> Result { + let mut changed = false; + // Bound each pass so a busy native event stream cannot starve stop. + for _ in 0..256 { + match self.connection.poll_for_event().map_err(unknown)? { + Some(Event::XfixesSelectionNotify(event)) + if event.window == self.window && event.selection == self.selection => + { + changed = true; + } + Some(Event::Error(error)) => return Err(unknown(format!("{error:?}"))), + Some(_) => {} + None => break, + } + } + Ok(changed) + } +} diff --git a/tests/watcher.rs b/tests/watcher.rs new file mode 100644 index 00000000..b3c337cf --- /dev/null +++ b/tests/watcher.rs @@ -0,0 +1,84 @@ +use arboard::{Clipboard, ClipboardEvent, ClipboardWatcher}; +use std::{ + io::Write, + process::{Command, Stdio}, + time::Duration, +}; + +/// This test overwrites the regular clipboard. It requires a native desktop or test compositor. +/// Run explicitly with `cargo test --test watcher -- --ignored`. +#[test] +#[ignore = "requires a native clipboard and pbcopy, PowerShell, xclip, or wl-copy; overwrites clipboard"] +fn native_changes_and_external_writer() { + let watcher = ClipboardWatcher::new().expect("native watcher must be available"); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Changed); + let mut clipboard = Clipboard::new().unwrap(); + for text in ["watcher native first", "watcher native second"] { + wait_for_quiet(&watcher); + clipboard.set_text(text).unwrap(); + assert_eq!( + watcher.wait_timeout(Duration::from_secs(5)).unwrap(), + Some(ClipboardEvent::Changed) + ); + assert_eq!(clipboard.get_text().unwrap(), text); + } + + // Drain native notifications before the independent writer changes the same text format. + wait_for_quiet(&watcher); + external_copy("watcher external writer"); + assert_eq!( + watcher.wait_timeout(Duration::from_secs(5)).unwrap(), + Some(ClipboardEvent::Changed) + ); + assert_eq!(clipboard.get_text().unwrap(), "watcher external writer"); + wait_for_quiet(&watcher); + clipboard.clear().unwrap(); + assert_eq!( + watcher.wait_timeout(Duration::from_secs(5)).unwrap(), + Some(ClipboardEvent::Changed) + ); + watcher.stop().unwrap(); + assert_eq!(watcher.wait().unwrap(), ClipboardEvent::Stopped); + drop(ClipboardWatcher::new().unwrap()); +} + +fn wait_for_quiet(watcher: &ClipboardWatcher) { + for _ in 0..20 { + if watcher.wait_timeout(Duration::from_millis(200)).unwrap().is_none() { + return; + } + } + panic!("the clipboard must become idle before the next test write"); +} + +fn external_copy(text: &str) { + #[cfg(target_os = "macos")] + let mut command = Command::new("pbcopy"); + #[cfg(windows)] + let mut command = { + let mut command = Command::new("powershell.exe"); + command.args([ + "-NoProfile", + "-Command", + "Set-Clipboard -Value ([Console]::In.ReadToEnd())", + ]); + command + }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = { + if cfg!(feature = "wayland-data-control") + && (std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var_os("WAYLAND_SOCKET").is_some()) + { + Command::new("wl-copy") + } else { + let mut command = Command::new("xclip"); + command.args(["-selection", "clipboard"]); + command + } + }; + let mut child = + command.stdin(Stdio::piped()).spawn().expect("external clipboard tool is required"); + child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap(); + assert!(child.wait().unwrap().success()); +} diff --git a/tools/test_watchers_linux.sh b/tools/test_watchers_linux.sh new file mode 100644 index 00000000..4e5e3d51 --- /dev/null +++ b/tools/test_watchers_linux.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Run inside an isolated Linux desktop or container. The tests overwrite its clipboard. +set -euo pipefail +cd "$(dirname "$0")/.." + +for command in cargo Xvfb sway xclip wl-copy timeout; do + command -v "$command" >/dev/null +done + +run_dir="$PWD/target/watcher-native-$$" +mkdir -p "$run_dir/runtime" +chmod 700 "$run_dir/runtime" +server= +client= +cleanup() { + if [ -n "$client" ]; then + kill "$client" 2>/dev/null || true + wait "$client" 2>/dev/null || true + fi + if [ -n "$server" ]; then + kill "$server" 2>/dev/null || true + wait "$server" 2>/dev/null || true + fi +} +trap cleanup EXIT + +start_x11() { + : >"$run_dir/display" + Xvfb -displayfd 3 -screen 0 1280x1024x24 -ac "$@" \ + 3>>"$run_dir/display" >"$run_dir/xvfb.log" 2>&1 & + server=$! + for _ in {1..100}; do + kill -0 "$server" + [ ! -s "$run_dir/display" ] || break + sleep 0.1 + done + test -s "$run_dir/display" + DISPLAY=":$(cat "$run_dir/display")" + export DISPLAY +} + +check_disconnect() { + local backend="$1" + : >"$run_dir/$backend-disconnect.log" + timeout 5s cargo run --quiet --example watch --all-features \ + >>"$run_dir/$backend-disconnect.log" 2>&1 & + client=$! + for _ in {1..40}; do + kill -0 "$client" + ! grep -q "Initial clipboard event" "$run_dir/$backend-disconnect.log" || break + sleep 0.1 + done + grep -q "Initial clipboard event" "$run_dir/$backend-disconnect.log" + kill "$server" + wait "$server" || true + server= + local status=0 + wait "$client" || status=$? + client= + test "$status" -ne 0 + test "$status" -ne 124 + grep -q "Error:" "$run_dir/$backend-disconnect.log" + echo "$backend disconnect: native error delivered" +} + +unset WAYLAND_DISPLAY WAYLAND_SOCKET +cargo test --lib watcher --all-features +cargo build --example watch --all-features +start_x11 +cargo test --test watcher --all-features -- --ignored +cargo run --quiet --example watch --all-features -- --self-test +check_disconnect X11 + +start_x11 -extension XFIXES +if cargo run --quiet --example watch --all-features >"$run_dir/unsupported.log" 2>&1; then + echo "The watcher accepted a server without XFixes." >&2 + exit 1 +fi +grep -q ClipboardNotSupported "$run_dir/unsupported.log" +echo "X11 without XFixes: explicit unsupported error" +kill "$server" +wait "$server" || true +server= + +export XDG_RUNTIME_DIR="$run_dir/runtime" +export WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 +unset DISPLAY +sway -c /dev/null >"$run_dir/sway.log" 2>&1 & +server=$! +socket= +for _ in {1..100}; do + kill -0 "$server" + socket=$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -type s -name 'wayland-*' -print -quit) + [ -z "$socket" ] || break + sleep 0.1 +done +test -n "$socket" +WAYLAND_DISPLAY=$(basename "$socket") +export WAYLAND_DISPLAY +cargo test --test watcher --all-features -- --ignored +cargo run --quiet --example watch --all-features -- --self-test +check_disconnect Wayland +echo "Native watcher validation passed. Logs: $run_dir"