From 9735e51473ad5d2bc7fc1ed4aee4e9ff5bddbc4a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:24:26 -0800 Subject: [PATCH] Clipboard: files copied in a file manager paste as art on core Wayland Where the clipboard goes through the compositor's core Wayland protocol (the Flatpak on GNOME), Paste now reads the text/uri-list a file manager offers, else GNOME Files' x-special/gnome-copied-files, and pastes the first file that is art before the clipboard's other types, as on the other platforms. The list is read on the read worker and the files on the paste's background thread. Only file: URIs with no host or localhost are taken (RFC 8089), percent escapes decoded once (RFC 3986); other URIs are skipped, and a list of more than 1,024 URIs, a limit of VectorCraft's own, names no file. The Paste menu item is enabled when a file list is offered. --- apps/vectorcraft/src/clipboard.rs | 342 +++++++++++++++++++++++++++--- docs/development.md | 2 +- docs/target-app-parity.md | 1 + 3 files changed, 317 insertions(+), 28 deletions(-) diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 17b9327956..bcd37ecce3 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -11,9 +11,9 @@ //! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps //! copied), falling back to X11 where the compositor lacks the protocol, and where there is no //! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection: -//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads SVG, -//! text and PNG. A clipboard that can't be opened is tried again after a wait that grows to a -//! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app +//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads copied +//! files (`text/uri-list`), SVG, text and PNG. A clipboard that can't be opened is tried again +//! after a wait that grows to a minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app //! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at //! once; a paste then waits for that read's answer off the UI thread. //! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS, @@ -172,6 +172,85 @@ fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option { }) } +/// The most URIs a copied file list may hold, VectorCraft's own limit: a longer list (untrusted +/// data) names no files. +const MAX_URIS: usize = 1024; + +/// The local files a `text/uri-list` names, in order ([`file_uri_path`]). Each line holds one URI +/// and a line that starts with `#` is a comment (RFC 2483, section 5); lines end in CRLF or LF. +/// Lines that are not UTF-8 and URIs that don't name a local file are skipped. A list of more than +/// [`MAX_URIS`] URIs names none. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn uri_list_paths(list: &[u8]) -> Vec { + let uris: Vec<&[u8]> = list + .split(|&b| b == b'\n') + .map(|line| line.strip_suffix(b"\r").unwrap_or(line)) + .filter(|line| !line.is_empty() && !line.starts_with(b"#")) + .take(MAX_URIS + 1) + .collect(); + if uris.len() > MAX_URIS { + return vec![]; + } + uris.into_iter().filter_map(|uri| file_uri_path(std::str::from_utf8(uri).ok()?)).collect() +} + +/// The local files GNOME Files' `x-special/gnome-copied-files` names: a first line of `copy` or +/// `cut`, then one URI a line, read as [`uri_list_paths`] reads them. A paste places the files' +/// art either way and leaves the files where they are. No files when the first line is neither. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn gnome_copied_paths(list: &[u8]) -> Vec { + let (action, uris) = match list.iter().position(|&b| b == b'\n') { + Some(end) => (list.get(..end).unwrap_or_default(), list.get(end + 1..).unwrap_or_default()), + None => (list, &[][..]), + }; + match action.strip_suffix(b"\r").unwrap_or(action) { + b"copy" | b"cut" => uri_list_paths(uris), + _ => vec![], + } +} + +/// The absolute path a `file:` URI names on this machine: `file:/path`, `file:///path` or +/// `file://localhost/path` (RFC 8089, section 2; scheme and host in any letter case), its percent +/// escapes decoded once (RFC 3986, section 2.1) and a fragment (`#…`) left out. None for another +/// scheme, another host, a relative path, a query (`?…`), a bad escape, an escaped NUL or a path +/// that is not UTF-8 once decoded. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn file_uri_path(uri: &str) -> Option { + let uri = uri.trim_matches(|c: char| c.is_ascii_whitespace()); + let uri = uri.split_once('#').map_or(uri, |(before, _)| before); + if uri.contains('?') { + return None; + } + let scheme = uri.get(..5).filter(|s| s.eq_ignore_ascii_case("file:"))?; + let rest = uri.get(scheme.len()..)?; + let path = match rest.strip_prefix("//") { + Some(authority_and_path) => { + let slash = authority_and_path.find('/')?; + let host = authority_and_path.get(..slash)?; + if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") { + return None; + } + authority_and_path.get(slash..)? + } + None => rest.starts_with('/').then_some(rest)?, + }; + let mut bytes = Vec::with_capacity(path.len()); + let mut it = path.bytes(); + while let Some(b) = it.next() { + if b == b'%' { + let hex = |d: Option| char::from(d?).to_digit(16); + let (hi, lo) = (hex(it.next())?, hex(it.next())?); + bytes.push(u8::try_from(hi * 16 + lo).ok()?); + } else { + bytes.push(b); + } + } + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok().map(PathBuf::from) +} + #[cfg(windows)] mod win { use std::num::NonZeroU32; @@ -1094,7 +1173,9 @@ mod portable { } } - /// Files copied in a file manager (none where the backend can't read a file list). + /// Files copied in a file manager, read through arboard. The Wayland core protocol reads + /// its file list with the rest of a paste + /// ([`DataClipboard::read`](super::wayland::DataClipboard::read)). fn file_list(&mut self) -> Vec { match self { Self::Arboard(cb) => cb.read(Request::Files).files(), @@ -1233,8 +1314,9 @@ mod portable { } /// Text and copied files are read here, and work that reads arboard's image comes back. On - /// the Wayland core protocol the first of `mimes` the clipboard offers is read: text here, - /// SVG and PNG by the work that comes back, which waits [`IMAGE_WAIT`] at most. + /// the Wayland core protocol the copied files, else the first of `mimes` the clipboard + /// offers, is read: text here, file lists, SVG and PNG by the work that comes back, which + /// waits [`IMAGE_WAIT`] at most. fn read_now_or_later(&mut self, mimes: &[&'static str]) -> Option { let cb = self.cb(Asker::User).ok()?; #[cfg(target_os = "linux")] @@ -1335,9 +1417,9 @@ mod portable { } /// Text, on macOS vector art or a bitmap (by the pasteboard's types), and on the Wayland - /// core protocol SVG, text or PNG (by the types offered, nothing read). arboard has no - /// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys - /// take it, the menu item waits for text. + /// core protocol copied files, SVG, text or PNG (by the types offered, nothing read). + /// arboard has no cheap way to ask for a bitmap (reading one costs as much as pasting it): + /// the paste keys take it, the menu item waits for text. fn has(&mut self, mimes: &[&'static str]) -> bool { #[cfg(target_os = "macos")] if super::mac::has(mimes) { @@ -1784,12 +1866,13 @@ 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). A Copy offers its SVG as `image/svg+xml`, its PNG as `image/png` and its -/// text under each of [`TEXT_TYPES`]; Paste reads the clipboard's SVG, text and PNG, and the paste -/// check looks at the types offered without reading any data. The protocol gives the clipboard to -/// the window with the keyboard focus only. A worker thread handles the protocol's events and -/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads -/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and -/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for +/// text under each of [`TEXT_TYPES`]; Paste reads the files a file manager copied +/// ([`FILE_LISTS`]), then the clipboard's SVG, text and PNG, and the paste check looks at the types +/// offered without reading any data. The protocol gives the clipboard to the window with the +/// keyboard focus only. A worker thread handles the protocol's events and starts a thread for each +/// paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads run on another worker and +/// take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, file lists, SVG and PNG as long as +/// a paste's bitmap from arboard. A read that gets no answer in its wait is kept for /// a paste to wait for off the UI thread, as arboard's are. A read or send whose pipe moves no data /// for [`IDLE_WAIT`] fails and closes the pipe, which frees its worker or thread. #[cfg(target_os = "linux")] @@ -1817,11 +1900,17 @@ mod wayland { use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum}; use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer}; - use super::{BITMAP, Flavour, PNG, SVG, TEXT}; + use super::{BITMAP, Flavour, PNG, PathBuf, SVG, TEXT, copied_file, gnome_copied_paths, uri_list_paths}; /// The types text is offered under and read from, best first: `text/plain;charset=utf-8` (GTK /// and Qt apps), `UTF8_STRING` (X11 apps through XWayland) and `text/plain`. const TEXT_TYPES: [&str; 3] = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"]; + /// What reads the files a file list names. + type PathsOf = fn(&[u8]) -> Vec; + /// The types files copied in a file manager are offered under, each with what reads its files, + /// best first: `text/uri-list` (RFC 2483), and GNOME Files' own list, read only when the first + /// names no file. + const FILE_LISTS: [(&str, PathsOf); 2] = [("text/uri-list", uri_list_paths), ("x-special/gnome-copied-files", gnome_copied_paths)]; /// The most bytes a read takes from the app that copied (untrusted data), VectorCraft's own /// limit: a read of more fails. const MAX_BYTES: usize = 64 << 20; @@ -1905,6 +1994,35 @@ mod wayland { Some(first) } + /// Whether a paste of one of `mimes` can take a copied file: one of them is not text. + fn takes_files(mimes: &[&'static str]) -> bool { + mimes.iter().any(|&m| m != TEXT) + } + + /// The first type among `offered` that lists copied files ([`FILE_LISTS`]), in any letter case. + fn file_list_type(offered: &[String]) -> Option<&str> { + FILE_LISTS.iter().find_map(|(list, _)| offered.iter().find(|o| o.eq_ignore_ascii_case(list))).map(String::as_str) + } + + /// The first file a file manager copied that pastes as one of `mimes` ([`copied_file`]), from + /// the first of [`FILE_LISTS`] among `offered` that names a file, `read` reading each list. + fn copied(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option>) -> Option { + if !takes_files(mimes) { + return None; + } + let paths = FILE_LISTS.iter().find_map(|(list, paths_of)| { + let wire = offered.iter().find(|o| o.eq_ignore_ascii_case(list))?; + Some(paths_of(&read(wire)?)).filter(|paths| !paths.is_empty()) + })?; + copied_file(paths, mimes) + } + + /// What a paste of one of `mimes` takes: the first copied file that is art ([`copied`]), else + /// what [`pick`] takes. + fn pick_pasted(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option>) -> Option { + copied(offered, mimes, &mut read).or_else(|| pick(offered, mimes, read)) + } + /// The time `idle` from now; None when that is past the clock's range (no limit). fn idle_until(idle: Duration) -> Option { Instant::now().checked_add(idle) @@ -2089,9 +2207,9 @@ mod wayland { impl Unanswered { /// What a paste of one of `mimes` takes once this read's answer comes, waiting for it until - /// `limit` after the read was sent: the clipboard read again as [`pick`] reads it, each read - /// waiting as long, [`LOAD_WAIT`] at least, since the app that answers late may have lost the - /// clipboard meanwhile. None when the answer doesn't come or the clipboard holds this + /// `limit` after the read was sent: the clipboard read again as [`pick_pasted`] reads it, + /// each read waiting as long, [`LOAD_WAIT`] at least, since the app that answers late may + /// have lost the clipboard meanwhile. None when the answer doesn't come or the clipboard holds this /// connection's Copy. pub(super) fn paste(self, mimes: &[&'static str], limit: Duration) -> Option { let left = |now: Instant| (self.sent + limit).saturating_duration_since(now); @@ -2103,7 +2221,9 @@ mod wayland { } seen.offered.clone() }; - pick(&offered, mimes, |wire| wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok()) + pick_pasted(&offered, mimes, |wire| { + wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok() + }) } } @@ -2136,12 +2256,36 @@ mod wayland { } } - /// What a paste of one of `mimes` reads: the first the clipboard offers. Text is read here, - /// [`LOAD_WAIT`] at most, and pasted unless it is empty; SVG and PNG are asked for here and - /// waited for, `wait` at most, by the work that comes back. When that read fails or is - /// empty, the work reads the rest of `mimes` as [`pick`] does, each read waiting until - /// `wait` after the first was sent, [`LOAD_WAIT`] at least. + /// What a paste of one of `mimes` reads: the files a file manager copied, else the first of + /// `mimes` the clipboard offers. A file list is asked for here and read, with the files, + /// by the work that comes back, which pastes the first file that is art ([`copied`]) and + /// else reads `mimes` as [`pick`] does. Text is read here, [`LOAD_WAIT`] at most, and + /// pasted unless it is empty; SVG and PNG are asked for here and waited for, `wait` at most, + /// by the work that comes back. When that read fails or is empty, the work reads the rest + /// of `mimes` as [`pick`] does. Each read the work makes after the first waits until `wait` + /// after the first was sent, [`LOAD_WAIT`] at least. pub(super) fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option { + if takes_files(mimes) { + let offered = lock(&self.seen).offered.clone(); + if let Some(list) = file_list_type(&offered).map(str::to_string) { + let sent = Instant::now(); + let answer = self.loads.ask(list.clone()).ok()?; + self.unanswered = None; + let (reads, mimes) = (self.loads.asker(), mimes.to_vec()); + return Some(ClipboardRead::Later(Box::new(move || { + let left = || LOAD_WAIT.max((sent + wait).saturating_duration_since(Instant::now())); + // The list asked for here is read from its answer, every other type anew. + let mut first = Some((list, answer)); + pick_pasted(&offered, &mimes, |wire| { + let answer = match first.take_if(|(list, _)| list.as_str() == wire) { + Some((_, answer)) => answer, + None => reads.ask(wire.to_string()).ok()?, + }; + wait_for_answer(&answer, left()).ok()?.ok() + }) + }))); + } + } mimes.iter().enumerate().find_map(|(i, &mime)| { let wire = self.wire(mime)?; if mime == TEXT { @@ -2163,10 +2307,11 @@ mod wayland { }) } - /// Whether the clipboard offers one of `mimes`; nothing is read. + /// Whether the clipboard offers one of `mimes`, or files a file manager copied when a paste + /// of `mimes` can take a file; nothing is read. pub(super) fn has(&self, mimes: &[&'static str]) -> bool { let seen = lock(&self.seen); - mimes.iter().any(|m| wire_type(m, &seen.offered).is_some()) + (takes_files(mimes) && file_list_type(&seen.offered).is_some()) || mimes.iter().any(|m| wire_type(m, &seen.offered).is_some()) } /// Whether the clipboard holds this connection's last Copy. @@ -2835,6 +2980,85 @@ mod wayland { let Some(ClipboardRead::Later(work)) = reads.read(&[BITMAP], Duration::from_secs(600)) else { panic!("the PNG is asked for") }; assert!(work().is_none()); } + + /// Reads of a clipboard holding `data` by type, answered at once. + fn reads_holding(data: Vec<(String, Vec)>) -> Reads { + let types: Vec = data.iter().map(|(t, _)| t.clone()).collect(); + let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| { + data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.clone()).ok_or_else(|| "not offered".to_string()) + }) + .unwrap(); + let types: Vec<&str> = types.iter().map(String::as_str).collect(); + Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&types)))) + } + + /// The `file:` URI of `path`, its spaces escaped. + fn file_uri(path: &std::path::Path) -> String { + format!("file://{}", path.display().to_string().replace(' ', "%20")) + } + + /// What a paste of `mimes` takes from `reads`. + fn paste(reads: &mut Reads, mimes: &[&'static str]) -> Option<(&'static str, Vec)> { + match reads.read(mimes, Duration::from_secs(600))? { + ClipboardRead::Now(f) => Some((f.mime, f.data)), + ClipboardRead::Later(work) => work().map(|f| (f.mime, f.data)), + } + } + + #[test] + fn files_copied_in_a_file_manager_paste_as_their_art() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + let d = tempfile::Builder::new().prefix("vectorcraft-wayland-files-").tempdir().unwrap(); + let (notes, red) = (d.path().join("notes.txt"), d.path().join("red image.png")); + let mut png = vec![]; + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + std::fs::write(¬es, "not art").unwrap(); + std::fs::write(&red, &png).unwrap(); + let list = |paths: &[&std::path::Path]| paths.iter().map(|p| file_uri(p) + "\r\n").collect::().into_bytes(); + let held = |types: &[(&str, Vec)]| reads_holding(types.iter().map(|(t, d)| (t.to_string(), d.clone())).collect()); + let text = ("text/plain;charset=utf-8", b"the files' paths".to_vec()); + // A file list alone enables Paste and pastes the first file that is art. + let mut reads = held(&[("text/uri-list", list(&[¬es, &red]))]); + assert!(reads.has(&PASTE_ORDER) && reads.has(&[BITMAP])); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone()))); + // A paste of text alone takes no file. + assert!(!reads.has(&[TEXT])); + assert_eq!(paste(&mut reads, &[TEXT]), None); + // The files come before the other types offered, as on the other platforms; the type in + // any letter case. + let mut reads = held(&[(SVG, b"".to_vec()), text.clone(), ("Text/URI-List", list(&[&red]))]); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone()))); + assert_eq!(paste(&mut reads, &[TEXT]), Some((TEXT, text.1.clone()))); + // With no file that is art, the paste takes the other types. + let mut reads = held(&[("text/uri-list", list(&[¬es, &d.path().join("gone.png")])), text.clone()]); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((TEXT, text.1.clone()))); + let mut reads = held(&[("text/uri-list", b"https://example.com/art.png\r\n".to_vec())]); + assert_eq!(paste(&mut reads, &PASTE_ORDER), None); + // GNOME Files' own list, alone or when the URI list names no local file. + let gnome = [b"copy\n".to_vec(), file_uri(&red).into_bytes()].concat(); + let mut reads = held(&[("x-special/gnome-copied-files", gnome.clone())]); + assert!(reads.has(&PASTE_ORDER)); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone()))); + let mut reads = held(&[("text/uri-list", b"file://server/art.png\r\n".to_vec()), ("x-special/gnome-copied-files", gnome)]); + assert_eq!(paste(&mut reads, &PASTE_ORDER), Some((PNG, png.clone()))); + // KDE's cut marker is not a file list. + let mut reads = held(&[("application/x-kde-cutselection", b"1".to_vec())]); + assert!(!reads.has(&PASTE_ORDER)); + assert_eq!(paste(&mut reads, &PASTE_ORDER), None); + } + + #[test] + fn a_file_list_that_cant_be_read_gives_way_to_the_other_types() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + let offered = offered(&["text/uri-list", "text/plain"]); + let mut asked = vec![]; + let f = pick_pasted(&offered, &PASTE_ORDER, |wire| { + asked.push(wire.to_string()); + (wire == "text/plain").then(|| b"words".to_vec()) + }); + assert_eq!(f.map(|f| (f.mime, f.data)), Some((TEXT, b"words".to_vec()))); + assert_eq!(asked, ["text/uri-list", "text/plain"]); + } } } @@ -2952,6 +3176,70 @@ mod tests { assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG)); } + fn uri_paths(list: &[u8]) -> Vec { + uri_list_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect() + } + + #[test] + fn a_uri_list_names_the_local_files_of_its_lines() { + // CRLF or LF, comment lines and blank lines. + assert_eq!(uri_paths(b"# copied\r\nfile:///home/a/one.svg\r\n\r\nfile:///home/a/two.png\n"), ["/home/a/one.svg", "/home/a/two.png"]); + assert_eq!(uri_paths(b"file:///a.svg"), ["/a.svg"]); + // Percent escapes are decoded once; `+` and an escaped `#` are part of the name. + assert_eq!(uri_paths(b"file:///home/a/red%20image.png"), ["/home/a/red image.png"]); + assert_eq!(uri_paths(b"file:///home/a/caf%C3%A9.svg\nfile:///home/a/caf\xC3\xA9.svg"), ["/home/a/café.svg", "/home/a/café.svg"]); + assert_eq!(uri_paths(b"file:///a/%231+2.svg"), ["/a/#1+2.svg"]); + assert_eq!(uri_paths(b"file:///a/100%2525.svg"), ["/a/100%25.svg"]); + // No host, `localhost` in any case, and the short form name this machine; a fragment is + // left out. + assert_eq!( + uri_paths(b"file://localhost/a.svg\nFILE://LocalHost/b.svg\nfile:/c.svg\nfile:///d.svg#page=2"), + ["/a.svg", "/b.svg", "/c.svg", "/d.svg"] + ); + // Other hosts and schemes, relative paths, queries, bad escapes, an escaped NUL, a name + // that is not UTF-8 once decoded and a line that is not UTF-8 name no file. + for uri in [ + &b"file://server/a.svg"[..], + b"https://example.com/a.svg", + b"file:a.svg", + b"file://", + b"file://localhost", + b"file:///a.svg?x=1", + b"file:///a%2", + b"file:///a%zz.svg", + b"file:///a%00b.svg", + b"file:///a%FF.svg", + b"file:///a\xFF.svg", + b"/home/a/one.svg", + b"#file:///a.svg", + ] { + assert!(uri_list_paths(uri).is_empty(), "{}", String::from_utf8_lossy(uri)); + } + // The files beside a URI that names none are kept. + assert_eq!(uri_paths(b"https://example.com/a.svg\r\nfile:///b.svg\r\n"), ["/b.svg"]); + assert!(uri_list_paths(b"").is_empty()); + } + + #[test] + fn a_uri_list_longer_than_the_limit_names_no_file() { + assert_eq!(MAX_URIS, 1024); + let list = |n: usize| (0..n).map(|i| format!("# file {i}\r\nfile:///f{i}.svg\r\n")).collect::(); + // Comment lines don't count. + assert_eq!(uri_list_paths(list(MAX_URIS).as_bytes()).len(), MAX_URIS); + assert!(uri_list_paths(list(MAX_URIS + 1).as_bytes()).is_empty()); + } + + #[test] + fn gnome_files_list_names_its_files_after_copy_or_cut() { + let paths = |list: &[u8]| gnome_copied_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect::>(); + assert_eq!(paths(b"copy\nfile:///a.svg\nfile:///b%20c.png"), ["/a.svg", "/b c.png"]); + assert_eq!(paths(b"cut\r\nfile:///a.svg\r\n"), ["/a.svg"]); + // Another first line, none, or no files. + for list in [&b"move\nfile:///a.svg"[..], b"file:///a.svg\nfile:///b.svg", b"copy", b"", b"Copy\nfile:///a.svg"] { + assert!(gnome_copied_paths(list).is_empty(), "{}", String::from_utf8_lossy(list)); + } + } + /// A DIB of `w`×`h` pixels (top row first when `h` is negative): a `header`-byte header (40, /// or 124 for a BITMAPV5HEADER with BGRA masks), `extra` (the masks after a 40-byte header, a /// colour table), then `rows`, each padded to 4 bytes. diff --git a/docs/development.md b/docs/development.md index 82ffbd4a5a..96c063a2a1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -125,7 +125,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 (`wl_data_device`) on the window's own connection, and only while the window has focus. One Copy offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text as `text/plain;charset=utf-8`, `UTF8_STRING` and `text/plain`; a format with no data is not offered. Paste reads `image/svg+xml`, then text, then `image/png`, and blank text or text that only names a picture gives way to the PNG; the Paste menu item is enabled by the types offered, without reading any data. Whether the clipboard still holds VectorCraft's Copy is told by its data source, which the compositor cancels when another app copies. A read takes 64 MiB at most and fails beyond that. Reads run on a worker thread: text waits 2 s at most on the UI thread, SVG and PNG 10 s on the paste's background thread. An owner that never sends its data costs one read its wait, and later reads return at once until it answers. When a text read gets no answer in its 2 s, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, then reads the clipboard again unless it holds VectorCraft's Copy. Another app's paste of a Copy is sent on a thread of its own, 8 at once at most; a paste beyond them gets nothing. 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 (`wl_data_device`) on the window's own connection, and only while the window has focus. One Copy offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text as `text/plain;charset=utf-8`, `UTF8_STRING` and `text/plain`; a format with no data is not offered. Paste reads the files a file manager copied (`text/uri-list`, else `x-special/gnome-copied-files`; local `file:` URIs only, 1,024 at most), then `image/svg+xml`, then text, then `image/png`, and blank text or text that only names a picture gives way to the PNG; the Paste menu item is enabled by the types offered, without reading any data. Whether the clipboard still holds VectorCraft's Copy is told by its data source, which the compositor cancels when another app copies. A read takes 64 MiB at most and fails beyond that. Reads run on a worker thread: text waits 2 s at most on the UI thread, file lists, SVG and PNG 10 s on the paste's background thread. An owner that never sends its data costs one read its wait, and later reads return at once until it answers. When a text read gets no answer in its 2 s, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, then reads the clipboard again unless it holds VectorCraft's Copy. Another app's paste of a Copy is sent on a thread of its own, 8 at once at most; a paste beyond them gets nothing. 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 when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app hides its main window before these waits (`hide_before_teardown` in `apps/vectorcraft/src/app.rs`), so an X11 window, an XWayland one included, closes at once; winit cannot hide a native Wayland window, which stays on screen until the waits end. 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..b931f74721 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1789,6 +1789,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - In the Linux Flatpak on GNOME, where the clipboard goes through the compositor's core Wayland protocol, one Copy offers the PNG (`image/png`), the SVG (`image/svg+xml`, with Include SVG Code on) and the text, and Paste takes another app's SVG, text or PNG in that order, a picture over blank text or text that only names it; the Paste menu item is enabled by the types offered (`clipboard::wayland` tests in `apps/vectorcraft/src/clipboard.rs`; not yet run in the Flatpak on a GNOME session). - On that core Wayland clipboard, a paste from an app that leaves its pipe open and sends no data for 2 s fails and pastes nothing, and a paste of VectorCraft's Copy by an app that takes no data for 2 s is stopped and its pipe closed, so later pastes and copies are read and sent again; the 2 s limit is VectorCraft's own and starts again after each byte that moves (`clipboard::wayland::tests::a_read_from_an_app_that_stops_sending_fails_and_closes_the_pipe` and `a_send_to_an_app_that_stops_reading_fails_and_frees_its_thread`). + - On that core Wayland clipboard, files copied in a file manager paste as the first one that is art, before the clipboard's other types, as on the other platforms: Paste reads the `text/uri-list` the file manager offers (RFC 2483), else GNOME Files' `x-special/gnome-copied-files`, off the UI thread, and takes the `file:` URIs with no host or `localhost` (RFC 8089), percent escapes decoded; other URIs are skipped, and a list of more than 1,024 URIs, VectorCraft's own limit, names no file. The Paste menu item is enabled when a file list is offered (`clipboard::wayland::tests::files_copied_in_a_file_manager_paste_as_their_art`; not yet run in the Flatpak on a GNOME session). - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. - With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default).