1use std::io::Write;
18use std::process::{Command, Stdio};
19use std::time::Duration;
20
21use base64::Engine;
22
23use super::image::{convert_to_png, detect_supported_image_mime, extension_for_image_mime};
24
25pub const MAX_OSC52_ENCODED_LENGTH: usize = 100_000;
28
29pub const CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(5);
31
32#[derive(Copy, Clone, Debug, Eq, PartialEq)]
34pub enum ClipboardPlatform {
35 Darwin,
37 Windows,
39 Unix,
41}
42
43impl ClipboardPlatform {
44 #[must_use]
46 pub fn host() -> Self {
47 #[cfg(target_os = "macos")]
48 {
49 Self::Darwin
50 }
51 #[cfg(target_os = "windows")]
52 {
53 Self::Windows
54 }
55 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
56 {
57 Self::Unix
58 }
59 }
60}
61
62pub trait ClipboardEnv: Send + Sync {
67 fn get(&self, name: &str) -> Option<String>;
69}
70
71#[derive(Debug, Default)]
73pub struct HostEnv;
74
75impl ClipboardEnv for HostEnv {
76 fn get(&self, name: &str) -> Option<String> {
77 std::env::var(name).ok()
78 }
79}
80
81#[must_use]
86pub fn is_wayland_session(env: &dyn ClipboardEnv) -> bool {
87 env.get("WAYLAND_DISPLAY").is_some()
88 || env.get("XDG_SESSION_TYPE").as_deref() == Some("wayland")
89}
90
91#[must_use]
94pub fn is_remote_session(env: &dyn ClipboardEnv) -> bool {
95 env.get("SSH_CONNECTION").is_some()
96 || env.get("SSH_CLIENT").is_some()
97 || env.get("MOSH_CONNECTION").is_some()
98}
99
100#[derive(Debug, thiserror::Error)]
102pub enum ClipboardError {
103 #[error("Failed to copy to clipboard")]
105 Failed,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct ClipboardImage {
111 pub bytes: Vec<u8>,
113 pub mime: String,
115}
116
117#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct WriteCommand {
120 pub program: String,
122 pub args: Vec<String>,
124 pub fallback: Option<(String, Vec<String>)>,
126}
127
128impl WriteCommand {
129 fn new(program: &str, args: Vec<String>) -> Self {
130 Self {
131 program: program.to_owned(),
132 args,
133 fallback: None,
134 }
135 }
136}
137
138#[must_use]
149pub fn clipboard_write_command(
150 platform: ClipboardPlatform,
151 env: &dyn ClipboardEnv,
152) -> Option<WriteCommand> {
153 match platform {
154 ClipboardPlatform::Darwin => Some(WriteCommand::new("pbcopy", vec![])),
155 ClipboardPlatform::Windows => Some(WriteCommand::new("clip", vec![])),
156 ClipboardPlatform::Unix => {
157 if env.get("TERMUX_VERSION").is_some() {
158 return Some(WriteCommand::new("termux-clipboard-set", vec![]));
159 }
160 let has_wayland = env.get("WAYLAND_DISPLAY").is_some();
161 let has_x11 = env.get("DISPLAY").is_some();
162 if is_wayland_session(env) && has_wayland {
163 Some(WriteCommand::new("wl-copy", vec![]))
164 } else if has_x11 {
165 let mut cmd = WriteCommand::new(
166 "xclip",
167 vec!["-selection".to_owned(), "clipboard".to_owned()],
168 );
169 cmd.fallback = Some((
170 "xsel".to_owned(),
171 vec!["--clipboard".to_owned(), "--input".to_owned()],
172 ));
173 Some(cmd)
174 } else {
175 None
176 }
177 }
178 }
179}
180
181#[must_use]
184pub fn osc52_encode(text: &str) -> Option<String> {
185 let encoded = base64::engine::general_purpose::STANDARD.encode(text);
186 if encoded.len() > MAX_OSC52_ENCODED_LENGTH {
187 return None;
188 }
189 Some(format!("\x1b]52;c;{encoded}\x07"))
190}
191
192pub fn copy_to_clipboard(text: &str) -> Result<(), ClipboardError> {
198 copy_to_clipboard_with(text, ClipboardPlatform::host(), &HostEnv)
199}
200
201pub fn copy_to_clipboard_with(
212 text: &str,
213 platform: ClipboardPlatform,
214 env: &dyn ClipboardEnv,
215) -> Result<(), ClipboardError> {
216 let mut copied = false;
217
218 if let Some(cmd) = clipboard_write_command(platform, env)
219 && run_write_command(&cmd, text)
220 {
221 copied = true;
222 }
223
224 if !copied && osc52_encode(text).is_some() {
225 copied = true;
226 }
227
228 if copied {
229 Ok(())
230 } else {
231 Err(ClipboardError::Failed)
232 }
233}
234
235fn run_write_command(cmd: &WriteCommand, text: &str) -> bool {
236 if pipe_to(&cmd.program, &cmd.args, text) {
237 return true;
238 }
239 if let Some((program, args)) = &cmd.fallback {
240 return pipe_to(program, args, text);
241 }
242 false
243}
244
245fn pipe_to(program: &str, args: &[String], text: &str) -> bool {
248 let Ok(mut child) = Command::new(program)
249 .args(args)
250 .stdin(Stdio::piped())
251 .stdout(Stdio::null())
252 .stderr(Stdio::null())
253 .spawn()
254 else {
255 return false;
256 };
257 if let Some(mut stdin) = child.stdin.take()
258 && stdin.write_all(text.as_bytes()).is_err()
259 {
260 }
262 match wait_timeout::ChildExt::wait_timeout(&mut child, CLIPBOARD_TIMEOUT) {
263 Ok(Some(status)) => status.success(),
264 Ok(None) => {
265 let _ = child.kill();
266 let _ = child.wait();
267 false
268 }
269 Err(_) => false,
270 }
271}
272
273#[derive(Clone, Debug, Eq, PartialEq)]
275pub struct ReadCommand {
276 pub program: String,
278 pub args: Vec<String>,
280 pub fallback: Option<(String, Vec<String>)>,
282}
283
284impl ReadCommand {
285 fn new(program: &str, args: Vec<String>) -> Self {
286 Self {
287 program: program.to_owned(),
288 args,
289 fallback: None,
290 }
291 }
292}
293
294#[must_use]
296pub fn clipboard_read_command(
297 platform: ClipboardPlatform,
298 env: &dyn ClipboardEnv,
299) -> Option<ReadCommand> {
300 match platform {
301 ClipboardPlatform::Darwin => Some(ReadCommand::new("pbpaste", vec![])),
302 ClipboardPlatform::Windows => Some(ReadCommand::new(
303 "powershell",
304 vec![
305 "-NoProfile".to_owned(),
306 "-Command".to_owned(),
307 "Get-Clipboard".to_owned(),
308 ],
309 )),
310 ClipboardPlatform::Unix => {
311 if is_wayland_session(env) && env.get("WAYLAND_DISPLAY").is_some() {
312 Some(ReadCommand::new(
313 "wl-paste",
314 vec!["--no-newline".to_owned()],
315 ))
316 } else if env.get("DISPLAY").is_some() {
317 let mut cmd = ReadCommand::new(
318 "xclip",
319 vec![
320 "-selection".to_owned(),
321 "clipboard".to_owned(),
322 "-o".to_owned(),
323 ],
324 );
325 cmd.fallback = Some((
326 "xsel".to_owned(),
327 vec!["--clipboard".to_owned(), "--output".to_owned()],
328 ));
329 Some(cmd)
330 } else {
331 None
332 }
333 }
334 }
335}
336
337#[must_use]
339pub fn read_clipboard_text() -> Option<String> {
340 read_clipboard_text_with(ClipboardPlatform::host(), &HostEnv)
341}
342
343pub fn read_clipboard_text_with(
345 platform: ClipboardPlatform,
346 env: &dyn ClipboardEnv,
347) -> Option<String> {
348 let cmd = clipboard_read_command(platform, env)?;
349 if let Some(text) = capture(&cmd.program, &cmd.args)
350 && !text.is_empty()
351 {
352 return Some(text);
353 }
354 if let Some((program, args)) = &cmd.fallback {
355 return capture(program, args).filter(|t| !t.is_empty());
356 }
357 None
358}
359
360fn capture(program: &str, args: &[String]) -> Option<String> {
361 let output = Command::new(program)
362 .args(args)
363 .stdin(Stdio::null())
364 .stdout(Stdio::piped())
365 .stderr(Stdio::null())
366 .output()
367 .ok()?;
368 if !output.status.success() {
369 return None;
370 }
371 Some(String::from_utf8_lossy(&output.stdout).into_owned())
372}
373
374pub fn is_wsl(env: &dyn ClipboardEnv) -> bool {
376 if env.get("WSL_DISTRO_NAME").is_some() || env.get("WSLENV").is_some() {
377 return true;
378 }
379 std::fs::read_to_string("/proc/version").is_ok_and(|release| {
380 release.contains("microsoft") || release.to_ascii_lowercase().contains("wsl")
381 })
382}
383
384#[must_use]
389pub fn maybe_convert_to_png(bytes: &[u8], mime: &str) -> Option<(Vec<u8>, String)> {
390 if let Some(kind) = detect_supported_image_mime(bytes)
391 && matches!(
392 kind.mime(),
393 "image/png" | "image/jpeg" | "image/gif" | "image/webp"
394 )
395 {
396 return Some((bytes.to_vec(), kind.mime().to_owned()));
397 }
398 let base = base_mime(mime);
399 if matches!(
400 base.as_str(),
401 "image/png" | "image/jpeg" | "image/gif" | "image/webp"
402 ) {
403 return Some((bytes.to_vec(), base));
404 }
405 convert_to_png(bytes).map(|png| (png, "image/png".to_owned()))
406}
407
408fn base_mime(mime: &str) -> String {
409 mime.split(';')
410 .next()
411 .unwrap_or(mime)
412 .trim()
413 .to_ascii_lowercase()
414}
415
416#[must_use]
422pub fn read_clipboard_image() -> Option<ClipboardImage> {
423 read_clipboard_image_with(ClipboardPlatform::host(), &HostEnv)
424}
425
426pub fn read_clipboard_image_with(
428 platform: ClipboardPlatform,
429 env: &dyn ClipboardEnv,
430) -> Option<ClipboardImage> {
431 if env.get("TERMUX_VERSION").is_some() {
432 return None;
433 }
434 let raw = read_clipboard_image_raw(platform, env)?;
435 let (bytes, mime) = maybe_convert_to_png(&raw.bytes, &raw.mime)?;
436 Some(ClipboardImage { bytes, mime })
437}
438
439fn read_clipboard_image_raw(
440 platform: ClipboardPlatform,
441 env: &dyn ClipboardEnv,
442) -> Option<ClipboardImage> {
443 if !matches!(platform, ClipboardPlatform::Unix) {
444 return None;
445 }
446 let wayland = is_wayland_session(env);
447 let wsl = is_wsl(env);
448 if wayland || wsl {
449 return wl_paste_image().or_else(xclip_image);
450 }
451 None
452}
453
454fn wl_paste_image() -> Option<ClipboardImage> {
455 let list = Command::new("wl-paste")
456 .arg("--list-types")
457 .stdin(Stdio::null())
458 .stdout(Stdio::piped())
459 .stderr(Stdio::null())
460 .output()
461 .ok()?;
462 if !list.status.success() {
463 return None;
464 }
465 let selected = select_preferred_image_mime(&String::from_utf8_lossy(&list.stdout))?;
466 let data = Command::new("wl-paste")
467 .args(["--type", &selected, "--no-newline"])
468 .stdin(Stdio::null())
469 .stdout(Stdio::piped())
470 .stderr(Stdio::null())
471 .output()
472 .ok()?;
473 if !data.status.success() || data.stdout.is_empty() {
474 return None;
475 }
476 Some(ClipboardImage {
477 bytes: data.stdout,
478 mime: base_mime(&selected),
479 })
480}
481
482fn xclip_image() -> Option<ClipboardImage> {
483 for mime in ["image/png", "image/jpeg", "image/webp", "image/gif"] {
484 let data = Command::new("xclip")
485 .args(["-selection", "clipboard", "-t", mime, "-o"])
486 .stdin(Stdio::null())
487 .stdout(Stdio::piped())
488 .stderr(Stdio::null())
489 .output()
490 .ok()?;
491 if data.status.success() && !data.stdout.is_empty() {
492 return Some(ClipboardImage {
493 bytes: data.stdout,
494 mime: mime.to_owned(),
495 });
496 }
497 }
498 None
499}
500
501fn select_preferred_image_mime(types_output: &str) -> Option<String> {
502 let normalized: Vec<String> = types_output
503 .lines()
504 .map(|line| line.trim().to_ascii_lowercase())
505 .filter(|line| !line.is_empty())
506 .collect();
507 for preferred in ["image/png", "image/jpeg", "image/webp", "image/gif"] {
508 if let Some(matched) = normalized.iter().find(|t| t.as_str() == preferred) {
509 return Some(matched.clone());
510 }
511 }
512 normalized.into_iter().find(|t| t.starts_with("image/"))
513}
514
515#[must_use]
517pub fn extension_for_image_mime_str(mime: &str) -> Option<&'static str> {
518 extension_for_image_mime(mime)
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use std::collections::HashMap;
525 use std::io;
526
527 type TestResult = Result<(), Box<dyn std::error::Error>>;
528
529 fn required<T>(value: Option<T>, context: &'static str) -> io::Result<T> {
530 value.ok_or_else(|| io::Error::other(context))
531 }
532
533 #[derive(Default)]
534 struct MapEnv {
535 vars: HashMap<String, String>,
536 }
537
538 impl MapEnv {
539 fn set(mut self, k: &str, v: &str) -> Self {
540 self.vars.insert(k.to_owned(), v.to_owned());
541 self
542 }
543 }
544
545 impl ClipboardEnv for MapEnv {
546 fn get(&self, name: &str) -> Option<String> {
547 self.vars.get(name).cloned()
548 }
549 }
550
551 #[test]
552 fn darwin_write_is_pbcopy() -> TestResult {
553 let env = MapEnv::default();
554 let cmd = required(
555 clipboard_write_command(ClipboardPlatform::Darwin, &env),
556 "Darwin write command",
557 )?;
558 assert_eq!(cmd.program, "pbcopy");
559 assert!(cmd.args.is_empty());
560 Ok(())
561 }
562
563 #[test]
564 fn windows_write_is_clip() -> TestResult {
565 let env = MapEnv::default();
566 let cmd = required(
567 clipboard_write_command(ClipboardPlatform::Windows, &env),
568 "Windows write command",
569 )?;
570 assert_eq!(cmd.program, "clip");
571 Ok(())
572 }
573
574 #[test]
575 fn unix_termux_wins_when_termux_version_set() -> TestResult {
576 let env = MapEnv::default().set("TERMUX_VERSION", "1.0");
577 let cmd = required(
578 clipboard_write_command(ClipboardPlatform::Unix, &env),
579 "Termux write command",
580 )?;
581 assert_eq!(cmd.program, "termux-clipboard-set");
582 Ok(())
583 }
584
585 #[test]
586 fn unix_wayland_when_wayland_display_and_session() -> TestResult {
587 let env = MapEnv::default()
588 .set("WAYLAND_DISPLAY", "wayland-0")
589 .set("XDG_SESSION_TYPE", "wayland");
590 let cmd = required(
591 clipboard_write_command(ClipboardPlatform::Unix, &env),
592 "Wayland write command",
593 )?;
594 assert_eq!(cmd.program, "wl-copy");
595 Ok(())
596 }
597
598 #[test]
599 fn unix_xclip_when_display_only_with_xsel_fallback() -> TestResult {
600 let env = MapEnv::default().set("DISPLAY", ":0");
601 let cmd = required(
602 clipboard_write_command(ClipboardPlatform::Unix, &env),
603 "X11 write command",
604 )?;
605 assert_eq!(cmd.program, "xclip");
606 assert_eq!(cmd.args, vec!["-selection", "clipboard"]);
607 let (fallback_prog, fallback_args) = required(cmd.fallback, "X11 write fallback")?;
608 assert_eq!(fallback_prog, "xsel");
609 assert_eq!(fallback_args, vec!["--clipboard", "--input"]);
610 Ok(())
611 }
612
613 #[test]
614 fn unix_no_display_returns_none_forcing_osc52() {
615 let env = MapEnv::default();
616 assert!(clipboard_write_command(ClipboardPlatform::Unix, &env).is_none());
617 }
618
619 #[test]
620 fn osc52_encodes_small_text() -> TestResult {
621 let seq = required(osc52_encode("hi"), "OSC 52 sequence")?;
622 assert!(seq.starts_with("\x1b]52;c;"));
623 assert!(seq.ends_with('\x07'));
624 Ok(())
625 }
626
627 #[test]
628 fn osc52_rejects_oversized_payload() {
629 let big = "a".repeat(MAX_OSC52_ENCODED_LENGTH * 3 / 4 + 1);
630 assert!(osc52_encode(&big).is_none());
631 }
632
633 #[test]
634 fn is_remote_detects_ssh_and_mosh() {
635 let ssh = MapEnv::default().set("SSH_CONNECTION", "1.2.3.4");
636 assert!(is_remote_session(&ssh));
637 let mosh = MapEnv::default().set("MOSH_CONNECTION", "1");
638 assert!(is_remote_session(&mosh));
639 let local = MapEnv::default();
640 assert!(!is_remote_session(&local));
641 }
642
643 #[test]
644 fn read_argv_matches_platform() -> TestResult {
645 let env = MapEnv::default();
646 let darwin = required(
647 clipboard_read_command(ClipboardPlatform::Darwin, &env),
648 "Darwin read command",
649 )?;
650 assert_eq!(darwin.program, "pbpaste");
651 let win = required(
652 clipboard_read_command(ClipboardPlatform::Windows, &env),
653 "Windows read command",
654 )?;
655 assert_eq!(win.program, "powershell");
656 assert_eq!(win.args, vec!["-NoProfile", "-Command", "Get-Clipboard"]);
657 Ok(())
658 }
659
660 #[test]
661 fn read_wayland_is_wl_paste_no_newline() -> TestResult {
662 let env = MapEnv::default()
663 .set("WAYLAND_DISPLAY", "wayland-0")
664 .set("XDG_SESSION_TYPE", "wayland");
665 let cmd = required(
666 clipboard_read_command(ClipboardPlatform::Unix, &env),
667 "Wayland read command",
668 )?;
669 assert_eq!(cmd.program, "wl-paste");
670 assert_eq!(cmd.args, vec!["--no-newline"]);
671 Ok(())
672 }
673
674 #[test]
675 fn read_x11_has_xsel_fallback() -> TestResult {
676 let env = MapEnv::default().set("DISPLAY", ":0");
677 let cmd = required(
678 clipboard_read_command(ClipboardPlatform::Unix, &env),
679 "X11 read command",
680 )?;
681 assert_eq!(cmd.program, "xclip");
682 let (fb, args) = required(cmd.fallback, "X11 read fallback")?;
683 assert_eq!(fb, "xsel");
684 assert_eq!(args, vec!["--clipboard", "--output"]);
685 Ok(())
686 }
687
688 #[test]
689 fn extension_helper_matches_image_module() {
690 assert_eq!(extension_for_image_mime_str("image/png"), Some("png"));
691 assert_eq!(extension_for_image_mime_str("image/jpeg"), Some("jpg"));
692 }
693
694 #[test]
695 fn select_preferred_prefers_png() {
696 let types = "text/plain\nimage/jpeg\nimage/png\n";
697 assert_eq!(
698 select_preferred_image_mime(types).as_deref(),
699 Some("image/png")
700 );
701 }
702}