From 2ce702eda0f795bca785f81ce2cd8cde9fb71f92 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 16:05:58 -0800 Subject: [PATCH] Clipboard: a paste after an unanswered Wayland text read waits for its answer The core-protocol text clipboard (used where the compositor has no data-control protocol and there is no X11) failed a paste at once after its 2 s read timeout, so the paste took the internal clipboard even when the other app's text came within the paste's 10 s limit. It now keeps a text read that got no answer in its wait, as the arboard path does, and the paste waits for that answer on its background thread until 10 s after the read was sent. The answer is pasted unless it is empty or the text copied here. --- apps/vectorcraft/src/clipboard.rs | 189 +++++++++++++++++++++++++++--- docs/development.md | 2 +- 2 files changed, 172 insertions(+), 19 deletions(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index b5b8137101..798af42fe7 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -472,6 +472,7 @@ mod loads { } /// The answer to `request`, or why there is none, after `wait` at most. + #[cfg(test)] pub(super) fn get(&mut self, request: R, wait: Duration) -> Result { wait_for_answer(&self.ask(request)?, wait) } @@ -710,8 +711,9 @@ mod portable { /// The longest a paste's bitmap read of arboard's waits for the app that copied, off the UI /// thread: the bitmap's transfer and its PNG decoding happen within the read. A paste waits as - /// long, from when it was sent, for a read that got no answer in its wait. Text, file lists and - /// the UI thread's bitmap check wait [`LOAD_WAIT`](super::loads::LOAD_WAIT). + /// long, from when it was sent, for a read that got no answer in its wait, the core protocol's + /// text read included. Text, file lists and the UI thread's bitmap check wait + /// [`LOAD_WAIT`](super::loads::LOAD_WAIT). #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] const IMAGE_WAIT: Duration = Duration::from_secs(10); @@ -829,6 +831,33 @@ mod portable { } } + /// A read that got no answer in its wait, for a paste to wait for off the UI thread. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + enum Late { + Arboard(Unanswered), + /// The core protocol's text read. + #[cfg(target_os = "linux")] + Wayland(super::wayland::Unanswered), + } + + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + impl Late { + /// What a paste of one of `mimes` takes once the answer comes ([`Unanswered::paste`]). The + /// core protocol's text is waited for as long, until [`IMAGE_WAIT`] after its read was + /// sent, and pasted unless it is empty, what `ours` put on the clipboard, or doesn't come. + fn paste(self, ours: Option<&Ours>, mimes: &[&'static str]) -> Option { + match self { + Self::Arboard(unanswered) => unanswered.paste(ours, mimes), + #[cfg(target_os = "linux")] + Self::Wayland(unanswered) => { + let text = unanswered.text(IMAGE_WAIT).ok().filter(|t| !t.is_empty())?; + let held = matches!(ours, Some(Ours::Text(t)) if *t == text); + (!held).then(|| Flavour { mime: TEXT, data: text.into_bytes() }) + } + } + } + } + /// arboard's bitmap as a PNG flavour. fn image_flavour(img: arboard::ImageData<'static>) -> Option { let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); @@ -1019,24 +1048,24 @@ mod portable { } } - /// arboard's read that got no answer in its wait, when a paste of one of `mimes` can take - /// its answer ([`Arboard::unanswered`]). + /// The read that got no answer in its wait, when a paste of one of `mimes` can take its + /// answer ([`Arboard::unanswered`], [`TextReads::unanswered`](super::wayland::TextReads::unanswered)). #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] - fn unanswered(&mut self, mimes: &[&'static str]) -> Option { + fn unanswered(&mut self, mimes: &[&'static str]) -> Option { match self { - Self::Arboard(cb) => cb.unanswered(mimes), + Self::Arboard(cb) => cb.unanswered(mimes).map(Late::Arboard), #[cfg(target_os = "linux")] - Self::Wayland(_) => None, + Self::Wayland(cb) => cb.unanswered(mimes).map(Late::Wayland), } } - /// Drops arboard's read that got no answer in its wait: a paste no longer takes its answer. + /// Drops the read that got no answer in its wait: a paste no longer takes its answer. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn forget_unanswered(&mut self) { match self { Self::Arboard(cb) => cb.unanswered = None, #[cfg(target_os = "linux")] - Self::Wayland(_) => {} + Self::Wayland(cb) => cb.forget_unanswered(), } } @@ -1168,8 +1197,8 @@ mod portable { self.cb.get(asker, Instant::now(), || Backend::open(window), Backend::name).ok_or_else(|| "no system clipboard".to_string()) } - /// The work that pastes the answer to arboard's read that got no answer in its wait - /// ([`Unanswered::paste`]), while that answer has not come. + /// The work that pastes the answer to a read that got no answer in its wait + /// ([`Late::paste`]), while that answer has not come. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn paste_unanswered(&mut self, mimes: &[&'static str]) -> Option { let ours = self.ours.clone(); @@ -1565,6 +1594,55 @@ mod portable { assert!(cb.unanswered(&[TEXT]).is_none()); } + #[cfg(target_os = "linux")] + #[test] + fn a_paste_after_an_unanswered_core_protocol_text_read_waits_for_its_answer() { + use super::super::wayland::TextReads; + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + + /// Core protocol text reads whose owner answers with `text` only once the sender + /// returned is used or dropped. + fn slow_text(text: &'static str) -> (TextReads, std::sync::mpsc::Sender<()>) { + let (release, gate) = std::sync::mpsc::channel::<()>(); + let loads = Loads::spawn("test", Duration::ZERO, move |()| { + let _ = gate.recv(); + Ok(text.to_string()) + }) + .unwrap(); + (TextReads::new(loads), release) + } + // Text was copied here; the clipboard's owner answers the check with `text` late. + let paste_after_check = |text: &'static str| { + let (mut reads, release) = slow_text(text); + // The check whether the clipboard still holds what was copied here gets no answer + // in its wait (2 s on the UI thread, none here). + assert!(reads.get(Duration::ZERO).is_err()); + // The paste's own read fails at once while the owner hasn't answered. + assert!(reads.get(Duration::from_secs(600)).is_err()); + // A paste of a bitmap alone can't take text: the read stays kept. + assert!(reads.unanswered(&[BITMAP]).is_none()); + let late = Late::Wayland(reads.unanswered(&PASTE_ORDER).expect("the check's read is kept for the paste")); + let ours = Ours::Text("copied here".into()); + let paste = std::thread::spawn(move || late.paste(Some(&ours), &PASTE_ORDER)); + drop(release); + paste.join().ok().flatten() + }; + let pasted = paste_after_check("copied elsewhere").expect("the other app's text is pasted"); + assert_eq!((pasted.mime, pasted.data), (TEXT, b"copied elsewhere".to_vec())); + // The text copied here, or nothing: the paste takes the internal clipboard. + assert!(paste_after_check("copied here").is_none()); + assert!(paste_after_check("").is_none()); + + // An answer that came before the paste may be old: it is dropped, not pasted. + let (mut reads, release) = slow_text("old"); + assert!(reads.get(Duration::ZERO).is_err()); + drop(release); + while reads.waiting() { + std::thread::yield_now(); + } + assert!(reads.unanswered(&[TEXT]).is_none()); + } + #[test] fn one_outage_is_logged_once_across_handles() { static LOGGED: AtomicBool = AtomicBool::new(false); @@ -1585,23 +1663,87 @@ mod portable { /// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on /// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the /// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's -/// data with no limit, so reads run on a worker of their own. +/// data with no limit, so reads run on a worker of their own. A read that gets no answer in that +/// time is kept for a paste to wait for off the UI thread, as arboard's are. #[cfg(target_os = "linux")] mod wayland { - use std::sync::Arc; - use std::time::Duration; + use std::sync::{Arc, mpsc}; + use std::time::{Duration, Instant}; - use super::loads::{LOAD_WAIT, Loads}; + use super::TEXT; + use super::loads::{GONE, LOAD_WAIT, Loads, wait_for_answer}; + + /// What a text read gives. + type Text = Result; pub struct TextClipboard { /// Writes: a store only hands the text to smithay-clipboard's thread. cb: smithay_clipboard::Clipboard, /// Reads, on a worker with a second clipboard on the same connection. - loads: Loads<(), Result>, + reads: TextReads, /// Held as long as the clipboards: their connection is the window's. _window: Arc, } + /// Text reads on a worker, and the last one that got no answer in its wait. + pub(super) struct TextReads { + loads: Loads<(), Text>, + /// The last read that got no answer in its wait, until a paste takes it or the next read + /// is sent. + unanswered: Option, + } + + /// A text read that got no answer in its wait. + pub(super) struct Unanswered { + answer: mpsc::Receiver, + sent: Instant, + } + + impl Unanswered { + /// The text, once it comes, waiting for it until `limit` after the read was sent. + pub(super) fn text(self, limit: Duration) -> Text { + wait_for_answer(&self.answer, (self.sent + limit).saturating_duration_since(Instant::now()))? + } + } + + impl TextReads { + pub(super) fn new(loads: Loads<(), Text>) -> Self { + Self { loads, unanswered: None } + } + + /// The text, waiting `wait` at most. A read that gets no answer in that time is kept + /// ([`Self::unanswered`]) for a paste to wait for off the UI thread; a read sent means the + /// one kept has been answered, and its answer is dropped. + pub(super) fn get(&mut self, wait: Duration) -> Text { + let sent = Instant::now(); + let answer = self.loads.ask(())?; + self.unanswered = None; + match wait_for_answer(&answer, wait) { + Ok(text) => text, + Err(why) => { + if why != GONE { + self.unanswered = Some(Unanswered { answer, sent }); + } + Err(why) + } + } + } + + /// The read that got no answer in its wait, while its answer has still not come and a + /// paste of one of `mimes` can take text. Once it has come the answer may be old, and it is + /// dropped: a new read is made. + pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option { + let unanswered = self.unanswered.take_if(|_| mimes.contains(&TEXT))?; + self.loads.waiting().then_some(unanswered) + } + + /// Whether a read sent is still unanswered. + #[cfg(test)] + pub(super) fn waiting(&self) -> bool { + self.loads.waiting() + } + } + /// The read worker's clipboard and the window whose connection it uses, dropped in that order. struct Loader { cb: smithay_clipboard::Clipboard, @@ -1631,16 +1773,27 @@ mod wayland { // Dropping doesn't wait for an unanswered read: only arboard's X11 handles hand what the // app copied on when the last one goes. let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |()| loader.load())?; - Some(Self { cb, loads, _window: window }) + Some(Self { cb, reads: TextReads::new(loads), _window: window }) } pub fn get_text(&mut self) -> Result { - self.loads.get((), LOAD_WAIT)? + self.reads.get(LOAD_WAIT) } pub fn set_text(&self, text: &str) { self.cb.store(text); } + + /// The read that got no answer in its wait, when a paste of one of `mimes` can take its + /// answer ([`TextReads::unanswered`]). + pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option { + self.reads.unanswered(mimes) + } + + /// Drops the read that got no answer in its wait: a paste no longer takes its answer. + pub fn forget_unanswered(&mut self) { + self.reads.unanswered = None; + } } } diff --git a/docs/development.md b/docs/development.md index 3cbbc45f99..a0960d3cbd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -122,7 +122,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: -- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and a Paste waits 2 s at most: an owner that never sends its data costs one Paste 2 s, and later reads return at once until it answers. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. +- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and the UI thread waits 2 s at most: an owner that never sends its data costs one read 2 s, and later reads return at once until it answers. When a read gets no answer in its 2 s, such as a paste's check whether the clipboard still holds what VectorCraft copied, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, and pastes the text unless it is empty or what VectorCraft copied. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. - **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own.