Skip to main content

omp_tui/
paste.rs

1//! Paste handling across terminal protocol, dropped-path classification, and OS
2//! clipboards.
3//!
4//! [`PasteEvents`] implements the sans-I/O OSC 5522 conversation,
5//! [`dropped_paths`] classifies terminal drops, and the clipboard functions
6//! provide blocking native backends. Terminal and runtime integration belongs
7//! beside [`crate::Terminal`].
8//!
9//! The clipboard functions block — subprocess bridges are killed after a few
10//! seconds, but a wedged native clipboard (a dead X11 connection, a stuck
11//! pasteboard) can hold them indefinitely. [`spawn_clipboard_read`] runs the
12//! read on a detached thread reporting through a one-shot channel — never
13//! tokio's blocking pool, which cannot abort a running task and stalls
14//! runtime shutdown behind it. Pair the receiver with a deadline to recover
15//! from a hung native handle.
16
17use 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/// A completed out-of-band paste delivered by the terminal or clipboard.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum Pasted {
38	/// Pasted UTF-8 text, with invalid sequences replaced.
39	Text(Str),
40	/// Pasted image bytes.
41	Image(PastedImage),
42}
43
44/// Raw image payload with its sniffed container format.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct PastedImage {
47	/// Encoded image container bytes.
48	pub bytes:  Vec<u8>,
49	/// Image container format.
50	pub format: ImageFormat,
51}
52
53impl PastedImage {
54	/// Creates an image when `bytes` begin with a recognized container header.
55	pub fn from_bytes(bytes: Vec<u8>) -> Option<Self> {
56		let format = crate::imagefmt::format(&bytes)?;
57		Some(Self { bytes, format })
58	}
59
60	/// Returns the conventional file extension for this image.
61	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	/// Writes the image to a fresh private temporary file and returns its
71	/// path.
72	///
73	/// The file is created with a randomized name, exclusively
74	/// (`O_CREAT | O_EXCL`), and written through the open handle — a
75	/// pre-planted symlink at a guessable `/tmp` name can neither be
76	/// followed nor hijack the write. It persists (is not deleted on drop)
77	/// for the lifetime of the attachment that references it.
78	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
91/// Aggregate cap on buffered OSC 5522 payload chunks (encoded bytes),
92/// matching the decoder's bracketed-paste ceiling. A runaway or malicious
93/// transfer resets instead of growing without bound.
94const MAX_READ_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;
95/// MIME listings beyond this length stop accumulating; no real terminal
96/// offers more than a handful of types.
97const MAX_LISTED_MIMES: usize = 64;
98/// Idle gap after which an unfinished conversation is considered abandoned
99/// (a DONE lost to a truncated link) and resets before the next packet.
100const 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		/// Aggregate encoded length across `chunks`.
114		bytes:  usize,
115	},
116}
117
118/// Sans-I/O OSC 5522 (kitty enhanced paste) read-offer state machine.
119#[derive(Default, Debug)]
120pub struct PasteEvents {
121	phase:       Option<PastePhase>,
122	last_packet: Option<Instant>,
123}
124
125/// One step of the OSC 5522 conversation.
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub enum PasteProgress {
128	/// The OSC body does not belong to protocol 5522.
129	NotMine,
130	/// The packet was handled without producing output.
131	Consumed,
132	/// The terminal must receive this protocol reply.
133	Reply(String),
134	/// A complete paste was decoded.
135	Done(Pasted),
136}
137
138impl PasteEvents {
139	/// Handles one unframed OSC body.
140	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		// A conversation whose peer went quiet (a DONE lost over a truncated
149		// link) must not wedge enhanced paste forever: the OK acknowledging
150		// our read request keeps `Reading` alive, so only staleness can
151		// distinguish a fresh offer from that ack. Reset before processing
152		// so the new packet starts clean.
153		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	/// Clears any in-progress OSC 5522 conversation.
193	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		// A transfer past the cap is dropped whole: a truncated image is
243		// useless, and the reset lets the next offer proceed.
244		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				// Each DATA packet is padded base64 in its own right, so the
278				// chunks decode independently — concatenating the encoded
279				// strings would corrupt at interior padding.
280				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
343/// Classifies a dropped string as one or more absolute local paths.
344pub 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
370/// Returns whether a path has a supported image extension.
371pub 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/// Smart clipboard content, preferring image data over paths and text.
584#[derive(Clone, Debug, Eq, PartialEq)]
585pub enum Clipboard {
586	/// Plain clipboard text.
587	Text(String),
588	/// Encoded clipboard image.
589	Image(PastedImage),
590	/// Local paths copied by a file manager.
591	Paths(Vec<Str>),
592}
593
594/// Reads smart clipboard content, preferring image data, then file paths, then
595/// text.
596pub 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/// Scope of one background clipboard read.
608#[derive(Clone, Copy, Debug, Eq, PartialEq)]
609pub enum ClipboardRead {
610	/// Smart content: image, then copied file paths, then text
611	/// ([`read_clipboard`]).
612	Smart,
613	/// Plain text only ([`read_clipboard_text`]) — the Ctrl+Shift+V
614	/// verbatim-insert contract, no image or file-URL interpretation.
615	Text,
616}
617
618impl ClipboardRead {
619	/// The read a paste chord requests: [`Key::Paste`] smart,
620	/// [`Key::PasteRaw`] text-only, anything else none.
621	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	/// Runs the blocking read this scope selects.
630	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
638/// Starts one background clipboard read on a detached thread, delivering
639/// the result through the returned one-shot channel.
640///
641/// `Ok(None)` means the clipboard was empty or unreadable; a channel
642/// closed without a value means the reader thread could not be spawned,
643/// so callers never wait on a read that will not happen.
644///
645/// The reader is deliberately a detached thread, not a tokio blocking
646/// task: a running blocking task cannot be aborted and would stall
647/// runtime shutdown behind a wedged native clipboard, while a detached
648/// thread dies with the process. Backend subprocesses cap themselves at
649/// 5–8 s, but a hung *native* handle can outlive that — pair the receiver
650/// with a deadline and drop it to abandon the read.
651pub fn spawn_clipboard_read(
652	scope: ClipboardRead,
653) -> tokio::sync::oneshot::Receiver<Option<Clipboard>> {
654	let (tx, rx) = tokio::sync::oneshot::channel();
655	// A spawn error drops `tx`, closing the channel: the failure is
656	// observable immediately instead of after the caller's deadline.
657	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
665/// Reads raw clipboard text.
666///
667/// Platform CLI bridges run first — they match what the OS itself would
668/// paste (`pbpaste`, PowerShell's `Get-Clipboard -Raw`, `wl-paste`) — with
669/// the native arboard backend as the fallback when no bridge is installed.
670pub 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		// PowerShell over arboard: `Get-Clipboard -Raw` survives console
676		// codepages that mangled the legacy shell-out path.
677		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
699/// Writes clipboard text, native backend first with CLI bridges as fallback.
700pub 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		// arboard rejects the CF_DIBV5 payloads Qt-based screenshot tools
740		// (PixPin, Snipaste, ...) put up; PowerShell's
741		// `[Clipboard]::GetImage()` reads them fine, so it backs the native
742		// path instead of a hand-rolled DIB decoder (pi-natives #3426).
743		return native_read_image().or_else(read_powershell_image);
744	}
745	if cfg!(target_os = "macos") {
746		// arboard transcodes whatever representation the pasteboard holds
747		// (TIFF included) to RGBA, reaching payloads an AppleScript
748		// `«class PNGf»` coercion would miss.
749		return native_read_image();
750	}
751	if std::env::var_os("WAYLAND_DISPLAY").is_some()
752		&& let Some(image) = read_wayland_image()
753	{
754		// wl-paste first: it hands over the original container bytes
755		// (PNG/JPEG/WebP/GIF) without an RGBA round-trip.
756		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
764/// Reads an image through arboard, re-encoding the RGBA payload as PNG.
765fn 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
771/// Reads clipboard text through arboard.
772fn native_read_text() -> Option<String> {
773	arboard::Clipboard::new().ok()?.get_text().ok()
774}
775
776/// Writes text through arboard, keeping the Linux handle alive.
777///
778/// X11 and Wayland selections are owner-based: the writing process must stay
779/// alive to answer `SelectionRequest`s, and arboard serves them from a
780/// background thread that lives only as long as some `Clipboard` instance
781/// does — a transient handle would empty the selection the moment it drops.
782/// One process-lifetime instance keeps that owner thread serving.
783#[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/// Writes text through a transient arboard handle.
801///
802/// macOS and Windows retain clipboard contents after the writer exits, so
803/// no owner handle needs to outlive the call.
804#[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
811/// Encodes an arboard RGBA payload as a PNG [`PastedImage`].
812fn 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
964// Returns POSIX paths for the pasteboard's `public.file-url` representation.
965// The `«class furl»` guard exists because AppleScript coerces plain text
966// through HFS paths and mangles URLs.
967const 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	/// Drives one complete text conversation and returns the final step.
1022	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		// Offer completes its listing and we request the payload …
1039		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		// … but the final DONE never arrives. Without staleness recovery the
1047		// `Reading` phase would swallow every future OK forever.
1048		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		// Terminals pad every DATA packet's base64 in its own right:
1161		// b64("a") + b64("b") carries interior `==` padding that a
1162		// concatenated-string decode would reject or truncate at.
1163		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}