diff --git a/Cargo.lock b/Cargo.lock index 542707cc6ad71ccef512cef18e90500febe714bb..3118429d17ab66dc62060c56ada258e70c503d68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,43 +601,18 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.13.1", - "polling", - "rustix 1.1.4", - "slab", - "tracing", -] - [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop 0.13.0", + "calloop", "rustix 0.38.44", "wayland-backend", "wayland-client", ] -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.4", - "rustix 1.1.4", - "wayland-backend", - "wayland-client", -] - [[package]] name = "canvas" version = "0.1.0" @@ -3377,7 +3352,7 @@ dependencies = [ "ab_glyph", "log", "memmap2", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "tiny-skia", ] @@ -3604,8 +3579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.1", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", + "calloop", + "calloop-wayland-source", "cursor-icon", "libc", "log", @@ -3622,44 +3597,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.1", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.4", - "thiserror 2.0.20", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -3700,7 +3637,6 @@ dependencies = [ "sctk-adwaita", "serde", "serde_json", - "smithay-clipboard", "spellbook", "ui", "unicode_names2", @@ -3710,6 +3646,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", + "wayland-sys", "web-sys", "web-time", "webpki-root-certs", @@ -4067,7 +4004,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4420,32 +4356,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -5040,7 +4950,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "bytemuck", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -5062,7 +4972,7 @@ dependencies = [ "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "smol_str", "tracing", "unicode-segmentation", diff --git a/arc/platforms.md b/arc/platforms.md index 1565a69d970bbea08b7943c7beef654a0740d0e4..0afb491628c40898af1d56e5cb61ced2b7702c9c 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -104,7 +104,10 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. and so is Enchant, which checks spelling with whatever dictionaries its providers have; without it, words go unmarked. - Wayland's clipboard goes through the window's own connection, since not every - compositor offers a clipboard to clients without a window. + compositor offers a clipboard to clients without a window. Its queue wakes the + event loop, which answers other apps' pastes with no thread of its own. Text, + pages, pictures and files are read there too, not through Xwayland. X11 + sessions use arboard. - The executable carries its desktop entry and icon (`desktop_linux.rs`). The window is `net.paperclover.snowbound` to Wayland and X11 alike. Where a Wayland compositor lacks xdg-toplevel-icon, as GNOME's does, it finds the diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 3218e1b35d104a0d7c912b0ecafb5ccde8014078..034475a5319f09541541661141ce47d3d4788cc5 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -103,7 +103,6 @@ windows-sys = { version = "0.61", features = [ [target.'cfg(target_os = "linux")'.dependencies] accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] } -smithay-clipboard = "0.7.3" # Scales the window's icon. image = { version = "0.25", default-features = false } # 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 } wayland-backend = { version = "0.3.17", features = ["client_system"] } wayland-client = "0.31.15" wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } +wayland-sys = { version = "0.31.11", features = ["client"] } # Brings the window forward on X11, on a connection of its own. x11rb = "0.13.2" diff --git a/crates/snowbound/src/clipboard_linux.rs b/crates/snowbound/src/clipboard_linux.rs new file mode 100644 index 0000000000000000000000000000000000000000..59436b82f20801d269ff56a40b4cbcd4a929cb67 --- /dev/null +++ b/crates/snowbound/src/clipboard_linux.rs @@ -0,0 +1,567 @@ +//! The clipboard on Linux. On Wayland it lives on winit's own connection, so other apps' +//! paste requests are answered on the event loop as they arrive, with no thread of its own; +//! text, pages, pictures and files are all read from the selection there. On X11, arboard's. + +use std::{ + error::Error, + io::Read, + os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}, + path::PathBuf, + sync::{Arc, Mutex}, + task::{Context, Wake, Waker}, + time::{Duration, Instant}, +}; +use wayland_client::{ + Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum, + backend::{Backend, ObjectData, ObjectId, protocol::Message}, + protocol::{ + wl_data_device::{self, WlDataDevice}, + wl_data_device_manager::WlDataDeviceManager, + wl_data_offer::{self, WlDataOffer}, + wl_data_source::{self, WlDataSource}, + wl_keyboard::{self, WlKeyboard}, + wl_pointer::{self, WlPointer}, + wl_registry::{self, WlRegistry}, + wl_seat::{self, WlSeat}, + wl_surface::{self, WlSurface}, + }, +}; +use wayland_sys::{ + client::{wayland_client_handle, wl_event_queue, wl_proxy}, + ffi_dispatch, +}; +use winit::{ + raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle}, + window::Window, +}; + +/// The MIME types text is offered and read as, the most precise first. +const TEXT: [&str; 4] = [ + "text/plain;charset=utf-8", + "UTF8_STRING", + "text/plain", + "STRING", +]; +/// Snowbound's own format, [`crate::paste::Copied::clip`]. +const CLIP: &str = "application/x-snowbound-clip"; +/// How long a paste waits on the app that copied. +const PATIENCE: Duration = Duration::from_secs(2); + +/// The HTML and Snowbound's own format of this process's last copy through X11. +static LAST_COPY: Mutex> = Mutex::new(None); + +pub enum Clipboard { + Wayland(Box), + X11(arboard::Clipboard), +} + +impl Clipboard { + pub fn new(window: &Window) -> Result { + let handles = window + .display_handle() + .ok() + .zip(window.window_handle().ok()); + match handles.map(|(display, window)| (display.as_raw(), window.as_raw())) { + Some((RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(window))) => Ok( + Self::Wayland(Box::new(Wayland::new(display.display, window.surface))), + ), + _ => arboard::Clipboard::new().map(Self::X11), + } + } + + pub fn set_text(&mut self, text: String) -> Result<(), Box> { + match self { + Self::Wayland(wayland) => { + wayland.copy(TEXT.map(|kind| (kind, text.clone().into_bytes())).to_vec()) + } + Self::X11(clipboard) => Ok(clipboard.set_text(text)?), + } + } + + /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`]. + pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box> { + match self { + Self::Wayland(wayland) => { + let text = TEXT.map(|kind| (kind, copied.text.clone().into_bytes())); + let rich = [("text/html", &copied.html), (CLIP, &copied.clip)] + .map(|(kind, bytes)| (kind, bytes.clone().into_bytes())); + wayland.copy(text.into_iter().chain(rich).collect()) + } + Self::X11(clipboard) => { + clipboard.set().html(&copied.html, Some(&copied.text))?; + *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone())); + Ok(()) + } + } + } + + /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of + /// Snowbound's own through, the last copy's, while the clipboard holds its HTML. + pub fn get_clip(&mut self) -> Option { + if let Self::Wayland(wayland) = self { + return String::from_utf8(wayland.paste(CLIP)?).ok(); + } + let html = self.get_html()?; + match &*LAST_COPY.lock().unwrap() { + Some((copied, clip)) if *copied == html => Some(clip.clone()), + _ => None, + } + } + + pub fn get_text(&mut self) -> Result> { + match self { + Self::Wayland(wayland) => { + let bytes = TEXT.iter().find_map(|kind| wayland.paste(kind)); + Ok(String::from_utf8_lossy(&bytes.unwrap_or_default()).into_owned()) + } + Self::X11(clipboard) => Ok(clipboard.get_text()?), + } + } + + pub fn get_files(&mut self) -> Vec { + use std::os::unix::ffi::OsStrExt; + match self { + Self::Wayland(wayland) => files(&wayland.paste("text/uri-list").unwrap_or_default()), + // arboard splits text/uri-list at LF and keeps the CR its CRLF lines end in. + Self::X11(clipboard) => (clipboard.get().file_list().unwrap_or_default().into_iter()) + .map(|path| { + let bytes = path.as_os_str().as_bytes(); + let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes); + std::ffi::OsStr::from_bytes(bytes).into() + }) + .collect(), + } + } + + pub fn get_html(&mut self) -> Option { + match self { + Self::Wayland(wayland) => wayland + .paste("text/html") + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()), + Self::X11(clipboard) => clipboard.get().html().ok(), + } + } + + pub fn get_picture(&mut self) -> Option> { + match self { + Self::Wayland(wayland) => ["image/png", "image/jpeg", "image/gif"] + .iter() + .find_map(|kind| wayland.paste(kind)), + Self::X11(clipboard) => crate::paste::bitmap(clipboard.get_image().ok()?), + } + } + + /// Answers what the compositor asked of the clipboard since it last woke the event loop. + pub fn serve(&mut self) { + if let Self::Wayland(wayland) = self { + wayland.serve(); + } + } +} + +/// The local files a `text/uri-list` names; its lines end in CRLF, though some apps end +/// them in LF alone. +fn files(list: &[u8]) -> Vec { + use std::os::unix::ffi::OsStringExt; + String::from_utf8_lossy(list) + .lines() + .map(|line| line.trim_end_matches('\r')) + .filter(|line| !line.starts_with('#')) + .filter_map(|uri| { + let path = uri.strip_prefix("file://")?; + let path = path.strip_prefix("localhost").unwrap_or(path); + path.starts_with('/') + .then(|| std::ffi::OsString::from_vec(crate::paste::percent_decode(path)).into()) + }) + .collect() +} + +/// A copy's bytes in each MIME type it is offered as. +type Formats = Vec<(&'static str, Vec)>; + +/// The Wayland clipboard, on winit's connection once the window's first frame lends it. +pub struct Wayland { + lent: Arc>>, + joined: Option<(Connection, EventQueue, Seat)>, +} + +/// What the clipboard knows of the seat: the selection and the serial to claim it with. +#[derive(Default)] +struct Seat { + seat: Option, + manager: Option, + device: Option, + keyboard: Option, + pointer: Option, + /// The latest input event's, which claiming the selection must name. + serial: u32, + /// The selection another app offers. + offer: Option, + /// What this app copied, in each format it offers, while it holds the selection. + copied: Option<(WlDataSource, Formats)>, +} + +/// Wakes the event loop to serve the clipboard. +struct Serve; + +impl Wake for Serve { + fn wake(self: Arc) { + if let Some(proxy) = crate::platform::PROXY.get() { + let _ = proxy.send_event(crate::UserEvent::Clipboard); + } + } +} + +/// Takes winit's connection from the event that reaches it, a frame callback moved onto +/// winit's queue: winit dispatches only that queue, with its own connection. +struct Lend(Arc>>); + +impl ObjectData for Lend { + fn event( + self: Arc, + backend: &Backend, + _: Message, + ) -> Option> { + *self.0.lock().unwrap() = Some(backend.clone()); + Arc::new(Serve).wake(); + None + } + + fn destroyed(&self, _: ObjectId) {} +} + +impl Wayland { + fn new( + display: std::ptr::NonNull, + surface: std::ptr::NonNull, + ) -> Self { + let lent = Arc::new(Mutex::new(None)); + // Safety: winit's display and surface outlive the window, which outlives this. + let backend = unsafe { Backend::from_foreign_display(display.as_ptr().cast()) }; + let connection = Connection::from_backend(backend); + let surface = + unsafe { ObjectId::from_ptr(WlSurface::interface(), surface.as_ptr().cast()) } + .and_then(|id| WlSurface::from_id(&connection, id)); + if let Ok(surface) = surface { + let lend: Arc = Arc::new(Lend(Arc::clone(&lent))); + let frame = wl_surface::Request::Frame {}; + if let Ok(callback) = connection.send_request(&surface, frame, Some(lend)) { + // Safety: both proxies are live, and the callback has had no event yet. + unsafe { onto_queue_of(callback.as_ptr(), surface.id().as_ptr()) }; + } + let _ = connection.flush(); + } + // The callback, on winit's queue, ends under winit's connection, which leaves this one + // a record of it that dropping it would free a second time. + std::mem::forget(connection); + Self { lent, joined: None } + } + + /// Dispatches the seat's events and asks to be woken for the next; first joins winit's + /// connection once lent. + fn serve(&mut self) { + if self.joined.is_none() + && let Some(backend) = self.lent.lock().unwrap().take() + { + let connection = Connection::from_backend(backend); + let queue = connection.new_event_queue(); + connection.display().get_registry(&queue.handle(), ()); + self.joined = Some((connection, queue, Seat::default())); + } + if let Some((_, queue, seat)) = &mut self.joined { + let waker = Waker::from(Arc::new(Serve)); + if let std::task::Poll::Ready(Err(error)) = + queue.poll_dispatch_pending(&mut Context::from_waker(&waker), seat) + { + eprintln!("The clipboard stopped: {error}"); + self.joined = None; + } + } + } + + fn copy(&mut self, formats: Formats) -> Result<(), Box> { + self.serve(); + let Some((connection, queue, seat)) = &mut self.joined else { + return Err("The clipboard isn't ready yet.".into()); + }; + let (Some(manager), Some(device)) = (&seat.manager, &seat.device) else { + return Err("The compositor offers no clipboard.".into()); + }; + let source = manager.create_data_source(&queue.handle(), ()); + for (kind, _) in &formats { + source.offer(kind.to_string()); + } + device.set_selection(Some(&source), seat.serial); + // The source replaced is cancelled, and destroyed then. + seat.copied = Some((source, formats)); + connection.flush()?; + Ok(()) + } + + /// The selection as `kind`, where it is offered as that: this app's own copy without a + /// round trip through the compositor, or what another app answers. + fn paste(&mut self, kind: &str) -> Option> { + self.serve(); + let (connection, _, seat) = self.joined.as_ref()?; + if let Some((_, formats)) = &seat.copied { + return offered(formats, kind).map(<[u8]>::to_vec); + } + let offer = seat.offer.as_ref()?; + let kinds = offer.data::>>()?; + if !kinds.lock().unwrap().iter().any(|offered| offered == kind) { + return None; + } + let (mut reader, writer) = std::io::pipe().ok()?; + offer.receive(kind.to_owned(), writer.as_fd()); + drop(writer); + connection.flush().ok()?; + let deadline = Instant::now() + PATIENCE; + let mut bytes = Vec::new(); + loop { + if !ready(reader.as_fd(), libc::POLLIN, deadline) { + eprintln!("The app that copied didn't answer the paste in time."); + return None; + } + let mut buffer = [0; 1 << 16]; + match reader.read(&mut buffer) { + Ok(0) => return Some(bytes), + Ok(read) => bytes.extend_from_slice(&buffer[..read]), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return None, + } + } + } +} + +/// Moves `proxy` onto `owner`'s event queue. libwayland names a proxy's queue only from 1.23 +/// (`wl_proxy_get_queue`), so this reads it from `struct wl_proxy`, laid out alike since 1.0. +unsafe fn onto_queue_of(proxy: *mut wl_proxy, owner: *mut wl_proxy) { + #[repr(C)] + struct Head { + interface: *const std::ffi::c_void, + implementation: *const std::ffi::c_void, + id: u32, + display: *mut std::ffi::c_void, + queue: *mut wl_event_queue, + } + let queue = unsafe { (*owner.cast::()).queue }; + unsafe { ffi_dispatch!(wayland_client_handle(), wl_proxy_set_queue, proxy, queue) }; +} + +/// The bytes of `formats` in the format `kind`. +fn offered<'a>(formats: &'a [(&str, Vec)], kind: &str) -> Option<&'a [u8]> { + let (_, bytes) = formats.iter().find(|(offered, _)| *offered == kind)?; + Some(bytes) +} + +/// Whether `fd` is ready for `events` before `deadline`. +fn ready(fd: BorrowedFd, events: i16, deadline: Instant) -> bool { + let left = deadline.saturating_duration_since(Instant::now()); + let mut poll = libc::pollfd { + fd: fd.as_raw_fd(), + events, + revents: 0, + }; + !left.is_zero() && unsafe { libc::poll(&mut poll, 1, left.as_millis() as i32) } > 0 +} + +/// Writes `bytes` to the pipe `fd`, waiting on a slow reader no longer than a paste waits. +fn answer(fd: OwnedFd, bytes: &[u8]) { + use std::io::Write; + let mut pipe = std::fs::File::from(fd); + let deadline = Instant::now() + PATIENCE; + // A pipe ready for writing takes PIPE_BUF bytes without blocking. + for chunk in bytes.chunks(libc::PIPE_BUF) { + if !ready(pipe.as_fd(), libc::POLLOUT, deadline) || pipe.write_all(chunk).is_err() { + return; + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + registry: &WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + queue: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_seat" if seat.seat.is_none() => { + seat.seat = Some(registry.bind(name, version.min(5), queue, ())); + } + "wl_data_device_manager" if seat.manager.is_none() => { + seat.manager = Some(registry.bind(name, version.min(3), queue, ())); + } + _ => return, + } + if let (Some(manager), Some(wl_seat), None) = (&seat.manager, &seat.seat, &seat.device) { + seat.device = Some(manager.get_data_device(wl_seat, queue, ())); + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + wl_seat: &WlSeat, + event: wl_seat::Event, + _: &(), + _: &Connection, + queue: &QueueHandle, + ) { + let wl_seat::Event::Capabilities { + capabilities: WEnum::Value(capabilities), + } = event + else { + return; + }; + let keyboard = capabilities.contains(wl_seat::Capability::Keyboard); + if keyboard != seat.keyboard.is_some() { + seat.keyboard = keyboard.then(|| wl_seat.get_keyboard(queue, ())); + } + let pointer = capabilities.contains(wl_seat::Capability::Pointer); + if pointer != seat.pointer.is_some() { + seat.pointer = pointer.then(|| wl_seat.get_pointer(queue, ())); + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + _: &WlKeyboard, + event: wl_keyboard::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_keyboard::Event::Enter { serial, .. } | wl_keyboard::Event::Key { serial, .. } = + event + { + seat.serial = serial; + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + _: &WlPointer, + event: wl_pointer::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_pointer::Event::Enter { serial, .. } | wl_pointer::Event::Button { serial, .. } = + event + { + seat.serial = serial; + } + } +} + +impl Dispatch for Seat { + fn event( + _: &mut Self, + _: &WlDataDeviceManager, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + _: &WlDataDevice, + event: wl_data_device::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + wl_data_device::Event::Selection { id } => { + if let Some(old) = std::mem::replace(&mut seat.offer, id) { + old.destroy(); + } + } + // Drags aren't taken. + wl_data_device::Event::Enter { + id: Some(offer), .. + } => offer.destroy(), + _ => {} + } + } + + wayland_client::event_created_child!(Seat, WlDataDevice, [ + wl_data_device::EVT_DATA_OFFER_OPCODE => (WlDataOffer, Mutex::new(Vec::::new())), + ]); +} + +impl Dispatch>> for Seat { + fn event( + _: &mut Self, + _: &WlDataOffer, + event: wl_data_offer::Event, + kinds: &Mutex>, + _: &Connection, + _: &QueueHandle, + ) { + if let wl_data_offer::Event::Offer { mime_type } = event { + kinds.lock().unwrap().push(mime_type); + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + source: &WlDataSource, + event: wl_data_source::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let copied = seat.copied.as_ref().filter(|(copied, _)| copied == source); + match event { + wl_data_source::Event::Send { mime_type, fd } => { + if let Some(bytes) = copied.and_then(|(_, formats)| offered(formats, &mime_type)) { + answer(fd, bytes); + } + } + wl_data_source::Event::Cancelled => { + if copied.is_some() { + seat.copied = None; + } + source.destroy(); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uri_lists_name_local_files_whatever_their_line_ends() { + let list = b"# copied\r\nfile:///home/ben/Notes%20One.one\r\nfile://localhost/tmp/a.png\nhttps://example.com/b.png\r\n"; + assert_eq!( + files(list), + ["/home/ben/Notes One.one", "/tmp/a.png"].map(PathBuf::from) + ); + } +} diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 4c6fec45551ee540471a0c17352306b480acdce3..aedc3f60c1183e55ea47100026c3e9c6fde365b8 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -212,106 +212,7 @@ pub fn install_title_bar(window: &Window) { crate::desktop::set_toplevel_icon(window); } -/// The HTML and Snowbound's own format of this process's last copy through X11. -static LAST_COPY: std::sync::Mutex> = std::sync::Mutex::new(None); - -/// Wayland's clipboard through the window's own connection, as not every compositor offers -/// a clipboard to clients without a window; X11's otherwise. -pub enum Clipboard { - Wayland(smithay_clipboard::Clipboard), - X11(arboard::Clipboard), -} - -impl Clipboard { - pub fn new(window: &Window) -> Result { - use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; - match window.display_handle().map(|handle| handle.as_raw()) { - // The display outlives the clipboard, which the window's state owns. - Ok(RawDisplayHandle::Wayland(handle)) => Ok(Self::Wayland(unsafe { - smithay_clipboard::Clipboard::new(handle.display.as_ptr()) - })), - _ => arboard::Clipboard::new().map(Self::X11), - } - } - - pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { - match self { - Self::Wayland(clipboard) => { - clipboard.store(text); - Ok(()) - } - Self::X11(clipboard) => clipboard.set_text(text), - } - } - - /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`]. - /// smithay-clipboard offers text alone. - pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), arboard::Error> { - match self { - Self::Wayland(clipboard) => { - clipboard.store(copied.text.clone()); - Ok(()) - } - Self::X11(clipboard) => { - clipboard.set().html(&copied.html, Some(&copied.text))?; - *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone())); - Ok(()) - } - } - } - - /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of - /// Snowbound's own through, the last copy's, while the clipboard holds its HTML. - pub fn get_clip(&mut self) -> Option { - if !matches!(self, Self::X11(_)) { - return None; - } - let html = self.get_html()?; - match &*LAST_COPY.lock().unwrap() { - Some((copied, clip)) if *copied == html => Some(clip.clone()), - _ => None, - } - } - - pub fn get_text(&mut self) -> Result> { - Ok(match self { - Self::Wayland(clipboard) => clipboard.load()?, - Self::X11(clipboard) => clipboard.get_text()?, - }) - } - - pub fn get_files(&mut self) -> Vec { - use std::os::unix::ffi::OsStrExt; - let files = self.through_x11(|clipboard| clipboard.get().file_list().ok()); - // arboard splits text/uri-list at LF and keeps the CR its CRLF lines end in. - let path = |path: std::path::PathBuf| { - let bytes = path.as_os_str().as_bytes(); - let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes); - std::ffi::OsStr::from_bytes(bytes).into() - }; - files.unwrap_or_default().into_iter().map(path).collect() - } - - pub fn get_html(&mut self) -> Option { - self.through_x11(|clipboard| clipboard.get().html().ok()) - } - - pub fn get_picture(&mut self) -> Option> { - self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?)) - } - - /// Files, pages and pictures come through X11 on Wayland too, whose clipboard Xwayland - /// shares. - fn through_x11( - &mut self, - read: impl FnOnce(&mut arboard::Clipboard) -> Option, - ) -> Option { - match self { - Self::Wayland(_) => read(&mut arboard::Clipboard::new().ok()?), - Self::X11(clipboard) => read(clipboard), - } - } -} +pub use crate::clipboard::Clipboard; /// Whether the system draws the title bar: the window manager, or on Wayland the compositor. /// Where winit's Adwaita frame decorates the window instead, as on GNOME, the frame has no diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index f9a2b42a2d8fd694b05cea9a225eb00673999526..9b704eba4b5dbcd248bcc017c551d8faacfadbdf 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -6,6 +6,9 @@ mod aqua; mod art; mod attachment; mod background; +#[cfg(target_os = "linux")] +#[path = "clipboard_linux.rs"] +mod clipboard; mod commands; #[cfg(all(test, feature = "wgpu", not(windows)))] mod conflict_render; @@ -278,6 +281,9 @@ fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply enum UserEvent { Quit, + /// The Wayland compositor asked something of the clipboard. + #[cfg(target_os = "linux")] + Clipboard, /// Quits without asking, as after the user agreed to discard a temporary page. #[cfg(not(target_arch = "wasm32"))] Exit, @@ -6194,6 +6200,12 @@ impl State { UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {} #[cfg(not(target_arch = "wasm32"))] UserEvent::Exit => {} + #[cfg(target_os = "linux")] + UserEvent::Clipboard => { + if let Clipboard::System(clipboard) = &mut self.clipboard { + clipboard.serve(); + } + } UserEvent::Picture(bytes) => { if let Err(error) = self.insert_picture(bytes, None) { eprintln!("{error}"); @@ -6623,7 +6635,7 @@ impl ApplicationHandler for App { } /// Drops the window's state while the event loop still holds the Wayland connection the - /// clipboard's thread and the surface use; `run_app` closes it on returning. + /// clipboard and the surface use; `run_app` closes it on returning. fn exiting(&mut self, _: &ActiveEventLoop) { self.restart = self .state