Skip to main content

yapi_core/
clipboard.rs

1//! Copying text to the system clipboard, and reading what the paste key
2//! inserts.
3//!
4//! Port of `copyToClipboard` in `packages/coding-agent/src/utils/clipboard.ts`
5//! and of the reads of `handleClipboardPaste` in pi `v1.0.0`, without pi's
6//! native clipboard addon: platform commands first, then OSC (operating
7//! system command) 52 for remote and headless sessions. On macOS a script
8//! reads the pasteboard as the addon does.
9
10use base64::Engine as _;
11use base64::engine::general_purpose::STANDARD;
12use serde::Deserialize;
13use std::process::Stdio;
14use std::time::Duration;
15use tokio::io::AsyncWriteExt;
16
17const MAX_OSC52_ENCODED_LENGTH: usize = 100_000;
18const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
19/// pi's limits for listing the clipboard's types and for reading an image.
20const LIST_TIMEOUT: Duration = Duration::from_secs(1);
21const IMAGE_TIMEOUT: Duration = Duration::from_secs(3);
22/// The image types pasted as they are, in pi's order of preference, with
23/// their file extensions.
24const IMAGE_TYPES: [(&str, &str); 4] = [
25    ("image/png", "png"),
26    ("image/jpeg", "jpg"),
27    ("image/webp", "webp"),
28    ("image/gif", "gif"),
29];
30/// Reads the pasteboard named by its argument, else the general one, as
31/// pi's native addon does: the paths of copied files, else an image as PNG,
32/// else text.
33const MACOS_READER: &str = r#"ObjC.import("AppKit");
34function run(argv) {
35	const board = argv.length ? $.NSPasteboard.pasteboardWithName(argv[0]) : $.NSPasteboard.generalPasteboard;
36	const urls = board.readObjectsForClassesOptions($([$.NSURL]), $({ NSPasteboardURLReadingFileURLsOnlyKey: true }));
37	const files = urls.isNil() ? [] : urls.js.map((url) => url.path.js);
38	if (files.length) return JSON.stringify({ files });
39	if (!board.availableTypeFromArray($([$.NSPasteboardTypePNG, $.NSPasteboardTypeTIFF])).isNil()) {
40		let png = board.dataForType($.NSPasteboardTypePNG);
41		if (png.isNil()) {
42			const tiff = $.NSImage.alloc.initWithPasteboard(board).TIFFRepresentation;
43			const bitmap = tiff.isNil() ? tiff : $.NSBitmapImageRep.imageRepWithData(tiff);
44			png = bitmap.isNil() ? bitmap : bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $());
45		}
46		if (png.isNil()) return JSON.stringify({ error: "Clipboard does not contain an image" });
47		return JSON.stringify({ png: png.base64EncodedStringWithOptions(0).js });
48	}
49	const text = board.stringForType($.NSPasteboardTypeString);
50	return JSON.stringify(text.isNil() ? null : { text: text.js });
51}"#;
52
53fn env(name: &str) -> Option<String> {
54    std::env::var(name).ok().filter(|value| !value.is_empty())
55}
56
57fn is_wsl() -> bool {
58    env("WSL_DISTRO_NAME").is_some()
59        || std::fs::read_to_string("/proc/version")
60            .is_ok_and(|version| version.to_lowercase().contains("microsoft"))
61}
62
63/// pi's `runClipboardCommand`: runs `command` with `input` on stdin, and
64/// returns its output when it exits successfully within `timeout`. A
65/// command given input gets no output pipe, because clipboard writers can
66/// daemonize.
67async fn run(
68    command: &str,
69    args: &[&str],
70    input: Option<&str>,
71    timeout: Duration,
72) -> Option<Vec<u8>> {
73    let mut child = tokio::process::Command::new(command)
74        .args(args)
75        .stdin(Stdio::piped())
76        .stdout(if input.is_some() {
77            Stdio::null()
78        } else {
79            Stdio::piped()
80        })
81        .stderr(Stdio::null())
82        .kill_on_drop(true)
83        .spawn()
84        .ok()?;
85    let stdin = child.stdin.take();
86    let output = tokio::time::timeout(timeout, async {
87        if let Some(mut stdin) = stdin {
88            let _ = stdin.write_all(input.unwrap_or_default().as_bytes()).await;
89        }
90        child.wait_with_output().await
91    })
92    .await;
93    match output {
94        Ok(Ok(output)) if output.status.success() => Some(output.stdout),
95        _ => None,
96    }
97}
98
99/// The OSC 52 sequence that sets the clipboard, or `None` when too long.
100fn osc52(text: &str) -> Option<String> {
101    let encoded = base64::engine::general_purpose::STANDARD.encode(text);
102    (encoded.len() <= MAX_OSC52_ENCODED_LENGTH).then(|| format!("\x1b]52;c;{encoded}\x07"))
103}
104
105/// Copies `text`. Terminal output (OSC 52) goes through `emit`.
106pub async fn copy(text: &str, emit: impl Fn(&str)) -> Result<(), String> {
107    let linux = cfg!(target_os = "linux");
108    let mut commands: Vec<(&str, Vec<&str>)> = Vec::new();
109    if cfg!(target_os = "macos") {
110        commands.push(("pbcopy", vec![]));
111    } else if cfg!(windows) {
112        commands.push(("clip", vec![]));
113    } else {
114        if env("TERMUX_VERSION").is_some() {
115            commands.push(("termux-clipboard-set", vec![]));
116        }
117        if env("WAYLAND_DISPLAY").is_some() {
118            commands.push(("wl-copy", vec![]));
119        }
120        if env("DISPLAY").is_some() {
121            commands.push(("xclip", vec!["-selection", "clipboard"]));
122            commands.push(("xsel", vec!["--clipboard", "--input"]));
123        }
124    }
125    let mut copied = false;
126    for (command, args) in &commands {
127        if run(command, args, Some(text), COMMAND_TIMEOUT)
128            .await
129            .is_some()
130        {
131            copied = true;
132            break;
133        }
134    }
135    let mut emitted = false;
136    if !copied
137        && linux
138        && is_wsl()
139        && env("WT_SESSION").is_some()
140        && let Some(sequence) = osc52(text)
141    {
142        emit(&sequence);
143        emitted = true;
144        copied = true;
145    }
146    let remote = ["SSH_CONNECTION", "SSH_CLIENT", "MOSH_CONNECTION"]
147        .iter()
148        .any(|name| env(name).is_some());
149    let headless = linux
150        && env("DISPLAY").is_none()
151        && env("WAYLAND_DISPLAY").is_none()
152        && env("TERMUX_VERSION").is_none();
153    let mut oversized = false;
154    if !emitted && (remote || (!copied && headless)) {
155        match osc52(text) {
156            Some(sequence) => {
157                emit(&sequence);
158                copied = true;
159            }
160            None => oversized = true,
161        }
162    }
163    if copied {
164        return Ok(());
165    }
166    if oversized {
167        return Err("Clipboard unavailable: text exceeds the OSC 52 size limit".into());
168    }
169    if linux {
170        if env("TERMUX_VERSION").is_some() {
171            return Err(
172                "Clipboard unavailable: install the Termux:API app and `termux-api` package".into(),
173            );
174        }
175        if env("WAYLAND_DISPLAY").is_some() {
176            return Err(
177                "Clipboard unavailable: install `wl-clipboard` (`wl-copy`) or check Wayland access"
178                    .into(),
179            );
180        }
181        if env("DISPLAY").is_some() {
182            return Err(
183                "Clipboard unavailable: install `xclip` or `xsel`, or check X11 access".into(),
184            );
185        }
186    }
187    Err("Clipboard unavailable".into())
188}
189
190/// What the paste key inserts.
191#[derive(Debug, PartialEq)]
192pub enum Paste {
193    /// The paths of copied files.
194    Files(Vec<String>),
195    /// The clipboard's text, or the path of its image saved to a file.
196    Text(String),
197}
198
199/// pi's reads in `handleClipboardPaste`: the paths of copied files, else
200/// the clipboard's image saved to a temporary file, else its text. The
201/// error is the reason pi reports.
202pub async fn paste() -> Result<Option<Paste>, String> {
203    let paste = if cfg!(target_os = "macos") {
204        paste_macos().await?
205    } else if cfg!(target_os = "linux") {
206        paste_linux().await?
207    } else {
208        None
209    };
210    match paste {
211        Some(Paste::Files(paths)) if paths.iter().any(|path| path.contains(char::is_control)) => {
212            Err("Clipboard file path contains control characters".into())
213        }
214        Some(Paste::Text(text)) if text.is_empty() => Ok(None),
215        paste => Ok(paste),
216    }
217}
218
219/// What [`MACOS_READER`] found.
220#[derive(Debug, Deserialize, PartialEq)]
221#[serde(rename_all = "lowercase")]
222enum Pasteboard {
223    Files(Vec<String>),
224    /// Base64.
225    Png(String),
226    Text(String),
227    Error(String),
228}
229
230/// What the pasteboard named in `name`, else the general one, holds.
231async fn read_macos(name: &[&str]) -> Option<Pasteboard> {
232    let args = [&["-l", "JavaScript", "-e", MACOS_READER], name].concat();
233    let output = run("osascript", &args, None, COMMAND_TIMEOUT).await?;
234    serde_json::from_slice(&output).ok().flatten()
235}
236
237async fn paste_macos() -> Result<Option<Paste>, String> {
238    Ok(match read_macos(&[]).await {
239        None => None,
240        Some(Pasteboard::Files(paths)) => Some(Paste::Files(paths)),
241        Some(Pasteboard::Png(data)) => {
242            let bytes = STANDARD.decode(data).map_err(|error| error.to_string())?;
243            Some(Paste::Text(save_image(&bytes, "png").await?))
244        }
245        Some(Pasteboard::Text(text)) => Some(Paste::Text(text)),
246        Some(Pasteboard::Error(error)) => return Err(error),
247    })
248}
249
250/// pi's Linux reads: an image through wl-paste on Wayland and WSL, or
251/// through xclip when wl-paste fails, else text.
252async fn paste_linux() -> Result<Option<Paste>, String> {
253    if env("TERMUX_VERSION").is_none()
254        && let Some((bytes, extension)) = image_linux().await
255    {
256        return Ok(Some(Paste::Text(save_image(&bytes, extension).await?)));
257    }
258    Ok(text_linux().await.map(Paste::Text))
259}
260
261/// pi's `readClipboardImage` on Linux, without its native fallback.
262async fn image_linux() -> Option<(Vec<u8>, &'static str)> {
263    let wayland = env("WAYLAND_DISPLAY").is_some()
264        || std::env::var("XDG_SESSION_TYPE").is_ok_and(|kind| kind == "wayland");
265    let mut image = None;
266    if wayland || is_wsl() {
267        let read = [&["--type"][..], &["--no-newline"]];
268        image = image_via("wl-paste", &["--list-types"], read).await;
269    }
270    if image.is_none() {
271        let list = ["-selection", "clipboard", "-t", "TARGETS", "-o"];
272        let read = [&["-selection", "clipboard", "-t"][..], &["-o"]];
273        image = image_via("xclip", &list, read).await;
274    }
275    let (bytes, mime) = image.flatten()?;
276    tokio::task::spawn_blocking(move || pasteable(bytes, &mime))
277        .await
278        .ok()?
279}
280
281/// pi's `readClipboardImageVia{WlPaste,Xclip}`: `None` when `command`
282/// fails, `Some(None)` when the clipboard has no image. `list` lists the
283/// clipboard's types, and `read` reads the type named between its parts.
284async fn image_via(
285    command: &str,
286    list: &[&str],
287    read: [&[&str]; 2],
288) -> Option<Option<(Vec<u8>, String)>> {
289    let types = run(command, list, None, LIST_TIMEOUT).await?;
290    let types = String::from_utf8_lossy(&types);
291    let Some(mime) = image_type(&types) else {
292        return Some(None);
293    };
294    let bytes = run(
295        command,
296        &[read[0], &[mime], read[1]].concat(),
297        None,
298        IMAGE_TIMEOUT,
299    )
300    .await?;
301    Some((!bytes.is_empty()).then(|| (bytes, base_type(mime))))
302}
303
304/// pi's `baseMimeType`.
305fn base_type(mime: &str) -> String {
306    mime.split(';')
307        .next()
308        .unwrap_or_default()
309        .trim()
310        .to_lowercase()
311}
312
313/// pi's `selectPreferredImageMimeType`: of `types`, one per line, the first
314/// of [`IMAGE_TYPES`], else any image type.
315fn image_type(types: &str) -> Option<&str> {
316    let types: Vec<&str> = types
317        .lines()
318        .map(str::trim)
319        .filter(|kind| !kind.is_empty())
320        .collect();
321    IMAGE_TYPES
322        .iter()
323        .find_map(|(preferred, _)| types.iter().find(|kind| base_type(kind) == *preferred))
324        .or_else(|| {
325            types
326                .iter()
327                .find(|kind| base_type(kind).starts_with("image/"))
328        })
329        .copied()
330}
331
332/// An image of type `mime` as pi pastes it, with its file extension: as it
333/// is when of one of [`IMAGE_TYPES`], else decoded and encoded as PNG, with
334/// no EXIF orientation. `None` when the conversion fails.
335fn pasteable(bytes: Vec<u8>, mime: &str) -> Option<(Vec<u8>, &'static str)> {
336    let base = base_type(mime);
337    match IMAGE_TYPES.iter().find(|(kind, _)| *kind == base) {
338        Some((_, extension)) => Some((bytes, extension)),
339        None => Some((crate::images::to_png(&bytes)?, "png")),
340    }
341}
342
343/// pi's `readClipboardText` on Linux, without its native fallback: the
344/// output of the first clipboard tool that runs.
345async fn text_linux() -> Option<String> {
346    let mut commands: Vec<(&str, &[&str])> = Vec::new();
347    if env("TERMUX_VERSION").is_some() {
348        commands.push(("termux-clipboard-get", &[]));
349    }
350    if env("WAYLAND_DISPLAY").is_some() {
351        commands.push(("wl-paste", &["--no-newline", "--type", "text"]));
352    }
353    if env("DISPLAY").is_some() {
354        commands.push(("xclip", &["-selection", "clipboard", "-out"]));
355        commands.push(("xsel", &["--clipboard", "--output"]));
356    }
357    for (command, args) in commands {
358        if let Some(bytes) = run(command, args, None, COMMAND_TIMEOUT).await {
359            return Some(String::from_utf8_lossy(&bytes).into_owned());
360        }
361    }
362    None
363}
364
365/// Saves a pasted image where pi does, as `yapi-clipboard-<uuid>.<extension>`
366/// in the temporary directory, and returns its path.
367async fn save_image(bytes: &[u8], extension: &str) -> Result<String, String> {
368    let name = format!("yapi-clipboard-{}.{extension}", crate::time::uuid_v4());
369    let path = std::env::temp_dir().join(name);
370    tokio::fs::write(&path, bytes)
371        .await
372        .map_err(|error| error.to_string())?;
373    Ok(path.to_string_lossy().into_owned())
374}
375
376#[cfg(all(test, unix))]
377mod tests {
378    use super::*;
379
380    #[tokio::test(flavor = "current_thread")]
381    async fn commands_run_nonblocking() {
382        let tick = tokio::spawn(tokio::time::sleep(Duration::from_millis(20)));
383        let script = ["-c", "cat >/dev/null; sleep 0.5"];
384        assert!(
385            run("sh", &script, Some("text"), COMMAND_TIMEOUT)
386                .await
387                .is_some()
388        );
389        // The tick ran while the command slept, however long it took to start.
390        assert!(tick.is_finished());
391        let echo = run("sh", &["-c", "cat"], None, COMMAND_TIMEOUT).await;
392        assert_eq!(echo.as_deref(), Some(&b""[..]));
393        let output = run("sh", &["-c", "printf out"], None, COMMAND_TIMEOUT).await;
394        assert_eq!(output.as_deref(), Some(&b"out"[..]));
395        assert!(
396            run("sh", &["-c", "exit 1"], None, COMMAND_TIMEOUT)
397                .await
398                .is_none()
399        );
400        let slow = run("sleep", &["5"], None, Duration::from_millis(50)).await;
401        assert!(slow.is_none());
402        assert!(
403            run("yapi-test-missing-command", &[], None, COMMAND_TIMEOUT)
404                .await
405                .is_none()
406        );
407    }
408
409    #[test]
410    fn chooses_image_types() {
411        let types = "TARGETS\r\ntext/plain\nimage/bmp\n image/JPEG \nimage/png;q=1\n";
412        assert_eq!(image_type(types), Some("image/png;q=1"));
413        assert_eq!(image_type("image/gif\nimage/webp"), Some("image/webp"));
414        assert_eq!(image_type("image/JPEG\nimage/gif"), Some("image/JPEG"));
415        assert_eq!(image_type("text/plain\nimage/bmp"), Some("image/bmp"));
416        assert_eq!(image_type("text/plain\nUTF8_STRING\n"), None);
417    }
418
419    #[tokio::test]
420    async fn reads_listed_image_type() {
421        let list = ["-c", "printf 'text/plain\\nimage/png\\n'"];
422        // `sh -c` names the type `$0`.
423        let read = [&["-c", r#"test "$0" = image/png && printf png"#][..], &[]];
424        let image = image_via("sh", &list, read).await;
425        assert_eq!(image, Some(Some((b"png".to_vec(), "image/png".into()))));
426        // An empty read is no image, and a failed one a failed tool.
427        let empty = [&["-c", "true"][..], &[]];
428        assert_eq!(image_via("sh", &list, empty).await, Some(None));
429        assert_eq!(image_via("sh", &list, [&["-c", "exit 1"], &[]]).await, None);
430        let text = ["-c", "echo text/plain"];
431        assert_eq!(image_via("sh", &text, read).await, Some(None));
432        assert_eq!(image_via("sh", &["-c", "exit 1"], read).await, None);
433    }
434
435    #[test]
436    fn other_image_types_become_png() {
437        for (mime, extension) in [
438            ("image/png", "png"),
439            ("image/jpeg", "jpg"),
440            ("image/webp", "webp"),
441            ("image/gif", "gif"),
442        ] {
443            assert_eq!(
444                pasteable(b"bytes".to_vec(), mime),
445                Some((b"bytes".to_vec(), extension))
446            );
447        }
448        // pi's 1×1 red BMP.
449        let mut bmp = b"BM".to_vec();
450        for value in [58u32, 0, 54, 40, 1, 1] {
451            bmp.extend(value.to_le_bytes());
452        }
453        bmp.extend([1, 0, 24, 0]);
454        bmp.extend(
455            [0u32, 4, 0, 0, 0, 0]
456                .iter()
457                .flat_map(|value| value.to_le_bytes()),
458        );
459        bmp.extend([0, 0, 0xff, 0]);
460        // `image/jpg` is not a pasted type, so it converts too.
461        assert_eq!(pasteable(bmp.clone(), "image/jpg").unwrap().1, "png");
462        let (png, extension) = pasteable(bmp, "image/bmp").unwrap();
463        assert!(png.starts_with(b"\x89PNG"));
464        assert_eq!(extension, "png");
465        assert_eq!(pasteable(b"not tiff".to_vec(), "image/tiff"), None);
466    }
467
468    /// The reader on a pasteboard of its own, filled by a script.
469    #[cfg(target_os = "macos")]
470    #[tokio::test]
471    async fn macos_files_images_text() {
472        const WRITER: &str = r#"ObjC.import("AppKit");
473function run([name, kind, value]) {
474	const board = $.NSPasteboard.pasteboardWithName(name);
475	board.clearContents;
476	if (kind === "release") board.releaseGlobally;
477	else if (kind === "files") board.writeObjects($(value.split("\n").map((path) => $.NSURL.fileURLWithPath(path))));
478	else if (kind === "text") board.setStringForType(value, $.NSPasteboardTypeString);
479	else {
480		const png = $.NSData.alloc.initWithBase64EncodedStringOptions(value, 0);
481		const data = kind === "png" ? png : $.NSBitmapImageRep.imageRepWithData(png).TIFFRepresentation;
482		board.setDataForType(data, kind === "png" ? $.NSPasteboardTypePNG : $.NSPasteboardTypeTIFF);
483	}
484}"#;
485        const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
486        let name = format!("yapi-test-{}", std::process::id());
487        let write = |kind: &str, value: &str| {
488            let args = ["-l", "JavaScript", "-e", WRITER, &name, kind, value];
489            let status = std::process::Command::new("osascript").args(args).status();
490            assert!(status.unwrap().success());
491        };
492        let read = || async { read_macos(&[&name]).await };
493        write("text", "");
494        assert_eq!(read().await, Some(Pasteboard::Text(String::new())));
495        write("files", "/etc/hosts\n/tmp/My Photos");
496        let files = vec!["/etc/hosts".to_owned(), "/tmp/My Photos".to_owned()];
497        assert_eq!(read().await, Some(Pasteboard::Files(files)));
498        write("png", PNG);
499        assert_eq!(read().await, Some(Pasteboard::Png(PNG.into())));
500        write("tiff", PNG);
501        let Some(Pasteboard::Png(png)) = read().await else {
502            panic!("TIFF is read as PNG");
503        };
504        assert!(STANDARD.decode(png).unwrap().starts_with(b"\x89PNG"));
505        write("text", "héllo\nworld");
506        assert_eq!(read().await, Some(Pasteboard::Text("héllo\nworld".into())));
507        write("release", "");
508        assert_eq!(read().await, None);
509    }
510}