1use 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);
19const LIST_TIMEOUT: Duration = Duration::from_secs(1);
21const IMAGE_TIMEOUT: Duration = Duration::from_secs(3);
22const IMAGE_TYPES: [(&str, &str); 4] = [
25 ("image/png", "png"),
26 ("image/jpeg", "jpg"),
27 ("image/webp", "webp"),
28 ("image/gif", "gif"),
29];
30const 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
63async 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
99fn 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
105pub 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#[derive(Debug, PartialEq)]
192pub enum Paste {
193 Files(Vec<String>),
195 Text(String),
197}
198
199pub 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#[derive(Debug, Deserialize, PartialEq)]
221#[serde(rename_all = "lowercase")]
222enum Pasteboard {
223 Files(Vec<String>),
224 Png(String),
226 Text(String),
227 Error(String),
228}
229
230async 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
250async 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
261async 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
281async 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
304fn base_type(mime: &str) -> String {
306 mime.split(';')
307 .next()
308 .unwrap_or_default()
309 .trim()
310 .to_lowercase()
311}
312
313fn 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
332fn 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
343async 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
365async 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 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 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 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 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 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 #[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}