1use std::{
18 io::{Read, Write},
19 path::PathBuf,
20 process::{Command, Stdio},
21 thread,
22 time::{Duration, Instant},
23};
24
25use omp_core::{Str, base64, fmts, hex};
26use smallvec::SmallVec;
27
28use crate::{Key, imagefmt::ImageFormat};
29
30const CLI_TIMEOUT: Duration = Duration::from_secs(5);
31const POWERSHELL_TIMEOUT: Duration = Duration::from_secs(8);
32const PASTE_EVENT_NAME_BASE64: &str = "UGFzdGUgZXZlbnQ=";
33const IMAGE_MIMES: [&str; 4] = ["image/png", "image/jpeg", "image/webp", "image/gif"];
34
35#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum Pasted {
38 Text(Str),
40 Image(PastedImage),
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct PastedImage {
47 pub bytes: Vec<u8>,
49 pub format: ImageFormat,
51}
52
53impl PastedImage {
54 pub fn from_bytes(bytes: Vec<u8>) -> Option<Self> {
56 let format = crate::imagefmt::format(&bytes)?;
57 Some(Self { bytes, format })
58 }
59
60 pub const fn extension(&self) -> &'static str {
62 match self.format {
63 ImageFormat::Png => "png",
64 ImageFormat::Jpeg => "jpg",
65 ImageFormat::Gif => "gif",
66 ImageFormat::Webp => "webp",
67 }
68 }
69
70 pub fn persist(&self) -> std::io::Result<PathBuf> {
79 use std::io::Write as _;
80 let mut file = tempfile::Builder::new()
81 .prefix("omp-tui-paste-")
82 .suffix(&format!(".{}", self.extension()))
83 .tempfile()?;
84 file.write_all(&self.bytes)?;
85 let (file, path) = file.keep().map_err(|error| error.error)?;
86 drop(file);
87 Ok(path)
88 }
89}
90
91const MAX_READ_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;
95const MAX_LISTED_MIMES: usize = 64;
98const READ_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(5);
101
102#[derive(Debug)]
103enum PastePhase {
104 Listing {
105 mimes: SmallVec<Str, 5>,
106 kitty_dot: bool,
107 pw: Option<Str>,
108 loc: Option<Str>,
109 },
110 Reading {
111 mime: Str,
112 chunks: SmallVec<Str, 4>,
113 bytes: usize,
115 },
116}
117
118#[derive(Default, Debug)]
120pub struct PasteEvents {
121 phase: Option<PastePhase>,
122 last_packet: Option<Instant>,
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
127pub enum PasteProgress {
128 NotMine,
130 Consumed,
132 Reply(String),
134 Done(Pasted),
136}
137
138impl PasteEvents {
139 pub fn handle_osc(&mut self, payload: &str) -> PasteProgress {
141 self.handle_osc_at(payload, Instant::now())
142 }
143
144 fn handle_osc_at(&mut self, payload: &str, now: Instant) -> PasteProgress {
145 let Some(body) = payload.strip_prefix("5522;") else {
146 return PasteProgress::NotMine;
147 };
148 if self.phase.is_some()
154 && self
155 .last_packet
156 .is_some_and(|at| now.duration_since(at) >= READ_INACTIVITY_TIMEOUT)
157 {
158 self.reset();
159 }
160 self.last_packet = Some(now);
161 let (metadata, data) = body.split_once(';').unwrap_or((body, ""));
162 let metadata = parse_metadata(metadata);
163 if metadata_value(&metadata, "type") != Some("read") {
164 return PasteProgress::Consumed;
165 }
166 match metadata_value(&metadata, "status") {
167 Some("OK") => {
168 if !matches!(self.phase, Some(PastePhase::Reading { .. })) {
169 self.phase = Some(PastePhase::Listing {
170 mimes: SmallVec::new(),
171 kitty_dot: false,
172 pw: metadata_value(&metadata, "pw").map(Str::from),
173 loc: (metadata_value(&metadata, "loc") == Some("primary"))
174 .then(|| Str::from("primary")),
175 });
176 }
177 PasteProgress::Consumed
178 },
179 Some("DATA") => {
180 self.handle_data(&metadata, data);
181 PasteProgress::Consumed
182 },
183 Some("DONE") => self.handle_done(),
184 Some(_) => {
185 self.reset();
186 PasteProgress::Consumed
187 },
188 None => PasteProgress::Consumed,
189 }
190 }
191
192 pub fn reset(&mut self) {
194 self.phase = None;
195 self.last_packet = None;
196 }
197
198 fn handle_data(&mut self, metadata: &[(Str, Str)], payload: &str) {
199 let Some(encoded_mime) = metadata_value(metadata, "mime") else {
200 return;
201 };
202 let Some(mime) = decode_base64_text(encoded_mime) else {
203 return;
204 };
205 let overflow = match self.phase.as_mut() {
206 Some(PastePhase::Listing { mimes, kitty_dot, .. }) if mime == "." => {
207 if payload.is_empty() {
208 return;
209 }
210 let Some(listing) = decode_base64_text(payload) else {
211 return;
212 };
213 *kitty_dot = true;
214 mimes.extend(
215 listing
216 .split_ascii_whitespace()
217 .filter(|candidate| !candidate.is_empty() && *candidate != ".")
218 .take(MAX_LISTED_MIMES.saturating_sub(mimes.len()))
219 .map(Str::from),
220 );
221 false
222 },
223 Some(PastePhase::Listing { mimes, .. }) => {
224 if mimes.len() < MAX_LISTED_MIMES {
225 mimes.push(Str::from(mime));
226 }
227 false
228 },
229 Some(PastePhase::Reading { mime: selected, chunks, bytes })
230 if selected.as_str() == mime && !payload.is_empty() =>
231 {
232 *bytes = bytes.saturating_add(payload.len());
233 if *bytes > MAX_READ_PAYLOAD_BYTES {
234 true
235 } else {
236 chunks.push(Str::from(payload));
237 false
238 }
239 },
240 _ => false,
241 };
242 if overflow {
245 self.reset();
246 }
247 }
248
249 fn handle_done(&mut self) -> PasteProgress {
250 let Some(phase) = self.phase.take() else {
251 return PasteProgress::Consumed;
252 };
253 match phase {
254 PastePhase::Listing { mimes, kitty_dot, pw, loc } => {
255 let Some(mime) = choose_mime(&mimes) else {
256 return PasteProgress::Consumed;
257 };
258 let encoded = base64::encode(mime.as_bytes()).into_string();
259 let mut reply = String::from("\x1b]5522;type=read");
260 if let Some(loc) = loc {
261 reply.push_str(":loc=");
262 reply.push_str(&loc);
263 }
264 if let Some(pw) = pw {
265 reply.push_str(":pw=");
266 reply.push_str(&pw);
267 reply.push_str(":name=");
268 reply.push_str(PASTE_EVENT_NAME_BASE64);
269 }
270 reply.push_str(if kitty_dot { ";" } else { ":mime=" });
271 reply.push_str(&encoded);
272 reply.push('\x07');
273 self.phase = Some(PastePhase::Reading { mime, chunks: SmallVec::new(), bytes: 0 });
274 PasteProgress::Reply(reply)
275 },
276 PastePhase::Reading { mime, chunks, bytes: _ } => {
277 let mut bytes = Vec::new();
281 for chunk in &chunks {
282 let Ok(decoded) = base64::decode(chunk.as_bytes()).into_vec() else {
283 return PasteProgress::Consumed;
284 };
285 bytes.extend_from_slice(&decoded);
286 }
287 if bytes.is_empty() {
288 return PasteProgress::Consumed;
289 }
290 if mime == "text/plain" {
291 return PasteProgress::Done(Pasted::Text(Str::from(
292 String::from_utf8_lossy(&bytes).as_ref(),
293 )));
294 }
295 let image = crate::imagefmt::format(&bytes)
296 .or_else(|| mime_format(&mime))
297 .map(|format| PastedImage { bytes, format });
298 image.map_or(PasteProgress::Consumed, |image| PasteProgress::Done(Pasted::Image(image)))
299 },
300 }
301 }
302}
303
304fn parse_metadata(raw: &str) -> SmallVec<(Str, Str), 6> {
305 raw.split(':')
306 .filter_map(|part| {
307 let (key, value) = part.split_once('=')?;
308 (!key.is_empty()).then(|| (Str::from(key), Str::from(value)))
309 })
310 .collect()
311}
312
313fn metadata_value<'a>(metadata: &'a [(Str, Str)], key: &str) -> Option<&'a str> {
314 metadata
315 .iter()
316 .rev()
317 .find_map(|(candidate, value)| (candidate == key).then(|| value.as_str()))
318}
319
320fn decode_base64_text(encoded: &str) -> Option<String> {
321 let bytes = base64::decode(encoded.as_bytes()).into_vec().ok()?;
322 String::from_utf8(bytes).ok()
323}
324
325fn choose_mime(mimes: &[Str]) -> Option<Str> {
326 IMAGE_MIMES
327 .into_iter()
328 .chain(["text/plain"])
329 .find(|candidate| mimes.iter().any(|mime| mime == candidate))
330 .map(Str::from)
331}
332
333fn mime_format(mime: &str) -> Option<ImageFormat> {
334 match mime {
335 "image/png" => Some(ImageFormat::Png),
336 "image/jpeg" => Some(ImageFormat::Jpeg),
337 "image/gif" => Some(ImageFormat::Gif),
338 "image/webp" => Some(ImageFormat::Webp),
339 _ => None,
340 }
341}
342
343pub fn dropped_paths(text: &str) -> SmallVec<Str, 2> {
345 let trimmed = text.trim();
346 if trimmed.is_empty() {
347 return SmallVec::new();
348 }
349 if let Some(tokens) = split_path_tokens(trimmed) {
350 let mut paths = SmallVec::new();
351 let mut valid = true;
352 for token in tokens {
353 match normalize_path(&token) {
354 Some(path) if has_absolute_anchor(&path) => paths.push(path),
355 _ => {
356 valid = false;
357 break;
358 },
359 }
360 }
361 if valid && !paths.is_empty() {
362 return paths;
363 }
364 }
365 whole_text_image_path(trimmed)
366 .into_iter()
367 .collect::<SmallVec<Str, 2>>()
368}
369
370pub fn is_image_path(path: &str) -> bool {
372 let Some((_, extension)) = path.rsplit_once('.') else {
373 return false;
374 };
375 ["png", "jpg", "jpeg", "gif", "webp"]
376 .into_iter()
377 .any(|candidate| extension.eq_ignore_ascii_case(candidate))
378}
379
380fn split_path_tokens(text: &str) -> Option<Vec<Str>> {
381 let mut tokens = Vec::new();
382 let mut token = String::new();
383 let mut quote = None;
384 let mut escaped = false;
385 for ch in text.chars() {
386 if escaped {
387 token.push(ch);
388 escaped = false;
389 continue;
390 }
391 if ch == '\\' && quote != Some('\'') {
392 token.push(ch);
393 escaped = true;
394 continue;
395 }
396 if let Some(active) = quote {
397 token.push(ch);
398 if ch == active {
399 quote = None;
400 }
401 continue;
402 }
403 if ch == '\'' || ch == '"' {
404 token.push(ch);
405 quote = Some(ch);
406 continue;
407 }
408 if is_ascii_path_whitespace(ch) {
409 if !token.is_empty() {
410 tokens.push(Str::from(std::mem::take(&mut token)));
411 }
412 continue;
413 }
414 token.push(ch);
415 }
416 if escaped || quote.is_some() {
417 return None;
418 }
419 if !token.is_empty() {
420 tokens.push(Str::from(token));
421 }
422 (!tokens.is_empty()).then_some(tokens)
423}
424
425const fn is_ascii_path_whitespace(ch: char) -> bool {
426 matches!(ch, ' ' | '\t' | '\r' | '\n')
427}
428
429fn normalize_path(raw: &str) -> Option<Str> {
430 let trimmed = raw.trim();
431 let unquoted = match (trimmed.chars().next(), trimmed.chars().last()) {
432 (Some(first @ ('\'' | '"')), Some(last)) if first == last && trimmed.len() > 1 => {
433 &trimmed[first.len_utf8()..trimmed.len() - last.len_utf8()]
434 },
435 _ => trimmed,
436 };
437 if unquoted
438 .get(..7)
439 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("file://"))
440 {
441 return normalize_file_url(unquoted);
442 }
443 let unescaped = shell_unescape(unquoted);
444 if let Some(rest) = unescaped.strip_prefix("~/") {
445 #[allow(deprecated, reason = "the standard-library home lookup matches shell path expansion")]
446 let home = std::env::home_dir()?;
447 return Some(fmts!("{}/{}", home.display(), rest));
448 }
449 Some(unescaped)
450}
451
452fn normalize_file_url(url: &str) -> Option<Str> {
453 let rest = &url[7..];
454 let path = if rest.starts_with('/') {
455 rest
456 } else {
457 let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
458 if !host.eq_ignore_ascii_case("localhost") {
459 return None;
460 }
461 if path.is_empty() {
462 "/"
463 } else {
464 return Some(percent_decode(&format!("/{path}")));
465 }
466 };
467 Some(percent_decode(path))
468}
469
470fn percent_decode(text: &str) -> Str {
471 let bytes = text.as_bytes();
472 let mut output = Vec::with_capacity(bytes.len());
473 let mut index = 0;
474 while index < bytes.len() {
475 if bytes[index] == b'%'
476 && index + 2 < bytes.len()
477 && let (Some(high), Some(low)) =
478 (hex::parse_nibble(bytes[index + 1]), hex::parse_nibble(bytes[index + 2]))
479 {
480 output.push((high << 4) | low);
481 index += 3;
482 } else {
483 output.push(bytes[index]);
484 index += 1;
485 }
486 }
487 Str::from_utf8_lossy(&output)
488}
489
490fn shell_unescape(text: &str) -> Str {
491 let (mut output, text) = if let Some(rest) = text.strip_prefix("\\\\") {
492 (String::from("\\\\"), rest)
493 } else {
494 (String::with_capacity(text.len()), text)
495 };
496 let mut chars = text.chars().peekable();
497 while let Some(ch) = chars.next() {
498 if ch == '\\'
499 && let Some(&next) = chars.peek()
500 && (next.is_whitespace() || "\\'\"()[]{}&;<>|?*!$`".contains(next))
501 {
502 output.push(next);
503 chars.next();
504 } else {
505 output.push(ch);
506 }
507 }
508 Str::from(output)
509}
510
511fn has_absolute_anchor(path: &str) -> bool {
512 path.starts_with('/')
513 || path.starts_with("~/")
514 || path.starts_with("\\\\")
515 || is_windows_drive_path(path)
516}
517
518const fn is_windows_drive_path(path: &str) -> bool {
519 let bytes = path.as_bytes();
520 bytes.len() >= 3
521 && bytes[0].is_ascii_alphabetic()
522 && bytes[1] == b':'
523 && matches!(bytes[2], b'/' | b'\\')
524}
525
526fn whole_text_image_path(text: &str) -> Option<Str> {
527 if text.contains('\r')
528 || text.contains('\n')
529 || !has_raw_anchor(text)
530 || !is_image_path(text)
531 || has_interior_anchor(text)
532 {
533 return None;
534 }
535 if split_path_tokens(text).is_some_and(|tokens| {
536 tokens.len() > 1
537 && tokens[..tokens.len() - 1].iter().any(|token| {
538 normalize_path(token)
539 .is_some_and(|path| has_absolute_anchor(&path) && is_image_path(&path))
540 })
541 }) {
542 return None;
543 }
544 let path = normalize_path(text)?;
545 (has_absolute_anchor(&path) && is_image_path(&path)).then_some(path)
546}
547
548fn has_raw_anchor(path: &str) -> bool {
549 path.starts_with('/')
550 || path.starts_with("~/")
551 || path
552 .get(..7)
553 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("file://"))
554 || path.starts_with("\\\\")
555 || is_windows_drive_path(path)
556}
557
558fn has_interior_anchor(text: &str) -> bool {
559 let mut escaped = false;
560 let chars: Vec<char> = text.chars().collect();
561 for index in 0..chars.len() {
562 let ch = chars[index];
563 if ch == '\\' {
564 escaped = !escaped;
565 continue;
566 }
567 if is_ascii_path_whitespace(ch) && !escaped {
568 let suffix: String = chars[index + 1..].iter().collect();
569 if has_raw_anchor(&suffix)
570 || suffix.starts_with("./")
571 || suffix.starts_with("../")
572 || suffix.starts_with(".\\")
573 || suffix.starts_with("..\\")
574 {
575 return true;
576 }
577 }
578 escaped = false;
579 }
580 false
581}
582
583#[derive(Clone, Debug, Eq, PartialEq)]
585pub enum Clipboard {
586 Text(String),
588 Image(PastedImage),
590 Paths(Vec<Str>),
592}
593
594pub fn read_clipboard() -> Option<Clipboard> {
597 if let Some(image) = read_clipboard_image() {
598 return Some(Clipboard::Image(image));
599 }
600 if let Some(paths) = read_file_urls() {
601 return Some(Clipboard::Paths(paths));
602 }
603 let text = read_clipboard_text()?;
604 (!text.is_empty()).then_some(Clipboard::Text(text))
605}
606
607#[derive(Clone, Copy, Debug, Eq, PartialEq)]
609pub enum ClipboardRead {
610 Smart,
613 Text,
616}
617
618impl ClipboardRead {
619 pub const fn for_key(key: Key) -> Option<Self> {
622 match key {
623 Key::Paste => Some(Self::Smart),
624 Key::PasteRaw => Some(Self::Text),
625 _ => None,
626 }
627 }
628
629 fn read(self) -> Option<Clipboard> {
631 match self {
632 Self::Smart => read_clipboard(),
633 Self::Text => read_clipboard_text().map(Clipboard::Text),
634 }
635 }
636}
637
638pub fn spawn_clipboard_read(
652 scope: ClipboardRead,
653) -> tokio::sync::oneshot::Receiver<Option<Clipboard>> {
654 let (tx, rx) = tokio::sync::oneshot::channel();
655 let _ = thread::Builder::new()
658 .name("omp-tui-clipboard-read".into())
659 .spawn(move || {
660 let _ = tx.send(scope.read());
661 });
662 rx
663}
664
665pub fn read_clipboard_text() -> Option<String> {
671 if cfg!(target_os = "macos") {
672 return capture_text(&["pbpaste"], CLI_TIMEOUT);
673 }
674 if cfg!(windows) {
675 return read_powershell_text().or_else(native_read_text);
678 }
679 if std::env::var_os("TERMUX_VERSION").is_some() {
680 return capture_text(&["termux-clipboard-get"], CLI_TIMEOUT);
681 }
682 if is_wsl()
683 && let Some(text) = read_powershell_text()
684 {
685 return Some(text);
686 }
687 if std::env::var_os("WAYLAND_DISPLAY").is_some()
688 && let Some(text) =
689 capture_text(&["wl-paste", "--type", "text/plain", "--no-newline"], CLI_TIMEOUT)
690 {
691 return Some(text);
692 }
693 if std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some() {
694 return read_x11_text().or_else(native_read_text);
695 }
696 None
697}
698
699pub fn write_clipboard_text(text: &str) -> bool {
701 let bytes = Some(text.as_bytes());
702 if std::env::var_os("TERMUX_VERSION").is_some()
703 && run_capture(&["termux-clipboard-set"], bytes, CLI_TIMEOUT).is_some()
704 {
705 return true;
706 }
707 if native_write_text(text) {
708 return true;
709 }
710 if cfg!(target_os = "macos") {
711 return run_capture(&["pbcopy"], bytes, CLI_TIMEOUT).is_some();
712 }
713 if cfg!(windows) {
714 return run_capture(&["clip.exe"], bytes, CLI_TIMEOUT).is_some();
715 }
716 if std::env::var_os("WAYLAND_DISPLAY").is_some()
717 && run_capture(&["wl-copy"], bytes, CLI_TIMEOUT).is_some()
718 {
719 return true;
720 }
721 if std::env::var_os("DISPLAY").is_some() {
722 return run_capture(&["xclip", "-selection", "clipboard", "-i"], bytes, CLI_TIMEOUT)
723 .is_some()
724 || run_capture(&["xsel", "--clipboard", "--input"], bytes, CLI_TIMEOUT).is_some();
725 }
726 false
727}
728
729fn read_clipboard_image() -> Option<PastedImage> {
730 if std::env::var_os("TERMUX_VERSION").is_some() {
731 return None;
732 }
733 if is_wsl()
734 && let Some(image) = read_powershell_image()
735 {
736 return Some(image);
737 }
738 if cfg!(windows) {
739 return native_read_image().or_else(read_powershell_image);
744 }
745 if cfg!(target_os = "macos") {
746 return native_read_image();
750 }
751 if std::env::var_os("WAYLAND_DISPLAY").is_some()
752 && let Some(image) = read_wayland_image()
753 {
754 return Some(image);
757 }
758 if std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some() {
759 return native_read_image().or_else(read_x11_image);
760 }
761 None
762}
763
764fn native_read_image() -> Option<PastedImage> {
766 let mut clipboard = arboard::Clipboard::new().ok()?;
767 let image = clipboard.get_image().ok()?;
768 encode_rgba_png(&image)
769}
770
771fn native_read_text() -> Option<String> {
773 arboard::Clipboard::new().ok()?.get_text().ok()
774}
775
776#[cfg(target_os = "linux")]
784fn native_write_text(text: &str) -> bool {
785 use std::sync::LazyLock;
786
787 use parking_lot::Mutex;
788
789 static CLIPBOARD: LazyLock<Mutex<Option<arboard::Clipboard>>> =
790 LazyLock::new(|| Mutex::new(None));
791 let mut guard = CLIPBOARD.lock();
792 if guard.is_none() {
793 *guard = arboard::Clipboard::new().ok();
794 }
795 guard
796 .as_mut()
797 .is_some_and(|clipboard| clipboard.set_text(text).is_ok())
798}
799
800#[cfg(not(target_os = "linux"))]
805fn native_write_text(text: &str) -> bool {
806 arboard::Clipboard::new()
807 .and_then(|mut clipboard| clipboard.set_text(text))
808 .is_ok()
809}
810
811fn encode_rgba_png(image: &arboard::ImageData<'_>) -> Option<PastedImage> {
813 let width = u32::try_from(image.width).ok()?;
814 let height = u32::try_from(image.height).ok()?;
815 let expected = image.width.checked_mul(image.height)?.checked_mul(4)?;
816 if image.bytes.len() != expected {
817 return None;
818 }
819 let mut bytes = Vec::new();
820 let mut encoder = png::Encoder::new(&mut bytes, width, height);
821 encoder.set_color(png::ColorType::Rgba);
822 encoder.set_depth(png::BitDepth::Eight);
823 let mut writer = encoder.write_header().ok()?;
824 writer.write_image_data(&image.bytes).ok()?;
825 writer.finish().ok()?;
826 Some(PastedImage { bytes, format: ImageFormat::Png })
827}
828
829fn read_wayland_image() -> Option<PastedImage> {
830 let offered = capture_text(&["wl-paste", "--list-types"], CLI_TIMEOUT)?;
831 for mime in IMAGE_MIMES {
832 if !offered.lines().any(|line| line.trim() == mime) {
833 continue;
834 }
835 let bytes = run_capture(&["wl-paste", "--type", mime], None, CLI_TIMEOUT)?;
836 if !bytes.is_empty() {
837 let format = crate::imagefmt::format(&bytes).or_else(|| mime_format(mime))?;
838 return Some(PastedImage { bytes, format });
839 }
840 }
841 None
842}
843
844fn read_x11_image() -> Option<PastedImage> {
845 let targets =
846 capture_text(&["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], CLI_TIMEOUT)?;
847 if !targets
848 .split_ascii_whitespace()
849 .any(|target| target == "image/png")
850 {
851 return None;
852 }
853 let bytes = run_capture(
854 &["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
855 None,
856 CLI_TIMEOUT,
857 )?;
858 PastedImage::from_bytes(bytes)
859}
860
861fn read_x11_text() -> Option<String> {
862 capture_text(&["xclip", "-selection", "clipboard", "-o"], CLI_TIMEOUT)
863 .or_else(|| capture_text(&["xsel", "--clipboard", "--output"], CLI_TIMEOUT))
864}
865
866fn read_powershell_image() -> Option<PastedImage> {
867 let output = run_capture(
868 &[
869 "powershell.exe",
870 "-NoProfile",
871 "-NonInteractive",
872 "-Sta",
873 "-Command",
874 POWERSHELL_IMAGE_SCRIPT,
875 ],
876 None,
877 POWERSHELL_TIMEOUT,
878 )?;
879 let encoded = std::str::from_utf8(&output).ok()?.trim();
880 if encoded.is_empty() {
881 return None;
882 }
883 let bytes = base64::decode(encoded.as_bytes()).into_vec().ok()?;
884 PastedImage::from_bytes(bytes)
885}
886
887fn read_powershell_text() -> Option<String> {
888 let output = run_capture(
889 &["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_TEXT_SCRIPT],
890 None,
891 POWERSHELL_TIMEOUT,
892 )?;
893 Some(String::from_utf8_lossy(&output).replace("\r\n", "\n"))
894}
895
896fn capture_text(argv: &[&str], timeout: Duration) -> Option<String> {
897 run_capture(argv, None, timeout).map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
898}
899
900fn run_capture(argv: &[&str], stdin: Option<&[u8]>, timeout: Duration) -> Option<Vec<u8>> {
901 let (program, args) = argv.split_first()?;
902 let mut command = Command::new(program);
903 command
904 .args(args)
905 .stdout(Stdio::piped())
906 .stderr(Stdio::null())
907 .stdin(if stdin.is_some() {
908 Stdio::piped()
909 } else {
910 Stdio::null()
911 });
912 let mut child = command.spawn().ok()?;
913 let mut stdout = child.stdout.take()?;
914 let reader = thread::spawn(move || {
915 let mut output = Vec::new();
916 stdout.read_to_end(&mut output).ok().map(|_| output)
917 });
918 let writer = stdin.map(|input| {
919 let input = input.to_vec();
920 let mut child_stdin = child.stdin.take().expect("piped stdin was requested");
921 thread::spawn(move || child_stdin.write_all(&input).ok())
922 });
923 let deadline = Instant::now() + timeout;
924 let status = loop {
925 match child.try_wait() {
926 Ok(Some(status)) => break Some(status),
927 Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(20)),
928 Ok(None) => {
929 let _ = child.kill();
930 break child.wait().ok();
931 },
932 Err(_) => break None,
933 }
934 };
935 if let Some(writer) = writer {
936 let _ = writer.join();
937 }
938 let output = reader.join().ok().flatten()?;
939 status?.success().then_some(output)
940}
941
942fn is_wsl() -> bool {
943 cfg!(target_os = "linux")
944 && (std::env::var_os("WSL_DISTRO_NAME").is_some()
945 || std::env::var_os("WSL_INTEROP").is_some())
946}
947
948fn read_file_urls() -> Option<Vec<Str>> {
949 if !cfg!(target_os = "macos") {
950 return None;
951 }
952 let output =
953 run_capture(&["osascript", "-"], Some(MAC_FILE_URL_SCRIPT.as_bytes()), CLI_TIMEOUT)?;
954 let output = String::from_utf8_lossy(&output);
955 let paths: Vec<_> = output
956 .lines()
957 .map(str::trim)
958 .filter(|line| !line.is_empty())
959 .map(Str::from)
960 .collect();
961 (!paths.is_empty()).then_some(paths)
962}
963
964const MAC_FILE_URL_SCRIPT: &str = r#"on run
968 set output to ""
969 try
970 if (clipboard info for «class furl») is {} then return output
971 set theClip to the clipboard as «class furl»
972 if class of theClip is list then
973 repeat with anItem in theClip
974 try
975 set output to output & POSIX path of anItem & linefeed
976 end try
977 end repeat
978 else
979 try
980 set output to POSIX path of theClip & linefeed
981 end try
982 end if
983 end try
984 return output
985end run
986"#;
987
988const POWERSHELL_IMAGE_SCRIPT: &str = r"
989$ErrorActionPreference = 'Stop'
990Add-Type -AssemblyName System.Windows.Forms
991Add-Type -AssemblyName System.Drawing
992$img = [System.Windows.Forms.Clipboard]::GetImage()
993if ($img -ne $null) {
994 $ms = New-Object System.IO.MemoryStream
995 $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
996 [Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))
997}
998";
999
1000const POWERSHELL_TEXT_SCRIPT: &str = r"
1001$ErrorActionPreference = 'Stop'
1002[Console]::OutputEncoding = [Text.Encoding]::UTF8
1003[Console]::Out.Write([string](Get-Clipboard -Raw))
1004";
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009
1010 fn b64(text: &str) -> String {
1011 base64::encode(text.as_bytes()).into_string()
1012 }
1013
1014 fn offer(events: &mut PasteEvents, mime: &str) {
1015 assert_eq!(
1016 events.handle_osc(&format!("5522;type=read:status=DATA:mime={}", b64(mime))),
1017 PasteProgress::Consumed
1018 );
1019 }
1020
1021 fn complete_text_read(events: &mut PasteEvents, text: &str) -> PasteProgress {
1023 events.handle_osc("5522;type=read:status=OK");
1024 offer(events, "text/plain");
1025 events.handle_osc("5522;type=read:status=DONE");
1026 events.handle_osc(&format!(
1027 "5522;type=read:status=DATA:mime={};{}",
1028 b64("text/plain"),
1029 b64(text)
1030 ));
1031 events.handle_osc("5522;type=read:status=DONE")
1032 }
1033
1034 #[test]
1035 fn stale_reading_state_resets_so_the_next_offer_is_not_wedged() {
1036 let mut events = PasteEvents::default();
1037 let start = Instant::now();
1038 events.handle_osc_at("5522;type=read:status=OK", start);
1040 events
1041 .handle_osc_at(&format!("5522;type=read:status=DATA:mime={}", b64("text/plain")), start);
1042 assert!(matches!(
1043 events.handle_osc_at("5522;type=read:status=DONE", start),
1044 PasteProgress::Reply(_)
1045 ));
1046 let later = start + READ_INACTIVITY_TIMEOUT;
1049 events.handle_osc_at("5522;type=read:status=OK", later);
1050 events
1051 .handle_osc_at(&format!("5522;type=read:status=DATA:mime={}", b64("text/plain")), later);
1052 assert!(
1053 matches!(
1054 events.handle_osc_at("5522;type=read:status=DONE", later),
1055 PasteProgress::Reply(_)
1056 ),
1057 "the fresh offer lists and requests again"
1058 );
1059 events.handle_osc_at(
1060 &format!("5522;type=read:status=DATA:mime={};{}", b64("text/plain"), b64("back")),
1061 later,
1062 );
1063 assert_eq!(
1064 events.handle_osc_at("5522;type=read:status=DONE", later),
1065 PasteProgress::Done(Pasted::Text(Str::from("back")))
1066 );
1067 }
1068
1069 #[test]
1070 fn oversized_transfer_is_dropped_and_the_machine_recovers() {
1071 let mut events = PasteEvents::default();
1072 events.handle_osc("5522;type=read:status=OK");
1073 offer(&mut events, "text/plain");
1074 events.handle_osc("5522;type=read:status=DONE");
1075 let huge = "Q".repeat(MAX_READ_PAYLOAD_BYTES + 1);
1076 events.handle_osc(&format!("5522;type=read:status=DATA:mime={};{huge}", b64("text/plain")));
1077 assert_eq!(
1078 events.handle_osc("5522;type=read:status=DONE"),
1079 PasteProgress::Consumed,
1080 "a transfer past the cap is dropped whole"
1081 );
1082 assert_eq!(
1083 complete_text_read(&mut events, "next"),
1084 PasteProgress::Done(Pasted::Text(Str::from("next")))
1085 );
1086 }
1087
1088 #[test]
1089 fn spec_listing_selects_png_and_reads_matching_chunks() {
1090 let mut events = PasteEvents::default();
1091 assert_eq!(events.handle_osc("5522;type=read:status=OK"), PasteProgress::Consumed);
1092 offer(&mut events, "text/plain");
1093 offer(&mut events, "image/png");
1094 assert_eq!(
1095 events.handle_osc("5522;type=read:status=DONE"),
1096 PasteProgress::Reply(format!("\x1b]5522;type=read:mime={}\x07", b64("image/png")))
1097 );
1098 let png = b"\x89PNG\r\n\x1a\nrest";
1099 let encoded = base64::encode(png).into_string();
1100 let split = encoded.len() / 2;
1101 let wrong =
1102 format!("5522;type=read:status=DATA:mime={};{}", b64("image/jpeg"), b64("ignored"));
1103 events.handle_osc(&wrong);
1104 for chunk in [&encoded[..split], &encoded[split..]] {
1105 events
1106 .handle_osc(&format!("5522;type=read:status=DATA:mime={};{chunk}", b64("image/png")));
1107 }
1108 assert_eq!(
1109 events.handle_osc("5522;type=read:status=DONE"),
1110 PasteProgress::Done(Pasted::Image(PastedImage {
1111 bytes: png.to_vec(),
1112 format: ImageFormat::Png,
1113 }))
1114 );
1115 }
1116
1117 #[test]
1118 fn kitty_dot_listing_preserves_offer_metadata() {
1119 let mut events = PasteEvents::default();
1120 events.handle_osc("5522;type=read:status=OK:pw=secret:loc=primary");
1121 events.handle_osc(&format!(
1122 "5522;type=read:status=DATA:mime={};{}",
1123 b64("."),
1124 b64("text/plain image/gif")
1125 ));
1126 assert_eq!(
1127 events.handle_osc("5522;type=read:status=DONE"),
1128 PasteProgress::Reply(format!(
1129 "\x1b]5522;type=read:loc=primary:pw=secret:name={};{}\x07",
1130 PASTE_EVENT_NAME_BASE64,
1131 b64("image/gif")
1132 ))
1133 );
1134 }
1135
1136 #[test]
1137 fn text_and_empty_reads_complete_as_expected() {
1138 let mut events = PasteEvents::default();
1139 events.handle_osc("5522;type=read:status=OK");
1140 offer(&mut events, "text/plain");
1141 events.handle_osc("5522;type=read:status=DONE");
1142 events.handle_osc(&format!(
1143 "5522;type=read:status=DATA:mime={};{}",
1144 b64("text/plain"),
1145 b64("hello")
1146 ));
1147 assert_eq!(
1148 events.handle_osc("5522;type=read:status=DONE"),
1149 PasteProgress::Done(Pasted::Text(Str::from("hello")))
1150 );
1151
1152 events.handle_osc("5522;type=read:status=OK");
1153 offer(&mut events, "text/plain");
1154 events.handle_osc("5522;type=read:status=DONE");
1155 assert_eq!(events.handle_osc("5522;type=read:status=DONE"), PasteProgress::Consumed);
1156 }
1157
1158 #[test]
1159 fn padded_chunks_decode_independently() {
1160 let mut events = PasteEvents::default();
1164 events.handle_osc("5522;type=read:status=OK");
1165 offer(&mut events, "text/plain");
1166 events.handle_osc("5522;type=read:status=DONE");
1167 let mime = b64("text/plain");
1168 assert_eq!(b64("a"), "YQ==");
1169 for chunk in ["YQ==", "Yg=="] {
1170 events.handle_osc(&format!("5522;type=read:status=DATA:mime={mime};{chunk}"));
1171 }
1172 assert_eq!(
1173 events.handle_osc("5522;type=read:status=DONE"),
1174 PasteProgress::Done(Pasted::Text(Str::from("ab")))
1175 );
1176 }
1177
1178 #[test]
1179 fn errors_reset_and_unrelated_osc_is_not_mine() {
1180 let mut events = PasteEvents::default();
1181 events.handle_osc("5522;type=read:status=OK");
1182 offer(&mut events, "text/plain");
1183 events.handle_osc("5522;type=read:status=ERROR");
1184 assert_eq!(events.handle_osc("5522;type=read:status=DONE"), PasteProgress::Consumed);
1185 assert_eq!(events.handle_osc("52;c;abc"), PasteProgress::NotMine);
1186 }
1187
1188 #[test]
1189 fn classifies_dropped_paths() {
1190 let cases: &[(&str, &[&str])] = &[
1191 ("/tmp/a.png", &["/tmp/a.png"]),
1192 ("'/tmp/a b.png'", &["/tmp/a b.png"]),
1193 ("\"/tmp/a b.png\"", &["/tmp/a b.png"]),
1194 ("/tmp/a\\ b.png", &["/tmp/a b.png"]),
1195 ("file:///tmp/a%20b.png", &["/tmp/a b.png"]),
1196 ("file://localhost/tmp/a.png", &["/tmp/a.png"]),
1197 ("'/tmp/a b.png' /tmp/c.gif", &["/tmp/a b.png", "/tmp/c.gif"]),
1198 ("C:\\Users\\me\\a.png", &["C:\\Users\\me\\a.png"]),
1199 ("\\\\server\\share\\a.png", &["\\\\server\\share\\a.png"]),
1200 ];
1201 for (text, expected) in cases {
1202 assert_eq!(dropped_paths(text).as_slice(), *expected, "{text:?}");
1203 }
1204 }
1205
1206 #[test]
1207 fn whole_text_fallback_recovers_macos_screenshot() {
1208 let path = "/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45\u{202f}PM.png";
1209 assert_eq!(dropped_paths(path).as_slice(), [path]);
1210 }
1211
1212 #[test]
1213 fn rejects_non_paths_and_ambiguous_paths() {
1214 for text in [
1215 "plain prose",
1216 "/tmp/a.png relative.png",
1217 "http://example.com/a.png",
1218 "file://example.com/a.png",
1219 "/tmp/a.png /tmp/b shot.png",
1220 ] {
1221 assert!(dropped_paths(text).is_empty(), "accepted {text:?}");
1222 }
1223 }
1224
1225 #[test]
1226 fn expands_home_path_when_available() {
1227 #[allow(deprecated, reason = "the production path expansion uses the same standard lookup")]
1228 if let Some(home) = std::env::home_dir() {
1229 assert_eq!(dropped_paths("~/image.png").as_slice(), [fmts!(
1230 "{}/image.png",
1231 home.display()
1232 )]);
1233 }
1234 }
1235
1236 #[test]
1237 fn image_extensions_are_exact_and_case_insensitive() {
1238 for path in ["a.PNG", "a.jpg", "a.JPEG", "a.Gif", "a.webp"] {
1239 assert!(is_image_path(path));
1240 }
1241 for path in ["a.bmp", "a.ppm", "png", "a.png.txt"] {
1242 assert!(!is_image_path(path));
1243 }
1244 }
1245
1246 #[test]
1247 fn pasted_image_sniffs_all_supported_formats() {
1248 let cases: &[(&[u8], ImageFormat)] = &[
1249 (b"\x89PNG\r\n\x1a\n", ImageFormat::Png),
1250 (&[0xff, 0xd8], ImageFormat::Jpeg),
1251 (b"GIF87a", ImageFormat::Gif),
1252 (b"RIFF\0\0\0\0WEBP", ImageFormat::Webp),
1253 ];
1254 for (bytes, format) in cases {
1255 assert_eq!(PastedImage::from_bytes(bytes.to_vec()).unwrap().format, *format);
1256 }
1257 assert_eq!(PastedImage::from_bytes(b"garbage".to_vec()), None);
1258 }
1259}