Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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",
] }
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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
Expand Down
31 changes: 31 additions & 0 deletions examples/watch.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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(())
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
Loading