authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 00:14:02-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 17:58:59-07:00
log01c718333a8b2a5e8afe547db7ace2aef0215179
tree1054a923fbfe06d3a0dd80aad4b258b092898302
parentcc25073ba74de4601e128acde1e294cc4bf1134a
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

chore: Linux serves the Wayland clipboard from the event loop

The clipboard now lives on winit's own Wayland connection, on a queue the event loop dispatches, instead of smithay-clipboard's thread: other apps' paste requests wake the event loop, which answers them, now that no dialog holds it up. Files, pages and pictures are read from the Wayland selection itself rather than through a new X11 connection to Xwayland on every paste, so they paste on compositors without Xwayland or that share the selection with it only while an X11 window has focus. Copy now offers HTML and Snowbound's own format on Wayland too, beside the text. X11 sessions keep arboard. The clipboard borrows winit's connection through a frame callback moved onto winit's event queue. libwayland names a proxy's queue only from 1.23 (wl_proxy_get_queue), so it is read from struct wl_proxy, laid out alike since 1.0. smithay-clipboard goes, and with it the second smithay-client-toolkit and calloop, calloop-wayland-source, wayland-protocols-experimental and wayland-protocols-misc. Assisted-by: claude-opus-5.5

6 files changed, 593 insertions(+), 200 deletions(-)

