mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Linux clipboard: a Wayland core-protocol read waits 2 s at most
smithay-clipboard's load() waits for the selection owner's data with no limit, and Paste and the "is the clipboard still ours" check read on the UI thread, so an owner that never sends its data froze the window. Reads now run on a worker thread with a second clipboard on the window's connection and are waited for 2 s at most. While a read that timed out is unanswered, later reads return at once instead of queueing behind it, and its late answer is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
dd934dfd4e
commit
7a86f94282
@@ -757,37 +757,155 @@ mod portable {
|
||||
/// window's own Wayland connection: what Copy and Paste use where the compositor lacks the
|
||||
/// 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.
|
||||
/// 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.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland {
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The longest a read waits for the selection's owner to send its text.
|
||||
const LOAD_WAIT: Duration = Duration::from_secs(2);
|
||||
/// What a read says once its worker has stopped.
|
||||
const GONE: &str = "the clipboard's reader stopped";
|
||||
|
||||
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,
|
||||
/// Held as long as the clipboards: their connection is the window's.
|
||||
_window: Arc<winit::window::Window>,
|
||||
}
|
||||
|
||||
/// The read worker's clipboard and the window whose connection it uses, dropped in that order.
|
||||
struct Loader {
|
||||
cb: smithay_clipboard::Clipboard,
|
||||
/// Held as long as the clipboard: its connection is the window's.
|
||||
_window: Arc<winit::window::Window>,
|
||||
}
|
||||
|
||||
impl TextClipboard {
|
||||
/// None when `window` isn't a Wayland window.
|
||||
/// None when `window` isn't a Wayland window or the read worker can't start.
|
||||
#[allow(unsafe_code)]
|
||||
pub fn new(window: Arc<winit::window::Window>) -> Option<Self> {
|
||||
use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};
|
||||
let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None };
|
||||
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (held beside
|
||||
// the clipboard); smithay-clipboard makes its own event queue on it and doesn't close it.
|
||||
let cb = unsafe { smithay_clipboard::Clipboard::new(d.display.as_ptr()) };
|
||||
Some(Self { cb, _window: window })
|
||||
let display = d.display.as_ptr();
|
||||
// SAFETY: the display is winit's open `wl_display`, which outlives `window`; each
|
||||
// clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard
|
||||
// makes its own event queue on the display and doesn't close it.
|
||||
let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) };
|
||||
let loader = Loader { cb: reader, _window: window.clone() };
|
||||
let loads = Loads::spawn(move || loader.cb.load().map_err(|e| e.to_string()))?;
|
||||
Some(Self { cb, loads, _window: window })
|
||||
}
|
||||
|
||||
pub fn get_text(&self) -> Result<String, String> {
|
||||
self.cb.load().map_err(|e| e.to_string())
|
||||
pub fn get_text(&mut self) -> Result<String, String> {
|
||||
self.loads.get(LOAD_WAIT)
|
||||
}
|
||||
|
||||
pub fn set_text(&self, text: &str) {
|
||||
self.cb.store(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads made on a worker thread and waited for a bounded time: a selection owner that never
|
||||
/// sends its data holds the worker, not the caller.
|
||||
struct Loads {
|
||||
requests: mpsc::Sender<()>,
|
||||
answers: mpsc::Receiver<Result<String, String>>,
|
||||
/// A read that timed out and hasn't answered yet: until it does, reads don't queue behind it.
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl Loads {
|
||||
fn spawn(mut load: impl FnMut() -> Result<String, String> + Send + 'static) -> Option<Self> {
|
||||
let (requests, asked) = mpsc::channel::<()>();
|
||||
let (answer, answers) = mpsc::channel();
|
||||
let work = move || {
|
||||
while asked.recv().is_ok() {
|
||||
if answer.send(load()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
std::thread::Builder::new().name("wayland-clipboard-read".into()).spawn(work).ok()?;
|
||||
Some(Self { requests, answers, pending: false })
|
||||
}
|
||||
|
||||
/// The text, or why there is none, after `wait` at most.
|
||||
fn get(&mut self, wait: Duration) -> Result<String, String> {
|
||||
if self.pending {
|
||||
match self.answers.try_recv() {
|
||||
// The late answer of the read that timed out: stale by now.
|
||||
Ok(_) => self.pending = false,
|
||||
Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()),
|
||||
Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()),
|
||||
}
|
||||
}
|
||||
self.requests.send(()).map_err(|_| GONE.to_string())?;
|
||||
match self.answers.recv_timeout(wait) {
|
||||
Ok(text) => text,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
self.pending = true;
|
||||
Err(format!("the clipboard's owner didn't send its text within {wait:?}"))
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Instant;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_read_that_answers_in_time_gives_the_text() {
|
||||
let mut loads = Loads::spawn(|| Ok("<svg/>".into())).unwrap();
|
||||
assert_eq!(loads.get(Duration::from_secs(5)), Ok("<svg/>".to_string()));
|
||||
assert_eq!(loads.get(Duration::from_secs(5)), Ok("<svg/>".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_owner_that_never_answers_holds_the_worker_not_the_caller() {
|
||||
let (release, gate) = mpsc::channel::<()>();
|
||||
let mut calls = 0;
|
||||
let mut loads = Loads::spawn(move || {
|
||||
calls += 1;
|
||||
if calls == 1 {
|
||||
// The first owner sends nothing until released.
|
||||
let _ = gate.recv();
|
||||
return Ok("stale".into());
|
||||
}
|
||||
Ok("fresh".into())
|
||||
})
|
||||
.unwrap();
|
||||
let start = Instant::now();
|
||||
assert!(loads.get(Duration::from_millis(50)).is_err(), "the first read times out");
|
||||
// While it is unanswered, a read doesn't queue behind it or wait at all.
|
||||
assert!(loads.get(Duration::from_secs(60)).is_err());
|
||||
assert!(start.elapsed() < Duration::from_secs(30), "no read waited past its bound");
|
||||
release.send(()).unwrap();
|
||||
// Once the late answer is in, it is dropped and a new read is made.
|
||||
let text = (0..200).find_map(|_| {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
loads.get(Duration::from_secs(5)).ok()
|
||||
});
|
||||
assert_eq!(text.as_deref(), Some("fresh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_after_the_worker_stops_says_so() {
|
||||
let mut loads = Loads::spawn(|| Ok(String::new())).unwrap();
|
||||
// The worker ends when its answers can't be sent: drop them by hand.
|
||||
let (_, dead) = mpsc::channel();
|
||||
drop(std::mem::replace(&mut loads.answers, dead));
|
||||
assert_eq!(loads.get(Duration::from_millis(50)), Err(GONE.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the
|
||||
|
||||
+2
-2
@@ -115,7 +115,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. 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 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 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, 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. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. 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.
|
||||
@@ -494,7 +494,7 @@ Scores and hour estimates are unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME). |
|
||||
| 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. |
|
||||
| 2026-10-29 | minor | Linux clipboard: a clipboard arboard can't open is retried after 1 s doubling to 60 s and logged once, instead of every 250 ms. |
|
||||
| 2026-10-28 | minor | Localisation: Catalan (`ca`) catalog and registry entry (every `ca-*` locale); complete under the catalog tests. |
|
||||
| 2026-10-25 | minor | Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30); behavior unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user