Cargo.lock+7-97
...@@ -601,43 +601,18 @@ dependencies = [...@@ -601,43 +601,18 @@ dependencies = [
601 "thiserror 1.0.69",601 "thiserror 1.0.69",
602]602]
603603
604[[package]]
605name = "calloop"
606version = "0.14.4"
607source = "registry+https://github.com/rust-lang/crates.io-index"
608checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
609dependencies = [
610 "bitflags 2.13.1",
611 "polling",
612 "rustix 1.1.4",
613 "slab",
614 "tracing",
615]
616
617[[package]]604[[package]]
618name = "calloop-wayland-source"605name = "calloop-wayland-source"
619version = "0.3.0"606version = "0.3.0"
620source = "registry+https://github.com/rust-lang/crates.io-index"607source = "registry+https://github.com/rust-lang/crates.io-index"
621checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"608checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
622dependencies = [609dependencies = [
623 "calloop 0.13.0",610 "calloop",
624 "rustix 0.38.44",611 "rustix 0.38.44",
625 "wayland-backend",612 "wayland-backend",
626 "wayland-client",613 "wayland-client",
627]614]
628615
629[[package]]
630name = "calloop-wayland-source"
631version = "0.4.1"
632source = "registry+https://github.com/rust-lang/crates.io-index"
633checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
634dependencies = [
635 "calloop 0.14.4",
636 "rustix 1.1.4",
637 "wayland-backend",
638 "wayland-client",
639]
640
641[[package]]616[[package]]
642name = "canvas"617name = "canvas"
643version = "0.1.0"618version = "0.1.0"
...@@ -3377,7 +3352,7 @@ dependencies = [...@@ -3377,7 +3352,7 @@ dependencies = [
3377 "ab_glyph",3352 "ab_glyph",
3378 "log",3353 "log",
3379 "memmap2",3354 "memmap2",
3380 "smithay-client-toolkit 0.19.2",3355 "smithay-client-toolkit",
3381 "tiny-skia",3356 "tiny-skia",
3382]3357]
33833358
...@@ -3604,8 +3579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -3604,8 +3579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3604checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"3579checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
3605dependencies = [3580dependencies = [
3606 "bitflags 2.13.1",3581 "bitflags 2.13.1",
3607 "calloop 0.13.0",3582 "calloop",
3608 "calloop-wayland-source 0.3.0",3583 "calloop-wayland-source",
3609 "cursor-icon",3584 "cursor-icon",
3610 "libc",3585 "libc",
3611 "log",3586 "log",
...@@ -3622,44 +3597,6 @@ dependencies = [...@@ -3622,44 +3597,6 @@ dependencies = [
3622 "xkeysym",3597 "xkeysym",
3623]3598]
36243599
3625[[package]]
3626name = "smithay-client-toolkit"
3627version = "0.20.0"
3628source = "registry+https://github.com/rust-lang/crates.io-index"
3629checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
3630dependencies = [
3631 "bitflags 2.13.1",
3632 "calloop 0.14.4",
3633 "calloop-wayland-source 0.4.1",
3634 "cursor-icon",
3635 "libc",
3636 "log",
3637 "memmap2",
3638 "rustix 1.1.4",
3639 "thiserror 2.0.20",
3640 "wayland-backend",
3641 "wayland-client",
3642 "wayland-csd-frame",
3643 "wayland-cursor",
3644 "wayland-protocols",
3645 "wayland-protocols-experimental",
3646 "wayland-protocols-misc",
3647 "wayland-protocols-wlr",
3648 "wayland-scanner",
3649 "xkeysym",
3650]
3651
3652[[package]]
3653name = "smithay-clipboard"
3654version = "0.7.3"
3655source = "registry+https://github.com/rust-lang/crates.io-index"
3656checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
3657dependencies = [
3658 "libc",
3659 "smithay-client-toolkit 0.20.0",
3660 "wayland-backend",
3661]
3662
3663[[package]]3600[[package]]
3664name = "smol_str"3601name = "smol_str"
3665version = "0.2.2"3602version = "0.2.2"
...@@ -3700,7 +3637,6 @@ dependencies = [...@@ -3700,7 +3637,6 @@ dependencies = [
3700 "sctk-adwaita",3637 "sctk-adwaita",
3701 "serde",3638 "serde",
3702 "serde_json",3639 "serde_json",
3703 "smithay-clipboard",
3704 "spellbook",3640 "spellbook",
3705 "ui",3641 "ui",
3706 "unicode_names2",3642 "unicode_names2",
...@@ -3710,6 +3646,7 @@ dependencies = [...@@ -3710,6 +3646,7 @@ dependencies = [
3710 "wayland-backend",3646 "wayland-backend",
3711 "wayland-client",3647 "wayland-client",
3712 "wayland-protocols",3648 "wayland-protocols",
3649 "wayland-sys",
3713 "web-sys",3650 "web-sys",
3714 "web-time",3651 "web-time",
3715 "webpki-root-certs",3652 "webpki-root-certs",
...@@ -4067,7 +4004,6 @@ version = "0.1.44"...@@ -4067,7 +4004,6 @@ version = "0.1.44"
4067source = "registry+https://github.com/rust-lang/crates.io-index"4004source = "registry+https://github.com/rust-lang/crates.io-index"
4068checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"4005checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
4069dependencies = [4006dependencies = [
4070 "log",
4071 "pin-project-lite",4007 "pin-project-lite",
4072 "tracing-attributes",4008 "tracing-attributes",
4073 "tracing-core",4009 "tracing-core",
...@@ -4420,32 +4356,6 @@ dependencies = [...@@ -4420,32 +4356,6 @@ dependencies = [
4420 "wayland-scanner",4356 "wayland-scanner",
4421]4357]
44224358
4423[[package]]
4424name = "wayland-protocols-experimental"
4425version = "20250721.0.1"
4426source = "registry+https://github.com/rust-lang/crates.io-index"
4427checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
4428dependencies = [
4429 "bitflags 2.13.1",
4430 "wayland-backend",
4431 "wayland-client",
4432 "wayland-protocols",
4433 "wayland-scanner",
4434]
4435
4436[[package]]
4437name = "wayland-protocols-misc"
4438version = "0.3.12"
4439source = "registry+https://github.com/rust-lang/crates.io-index"
4440checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
4441dependencies = [
4442 "bitflags 2.13.1",
4443 "wayland-backend",
4444 "wayland-client",
4445 "wayland-protocols",
4446 "wayland-scanner",
4447]
4448
4449[[package]]4359[[package]]
4450name = "wayland-protocols-plasma"4360name = "wayland-protocols-plasma"
4451version = "0.3.12"4361version = "0.3.12"
...@@ -5040,7 +4950,7 @@ dependencies = [...@@ -5040,7 +4950,7 @@ dependencies = [
5040 "bitflags 2.13.1",4950 "bitflags 2.13.1",
5041 "block2 0.5.1",4951 "block2 0.5.1",
5042 "bytemuck",4952 "bytemuck",
5043 "calloop 0.13.0",4953 "calloop",
5044 "cfg_aliases",4954 "cfg_aliases",
5045 "concurrent-queue",4955 "concurrent-queue",
5046 "core-foundation 0.9.4",4956 "core-foundation 0.9.4",
...@@ -5062,7 +4972,7 @@ dependencies = [...@@ -5062,7 +4972,7 @@ dependencies = [
5062 "redox_syscall 0.4.1",4972 "redox_syscall 0.4.1",
5063 "rustix 0.38.44",4973 "rustix 0.38.44",
5064 "sctk-adwaita",4974 "sctk-adwaita",
5065 "smithay-client-toolkit 0.19.2",4975 "smithay-client-toolkit",
5066 "smol_str",4976 "smol_str",
5067 "tracing",4977 "tracing",
5068 "unicode-segmentation",4978 "unicode-segmentation",
arc/platforms.md+4-1
...@@ -104,7 +104,10 @@ keyboard, the toolbar and the macOS menu bar all run commands from it....@@ -104,7 +104,10 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
104 and so is Enchant, which checks spelling with whatever dictionaries its104 and so is Enchant, which checks spelling with whatever dictionaries its
105 providers have; without it, words go unmarked.105 providers have; without it, words go unmarked.
106- Wayland's clipboard goes through the window's own connection, since not every106- Wayland's clipboard goes through the window's own connection, since not every
107 compositor offers a clipboard to clients without a window.107 compositor offers a clipboard to clients without a window. Its queue wakes the
108 event loop, which answers other apps' pastes with no thread of its own. Text,
109 pages, pictures and files are read there too, not through Xwayland. X11
110 sessions use arboard.
108- The executable carries its desktop entry and icon (`desktop_linux.rs`). The111- The executable carries its desktop entry and icon (`desktop_linux.rs`). The
109 window is `net.paperclover.snowbound` to Wayland and X11 alike. Where a112 window is `net.paperclover.snowbound` to Wayland and X11 alike. Where a
110 Wayland compositor lacks xdg-toplevel-icon, as GNOME's does, it finds the113 Wayland compositor lacks xdg-toplevel-icon, as GNOME's does, it finds the
crates/snowbound/Cargo.toml+1-1
...@@ -103,7 +103,6 @@ windows-sys = { version = "0.61", features = [...@@ -103,7 +103,6 @@ windows-sys = { version = "0.61", features = [
103103
104[target.'cfg(target_os = "linux")'.dependencies]104[target.'cfg(target_os = "linux")'.dependencies]
105accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] }105accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] }
106smithay-clipboard = "0.7.3"
107# Scales the window's icon.106# Scales the window's icon.
108image = { version = "0.25", default-features = false }107image = { version = "0.25", default-features = false }
109# Loads fontconfig at run time, so builds need no fontconfig headers or library.108# Loads fontconfig at run time, so builds need no fontconfig headers or library.
...@@ -118,6 +117,7 @@ sctk-adwaita = { version = "0.10.1", default-features = false }...@@ -118,6 +117,7 @@ sctk-adwaita = { version = "0.10.1", default-features = false }
118wayland-backend = { version = "0.3.17", features = ["client_system"] }117wayland-backend = { version = "0.3.17", features = ["client_system"] }
119wayland-client = "0.31.15"118wayland-client = "0.31.15"
120wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }119wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
120wayland-sys = { version = "0.31.11", features = ["client"] }
121# Brings the window forward on X11, on a connection of its own.121# Brings the window forward on X11, on a connection of its own.
122x11rb = "0.13.2"122x11rb = "0.13.2"
123123
crates/snowbound/src/clipboard_linux.rs created+567
...@@ -0,0 +1,567 @@
1//! The clipboard on Linux. On Wayland it lives on winit's own connection, so other apps'
2//! paste requests are answered on the event loop as they arrive, with no thread of its own;
3//! text, pages, pictures and files are all read from the selection there. On X11, arboard's.
4
5use std::{
6 error::Error,
7 io::Read,
8 os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd},
9 path::PathBuf,
10 sync::{Arc, Mutex},
11 task::{Context, Wake, Waker},
12 time::{Duration, Instant},
13};
14use wayland_client::{
15 Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum,
16 backend::{Backend, ObjectData, ObjectId, protocol::Message},
17 protocol::{
18 wl_data_device::{self, WlDataDevice},
19 wl_data_device_manager::WlDataDeviceManager,
20 wl_data_offer::{self, WlDataOffer},
21 wl_data_source::{self, WlDataSource},
22 wl_keyboard::{self, WlKeyboard},
23 wl_pointer::{self, WlPointer},
24 wl_registry::{self, WlRegistry},
25 wl_seat::{self, WlSeat},
26 wl_surface::{self, WlSurface},
27 },
28};
29use wayland_sys::{
30 client::{wayland_client_handle, wl_event_queue, wl_proxy},
31 ffi_dispatch,
32};
33use winit::{
34 raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle},
35 window::Window,
36};
37
38/// The MIME types text is offered and read as, the most precise first.
39const TEXT: [&str; 4] = [
40 "text/plain;charset=utf-8",
41 "UTF8_STRING",
42 "text/plain",
43 "STRING",
44];
45/// Snowbound's own format, [`crate::paste::Copied::clip`].
46const CLIP: &str = "application/x-snowbound-clip";
47/// How long a paste waits on the app that copied.
48const PATIENCE: Duration = Duration::from_secs(2);
49
50/// The HTML and Snowbound's own format of this process's last copy through X11.
51static LAST_COPY: Mutex<Option<(String, String)>> = Mutex::new(None);
52
53pub enum Clipboard {
54 Wayland(Box<Wayland>),
55 X11(arboard::Clipboard),
56}
57
58impl Clipboard {
59 pub fn new(window: &Window) -> Result<Self, arboard::Error> {
60 let handles = window
61 .display_handle()
62 .ok()
63 .zip(window.window_handle().ok());
64 match handles.map(|(display, window)| (display.as_raw(), window.as_raw())) {
65 Some((RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(window))) => Ok(
66 Self::Wayland(Box::new(Wayland::new(display.display, window.surface))),
67 ),
68 _ => arboard::Clipboard::new().map(Self::X11),
69 }
70 }
71
72 pub fn set_text(&mut self, text: String) -> Result<(), Box<dyn Error>> {
73 match self {
74 Self::Wayland(wayland) => {
75 wayland.copy(TEXT.map(|kind| (kind, text.clone().into_bytes())).to_vec())
76 }
77 Self::X11(clipboard) => Ok(clipboard.set_text(text)?),
78 }
79 }
80
81 /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`].
82 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box<dyn Error>> {
83 match self {
84 Self::Wayland(wayland) => {
85 let text = TEXT.map(|kind| (kind, copied.text.clone().into_bytes()));
86 let rich = [("text/html", &copied.html), (CLIP, &copied.clip)]
87 .map(|(kind, bytes)| (kind, bytes.clone().into_bytes()));
88 wayland.copy(text.into_iter().chain(rich).collect())
89 }
90 Self::X11(clipboard) => {
91 clipboard.set().html(&copied.html, Some(&copied.text))?;
92 *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone()));
93 Ok(())
94 }
95 }
96 }
97
98 /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of
99 /// Snowbound's own through, the last copy's, while the clipboard holds its HTML.
100 pub fn get_clip(&mut self) -> Option<String> {
101 if let Self::Wayland(wayland) = self {
102 return String::from_utf8(wayland.paste(CLIP)?).ok();
103 }
104 let html = self.get_html()?;
105 match &*LAST_COPY.lock().unwrap() {
106 Some((copied, clip)) if *copied == html => Some(clip.clone()),
107 _ => None,
108 }
109 }
110
111 pub fn get_text(&mut self) -> Result<String, Box<dyn Error>> {
112 match self {
113 Self::Wayland(wayland) => {
114 let bytes = TEXT.iter().find_map(|kind| wayland.paste(kind));
115 Ok(String::from_utf8_lossy(&bytes.unwrap_or_default()).into_owned())
116 }
117 Self::X11(clipboard) => Ok(clipboard.get_text()?),
118 }
119 }
120
121 pub fn get_files(&mut self) -> Vec<PathBuf> {
122 use std::os::unix::ffi::OsStrExt;
123 match self {
124 Self::Wayland(wayland) => files(&wayland.paste("text/uri-list").unwrap_or_default()),
125 // arboard splits text/uri-list at LF and keeps the CR its CRLF lines end in.
126 Self::X11(clipboard) => (clipboard.get().file_list().unwrap_or_default().into_iter())
127 .map(|path| {
128 let bytes = path.as_os_str().as_bytes();
129 let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes);
130 std::ffi::OsStr::from_bytes(bytes).into()
131 })
132 .collect(),
133 }
134 }
135
136 pub fn get_html(&mut self) -> Option<String> {
137 match self {
138 Self::Wayland(wayland) => wayland
139 .paste("text/html")
140 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()),
141 Self::X11(clipboard) => clipboard.get().html().ok(),
142 }
143 }
144
145 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
146 match self {
147 Self::Wayland(wayland) => ["image/png", "image/jpeg", "image/gif"]
148 .iter()
149 .find_map(|kind| wayland.paste(kind)),
150 Self::X11(clipboard) => crate::paste::bitmap(clipboard.get_image().ok()?),
151 }
152 }
153
154 /// Answers what the compositor asked of the clipboard since it last woke the event loop.
155 pub fn serve(&mut self) {
156 if let Self::Wayland(wayland) = self {
157 wayland.serve();
158 }
159 }
160}
161
162/// The local files a `text/uri-list` names; its lines end in CRLF, though some apps end
163/// them in LF alone.
164fn files(list: &[u8]) -> Vec<PathBuf> {
165 use std::os::unix::ffi::OsStringExt;
166 String::from_utf8_lossy(list)
167 .lines()
168 .map(|line| line.trim_end_matches('\r'))
169 .filter(|line| !line.starts_with('#'))
170 .filter_map(|uri| {
171 let path = uri.strip_prefix("file://")?;
172 let path = path.strip_prefix("localhost").unwrap_or(path);
173 path.starts_with('/')
174 .then(|| std::ffi::OsString::from_vec(crate::paste::percent_decode(path)).into())
175 })
176 .collect()
177}
178
179/// A copy's bytes in each MIME type it is offered as.
180type Formats = Vec<(&'static str, Vec<u8>)>;
181
182/// The Wayland clipboard, on winit's connection once the window's first frame lends it.
183pub struct Wayland {
184 lent: Arc<Mutex<Option<Backend>>>,
185 joined: Option<(Connection, EventQueue<Seat>, Seat)>,
186}
187
188/// What the clipboard knows of the seat: the selection and the serial to claim it with.
189#[derive(Default)]
190struct Seat {
191 seat: Option<WlSeat>,
192 manager: Option<WlDataDeviceManager>,
193 device: Option<WlDataDevice>,
194 keyboard: Option<WlKeyboard>,
195 pointer: Option<WlPointer>,
196 /// The latest input event's, which claiming the selection must name.
197 serial: u32,
198 /// The selection another app offers.
199 offer: Option<WlDataOffer>,
200 /// What this app copied, in each format it offers, while it holds the selection.
201 copied: Option<(WlDataSource, Formats)>,
202}
203
204/// Wakes the event loop to serve the clipboard.
205struct Serve;
206
207impl Wake for Serve {
208 fn wake(self: Arc<Self>) {
209 if let Some(proxy) = crate::platform::PROXY.get() {
210 let _ = proxy.send_event(crate::UserEvent::Clipboard);
211 }
212 }
213}
214
215/// Takes winit's connection from the event that reaches it, a frame callback moved onto
216/// winit's queue: winit dispatches only that queue, with its own connection.
217struct Lend(Arc<Mutex<Option<Backend>>>);
218
219impl ObjectData for Lend {
220 fn event(
221 self: Arc<Self>,
222 backend: &Backend,
223 _: Message<ObjectId, OwnedFd>,
224 ) -> Option<Arc<dyn ObjectData>> {
225 *self.0.lock().unwrap() = Some(backend.clone());
226 Arc::new(Serve).wake();
227 None
228 }
229
230 fn destroyed(&self, _: ObjectId) {}
231}
232
233impl Wayland {
234 fn new(
235 display: std::ptr::NonNull<std::ffi::c_void>,
236 surface: std::ptr::NonNull<std::ffi::c_void>,
237 ) -> Self {
238 let lent = Arc::new(Mutex::new(None));
239 // Safety: winit's display and surface outlive the window, which outlives this.
240 let backend = unsafe { Backend::from_foreign_display(display.as_ptr().cast()) };
241 let connection = Connection::from_backend(backend);
242 let surface =
243 unsafe { ObjectId::from_ptr(WlSurface::interface(), surface.as_ptr().cast()) }
244 .and_then(|id| WlSurface::from_id(&connection, id));
245 if let Ok(surface) = surface {
246 let lend: Arc<dyn ObjectData> = Arc::new(Lend(Arc::clone(&lent)));
247 let frame = wl_surface::Request::Frame {};
248 if let Ok(callback) = connection.send_request(&surface, frame, Some(lend)) {
249 // Safety: both proxies are live, and the callback has had no event yet.
250 unsafe { onto_queue_of(callback.as_ptr(), surface.id().as_ptr()) };
251 }
252 let _ = connection.flush();
253 }
254 // The callback, on winit's queue, ends under winit's connection, which leaves this one
255 // a record of it that dropping it would free a second time.
256 std::mem::forget(connection);
257 Self { lent, joined: None }
258 }
259
260 /// Dispatches the seat's events and asks to be woken for the next; first joins winit's
261 /// connection once lent.
262 fn serve(&mut self) {
263 if self.joined.is_none()
264 && let Some(backend) = self.lent.lock().unwrap().take()
265 {
266 let connection = Connection::from_backend(backend);
267 let queue = connection.new_event_queue();
268 connection.display().get_registry(&queue.handle(), ());
269 self.joined = Some((connection, queue, Seat::default()));
270 }
271 if let Some((_, queue, seat)) = &mut self.joined {
272 let waker = Waker::from(Arc::new(Serve));
273 if let std::task::Poll::Ready(Err(error)) =
274 queue.poll_dispatch_pending(&mut Context::from_waker(&waker), seat)
275 {
276 eprintln!("The clipboard stopped: {error}");
277 self.joined = None;
278 }
279 }
280 }
281
282 fn copy(&mut self, formats: Formats) -> Result<(), Box<dyn Error>> {
283 self.serve();
284 let Some((connection, queue, seat)) = &mut self.joined else {
285 return Err("The clipboard isn't ready yet.".into());
286 };
287 let (Some(manager), Some(device)) = (&seat.manager, &seat.device) else {
288 return Err("The compositor offers no clipboard.".into());
289 };
290 let source = manager.create_data_source(&queue.handle(), ());
291 for (kind, _) in &formats {
292 source.offer(kind.to_string());
293 }
294 device.set_selection(Some(&source), seat.serial);
295 // The source replaced is cancelled, and destroyed then.
296 seat.copied = Some((source, formats));
297 connection.flush()?;
298 Ok(())
299 }
300
301 /// The selection as `kind`, where it is offered as that: this app's own copy without a
302 /// round trip through the compositor, or what another app answers.
303 fn paste(&mut self, kind: &str) -> Option<Vec<u8>> {
304 self.serve();
305 let (connection, _, seat) = self.joined.as_ref()?;
306 if let Some((_, formats)) = &seat.copied {
307 return offered(formats, kind).map(<[u8]>::to_vec);
308 }
309 let offer = seat.offer.as_ref()?;
310 let kinds = offer.data::<Mutex<Vec<String>>>()?;
311 if !kinds.lock().unwrap().iter().any(|offered| offered == kind) {
312 return None;
313 }
314 let (mut reader, writer) = std::io::pipe().ok()?;
315 offer.receive(kind.to_owned(), writer.as_fd());
316 drop(writer);
317 connection.flush().ok()?;
318 let deadline = Instant::now() + PATIENCE;
319 let mut bytes = Vec::new();
320 loop {
321 if !ready(reader.as_fd(), libc::POLLIN, deadline) {
322 eprintln!("The app that copied didn't answer the paste in time.");
323 return None;
324 }
325 let mut buffer = [0; 1 << 16];
326 match reader.read(&mut buffer) {
327 Ok(0) => return Some(bytes),
328 Ok(read) => bytes.extend_from_slice(&buffer[..read]),
329 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
330 Err(_) => return None,
331 }
332 }
333 }
334}
335
336/// Moves `proxy` onto `owner`'s event queue. libwayland names a proxy's queue only from 1.23
337/// (`wl_proxy_get_queue`), so this reads it from `struct wl_proxy`, laid out alike since 1.0.
338unsafe fn onto_queue_of(proxy: *mut wl_proxy, owner: *mut wl_proxy) {
339 #[repr(C)]
340 struct Head {
341 interface: *const std::ffi::c_void,
342 implementation: *const std::ffi::c_void,
343 id: u32,
344 display: *mut std::ffi::c_void,
345 queue: *mut wl_event_queue,
346 }
347 let queue = unsafe { (*owner.cast::<Head>()).queue };
348 unsafe { ffi_dispatch!(wayland_client_handle(), wl_proxy_set_queue, proxy, queue) };
349}
350
351/// The bytes of `formats` in the format `kind`.
352fn offered<'a>(formats: &'a [(&str, Vec<u8>)], kind: &str) -> Option<&'a [u8]> {
353 let (_, bytes) = formats.iter().find(|(offered, _)| *offered == kind)?;
354 Some(bytes)
355}
356
357/// Whether `fd` is ready for `events` before `deadline`.
358fn ready(fd: BorrowedFd, events: i16, deadline: Instant) -> bool {
359 let left = deadline.saturating_duration_since(Instant::now());
360 let mut poll = libc::pollfd {
361 fd: fd.as_raw_fd(),
362 events,
363 revents: 0,
364 };
365 !left.is_zero() && unsafe { libc::poll(&mut poll, 1, left.as_millis() as i32) } > 0
366}
367
368/// Writes `bytes` to the pipe `fd`, waiting on a slow reader no longer than a paste waits.
369fn answer(fd: OwnedFd, bytes: &[u8]) {
370 use std::io::Write;
371 let mut pipe = std::fs::File::from(fd);
372 let deadline = Instant::now() + PATIENCE;
373 // A pipe ready for writing takes PIPE_BUF bytes without blocking.
374 for chunk in bytes.chunks(libc::PIPE_BUF) {
375 if !ready(pipe.as_fd(), libc::POLLOUT, deadline) || pipe.write_all(chunk).is_err() {
376 return;
377 }
378 }
379}
380
381impl Dispatch<WlRegistry, ()> for Seat {
382 fn event(
383 seat: &mut Self,
384 registry: &WlRegistry,
385 event: wl_registry::Event,
386 _: &(),
387 _: &Connection,
388 queue: &QueueHandle<Self>,
389 ) {
390 let wl_registry::Event::Global {
391 name,
392 interface,
393 version,
394 } = event
395 else {
396 return;
397 };
398 match interface.as_str() {
399 "wl_seat" if seat.seat.is_none() => {
400 seat.seat = Some(registry.bind(name, version.min(5), queue, ()));
401 }
402 "wl_data_device_manager" if seat.manager.is_none() => {
403 seat.manager = Some(registry.bind(name, version.min(3), queue, ()));
404 }
405 _ => return,
406 }
407 if let (Some(manager), Some(wl_seat), None) = (&seat.manager, &seat.seat, &seat.device) {
408 seat.device = Some(manager.get_data_device(wl_seat, queue, ()));
409 }
410 }
411}
412
413impl Dispatch<WlSeat, ()> for Seat {
414 fn event(
415 seat: &mut Self,
416 wl_seat: &WlSeat,
417 event: wl_seat::Event,
418 _: &(),
419 _: &Connection,
420 queue: &QueueHandle<Self>,
421 ) {
422 let wl_seat::Event::Capabilities {
423 capabilities: WEnum::Value(capabilities),
424 } = event
425 else {
426 return;
427 };
428 let keyboard = capabilities.contains(wl_seat::Capability::Keyboard);
429 if keyboard != seat.keyboard.is_some() {
430 seat.keyboard = keyboard.then(|| wl_seat.get_keyboard(queue, ()));
431 }
432 let pointer = capabilities.contains(wl_seat::Capability::Pointer);
433 if pointer != seat.pointer.is_some() {
434 seat.pointer = pointer.then(|| wl_seat.get_pointer(queue, ()));
435 }
436 }
437}
438
439impl Dispatch<WlKeyboard, ()> for Seat {
440 fn event(
441 seat: &mut Self,
442 _: &WlKeyboard,
443 event: wl_keyboard::Event,
444 _: &(),
445 _: &Connection,
446 _: &QueueHandle<Self>,
447 ) {
448 if let wl_keyboard::Event::Enter { serial, .. } | wl_keyboard::Event::Key { serial, .. } =
449 event
450 {
451 seat.serial = serial;
452 }
453 }
454}
455
456impl Dispatch<WlPointer, ()> for Seat {
457 fn event(
458 seat: &mut Self,
459 _: &WlPointer,
460 event: wl_pointer::Event,
461 _: &(),
462 _: &Connection,
463 _: &QueueHandle<Self>,
464 ) {
465 if let wl_pointer::Event::Enter { serial, .. } | wl_pointer::Event::Button { serial, .. } =
466 event
467 {
468 seat.serial = serial;
469 }
470 }
471}
472
473impl Dispatch<WlDataDeviceManager, ()> for Seat {
474 fn event(
475 _: &mut Self,
476 _: &WlDataDeviceManager,
477 _: <WlDataDeviceManager as Proxy>::Event,
478 _: &(),
479 _: &Connection,
480 _: &QueueHandle<Self>,
481 ) {
482 }
483}
484
485impl Dispatch<WlDataDevice, ()> for Seat {
486 fn event(
487 seat: &mut Self,
488 _: &WlDataDevice,
489 event: wl_data_device::Event,
490 _: &(),
491 _: &Connection,
492 _: &QueueHandle<Self>,
493 ) {
494 match event {
495 wl_data_device::Event::Selection { id } => {
496 if let Some(old) = std::mem::replace(&mut seat.offer, id) {
497 old.destroy();
498 }
499 }
500 // Drags aren't taken.
501 wl_data_device::Event::Enter {
502 id: Some(offer), ..
503 } => offer.destroy(),
504 _ => {}
505 }
506 }
507
508 wayland_client::event_created_child!(Seat, WlDataDevice, [
509 wl_data_device::EVT_DATA_OFFER_OPCODE => (WlDataOffer, Mutex::new(Vec::<String>::new())),
510 ]);
511}
512
513impl Dispatch<WlDataOffer, Mutex<Vec<String>>> for Seat {
514 fn event(
515 _: &mut Self,
516 _: &WlDataOffer,
517 event: wl_data_offer::Event,
518 kinds: &Mutex<Vec<String>>,
519 _: &Connection,
520 _: &QueueHandle<Self>,
521 ) {
522 if let wl_data_offer::Event::Offer { mime_type } = event {
523 kinds.lock().unwrap().push(mime_type);
524 }
525 }
526}
527
528impl Dispatch<WlDataSource, ()> for Seat {
529 fn event(
530 seat: &mut Self,
531 source: &WlDataSource,
532 event: wl_data_source::Event,
533 _: &(),
534 _: &Connection,
535 _: &QueueHandle<Self>,
536 ) {
537 let copied = seat.copied.as_ref().filter(|(copied, _)| copied == source);
538 match event {
539 wl_data_source::Event::Send { mime_type, fd } => {
540 if let Some(bytes) = copied.and_then(|(_, formats)| offered(formats, &mime_type)) {
541 answer(fd, bytes);
542 }
543 }
544 wl_data_source::Event::Cancelled => {
545 if copied.is_some() {
546 seat.copied = None;
547 }
548 source.destroy();
549 }
550 _ => {}
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn uri_lists_name_local_files_whatever_their_line_ends() {
561 let list = b"# copied\r\nfile:///home/ben/Notes%20One.one\r\nfile://localhost/tmp/a.png\nhttps://example.com/b.png\r\n";
562 assert_eq!(
563 files(list),
564 ["/home/ben/Notes One.one", "/tmp/a.png"].map(PathBuf::from)
565 );
566 }
567}
crates/snowbound/src/linux.rs+1-100
...@@ -212,106 +212,7 @@ pub fn install_title_bar(window: &Window) {...@@ -212,106 +212,7 @@ pub fn install_title_bar(window: &Window) {
212 crate::desktop::set_toplevel_icon(window);212 crate::desktop::set_toplevel_icon(window);
213}213}
214214
215/// The HTML and Snowbound's own format of this process's last copy through X11.215pub use crate::clipboard::Clipboard;
216static LAST_COPY: std::sync::Mutex<Option<(String, String)>> = std::sync::Mutex::new(None);
217
218/// Wayland's clipboard through the window's own connection, as not every compositor offers
219/// a clipboard to clients without a window; X11's otherwise.
220pub enum Clipboard {
221 Wayland(smithay_clipboard::Clipboard),
222 X11(arboard::Clipboard),
223}
224
225impl Clipboard {
226 pub fn new(window: &Window) -> Result<Self, arboard::Error> {
227 use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
228 match window.display_handle().map(|handle| handle.as_raw()) {
229 // The display outlives the clipboard, which the window's state owns.
230 Ok(RawDisplayHandle::Wayland(handle)) => Ok(Self::Wayland(unsafe {
231 smithay_clipboard::Clipboard::new(handle.display.as_ptr())
232 })),
233 _ => arboard::Clipboard::new().map(Self::X11),
234 }
235 }
236
237 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
238 match self {
239 Self::Wayland(clipboard) => {
240 clipboard.store(text);
241 Ok(())
242 }
243 Self::X11(clipboard) => clipboard.set_text(text),
244 }
245 }
246
247 /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`].
248 /// smithay-clipboard offers text alone.
249 pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), arboard::Error> {
250 match self {
251 Self::Wayland(clipboard) => {
252 clipboard.store(copied.text.clone());
253 Ok(())
254 }
255 Self::X11(clipboard) => {
256 clipboard.set().html(&copied.html, Some(&copied.text))?;
257 *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone()));
258 Ok(())
259 }
260 }
261 }
262
263 /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of
264 /// Snowbound's own through, the last copy's, while the clipboard holds its HTML.
265 pub fn get_clip(&mut self) -> Option<String> {
266 if !matches!(self, Self::X11(_)) {
267 return None;
268 }
269 let html = self.get_html()?;
270 match &*LAST_COPY.lock().unwrap() {
271 Some((copied, clip)) if *copied == html => Some(clip.clone()),
272 _ => None,
273 }
274 }
275
276 pub fn get_text(&mut self) -> Result<String, Box<dyn std::error::Error>> {
277 Ok(match self {
278 Self::Wayland(clipboard) => clipboard.load()?,
279 Self::X11(clipboard) => clipboard.get_text()?,
280 })
281 }
282
283 pub fn get_files(&mut self) -> Vec<std::path::PathBuf> {
284 use std::os::unix::ffi::OsStrExt;
285 let files = self.through_x11(|clipboard| clipboard.get().file_list().ok());
286 // arboard splits text/uri-list at LF and keeps the CR its CRLF lines end in.
287 let path = |path: std::path::PathBuf| {
288 let bytes = path.as_os_str().as_bytes();
289 let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes);
290 std::ffi::OsStr::from_bytes(bytes).into()
291 };
292 files.unwrap_or_default().into_iter().map(path).collect()
293 }
294
295 pub fn get_html(&mut self) -> Option<String> {
296 self.through_x11(|clipboard| clipboard.get().html().ok())
297 }
298
299 pub fn get_picture(&mut self) -> Option<Vec<u8>> {
300 self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?))
301 }
302
303 /// Files, pages and pictures come through X11 on Wayland too, whose clipboard Xwayland
304 /// shares.
305 fn through_x11<T>(
306 &mut self,
307 read: impl FnOnce(&mut arboard::Clipboard) -> Option<T>,
308 ) -> Option<T> {
309 match self {
310 Self::Wayland(_) => read(&mut arboard::Clipboard::new().ok()?),
311 Self::X11(clipboard) => read(clipboard),
312 }
313 }
314}
315216
316/// Whether the system draws the title bar: the window manager, or on Wayland the compositor.217/// Whether the system draws the title bar: the window manager, or on Wayland the compositor.
317/// Where winit's Adwaita frame decorates the window instead, as on GNOME, the frame has no218/// Where winit's Adwaita frame decorates the window instead, as on GNOME, the frame has no
crates/snowbound/src/main.rs+13-1
...@@ -6,6 +6,9 @@ mod aqua;...@@ -6,6 +6,9 @@ mod aqua;
6mod art;6mod art;
7mod attachment;7mod attachment;
8mod background;8mod background;
9#[cfg(target_os = "linux")]
10#[path = "clipboard_linux.rs"]
11mod clipboard;
9mod commands;12mod commands;
10#[cfg(all(test, feature = "wgpu", not(windows)))]13#[cfg(all(test, feature = "wgpu", not(windows)))]
11mod conflict_render;14mod conflict_render;
...@@ -278,6 +281,9 @@ fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply...@@ -278,6 +281,9 @@ fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply
278281
279enum UserEvent {282enum UserEvent {
280 Quit,283 Quit,
284 /// The Wayland compositor asked something of the clipboard.
285 #[cfg(target_os = "linux")]
286 Clipboard,
281 /// Quits without asking, as after the user agreed to discard a temporary page.287 /// Quits without asking, as after the user agreed to discard a temporary page.
282 #[cfg(not(target_arch = "wasm32"))]288 #[cfg(not(target_arch = "wasm32"))]
283 Exit,289 Exit,
...@@ -6194,6 +6200,12 @@ impl State {...@@ -6194,6 +6200,12 @@ impl State {
6194 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {}6200 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {}
6195 #[cfg(not(target_arch = "wasm32"))]6201 #[cfg(not(target_arch = "wasm32"))]
6196 UserEvent::Exit => {}6202 UserEvent::Exit => {}
6203 #[cfg(target_os = "linux")]
6204 UserEvent::Clipboard => {
6205 if let Clipboard::System(clipboard) = &mut self.clipboard {
6206 clipboard.serve();
6207 }
6208 }
6197 UserEvent::Picture(bytes) => {6209 UserEvent::Picture(bytes) => {
6198 if let Err(error) = self.insert_picture(bytes, None) {6210 if let Err(error) = self.insert_picture(bytes, None) {
6199 eprintln!("{error}");6211 eprintln!("{error}");
...@@ -6623,7 +6635,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -6623,7 +6635,7 @@ impl ApplicationHandler<UserEvent> for App {
6623 }6635 }
66246636
6625 /// Drops the window's state while the event loop still holds the Wayland connection the6637 /// Drops the window's state while the event loop still holds the Wayland connection the
6626 /// clipboard's thread and the surface use; `run_app` closes it on returning.6638 /// clipboard and the surface use; `run_app` closes it on returning.
6627 fn exiting(&mut self, _: &ActiveEventLoop) {6639 fn exiting(&mut self, _: &ActiveEventLoop) {
6628 self.restart = self6640 self.restart = self
6629 .state6641 .state