use std::{
io::{Read, Write},
path::PathBuf,
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
use omp_core::{Str, base64, fmts, hex};
use smallvec::SmallVec;
use crate::{Key, imagefmt::ImageFormat};
const CLI_TIMEOUT: Duration = Duration::from_secs(5);
const POWERSHELL_TIMEOUT: Duration = Duration::from_secs(8);
const PASTE_EVENT_NAME_BASE64: &str = "UGFzdGUgZXZlbnQ=";
const IMAGE_MIMES: [&str; 4] = ["image/png", "image/jpeg", "image/webp", "image/gif"];
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Pasted {
Text(Str),
Image(PastedImage),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PastedImage {
pub bytes: Vec<u8>,
pub format: ImageFormat,
}
impl PastedImage {
pub fn from_bytes(bytes: Vec<u8>) -> Option<Self> {
let format = crate::imagefmt::format(&bytes)?;
Some(Self { bytes, format })
}
pub const fn extension(&self) -> &'static str {
match self.format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
ImageFormat::Gif => "gif",
ImageFormat::Webp => "webp",
}
}
pub fn persist(&self) -> std::io::Result<PathBuf> {
use std::io::Write as _;
let mut file = tempfile::Builder::new()
.prefix("omp-tui-paste-")
.suffix(&format!(".{}", self.extension()))
.tempfile()?;
file.write_all(&self.bytes)?;
let (file, path) = file.keep().map_err(|error| error.error)?;
drop(file);
Ok(path)
}
}
const MAX_READ_PAYLOAD_BYTES: usize = 64 * 1024 * 1024;
const MAX_LISTED_MIMES: usize = 64;
const READ_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug)]
enum PastePhase {
Listing {
mimes: SmallVec<Str, 5>,
kitty_dot: bool,
pw: Option<Str>,
loc: Option<Str>,
},
Reading {
mime: Str,
chunks: SmallVec<Str, 4>,
bytes: usize,
},
}
#[derive(Default, Debug)]
pub struct PasteEvents {
phase: Option<PastePhase>,
last_packet: Option<Instant>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PasteProgress {
NotMine,
Consumed,
Reply(String),
Done(Pasted),
}
impl PasteEvents {
pub fn handle_osc(&mut self, payload: &str) -> PasteProgress {
self.handle_osc_at(payload, Instant::now())
}
fn handle_osc_at(&mut self, payload: &str, now: Instant) -> PasteProgress {
let Some(body) = payload.strip_prefix("5522;") else {
return PasteProgress::NotMine;
};
if self.phase.is_some()
&& self
.last_packet
.is_some_and(|at| now.duration_since(at) >= READ_INACTIVITY_TIMEOUT)
{
self.reset();
}
self.last_packet = Some(now);
let (metadata, data) = body.split_once(';').unwrap_or((body, ""));
let metadata = parse_metadata(metadata);
if metadata_value(&metadata, "type") != Some("read") {
return PasteProgress::Consumed;
}
match metadata_value(&metadata, "status") {
Some("OK") => {
if !matches!(self.phase, Some(PastePhase::Reading { .. })) {
self.phase = Some(PastePhase::Listing {
mimes: SmallVec::new(),
kitty_dot: false,
pw: metadata_value(&metadata, "pw").map(Str::from),
loc: (metadata_value(&metadata, "loc") == Some("primary"))
.then(|| Str::from("primary")),
});
}
PasteProgress::Consumed
},
Some("DATA") => {
self.handle_data(&metadata, data);
PasteProgress::Consumed
},
Some("DONE") => self.handle_done(),
Some(_) => {
self.reset();
PasteProgress::Consumed
},
None => PasteProgress::Consumed,
}
}
pub fn reset(&mut self) {
self.phase = None;
self.last_packet = None;
}
fn handle_data(&mut self, metadata: &[(Str, Str)], payload: &str) {
let Some(encoded_mime) = metadata_value(metadata, "mime") else {
return;
};
let Some(mime) = decode_base64_text(encoded_mime) else {
return;
};
let overflow = match self.phase.as_mut() {
Some(PastePhase::Listing { mimes, kitty_dot, .. }) if mime == "." => {
if payload.is_empty() {
return;
}
let Some(listing) = decode_base64_text(payload) else {
return;
};
*kitty_dot = true;
mimes.extend(
listing
.split_ascii_whitespace()
.filter(|candidate| !candidate.is_empty() && *candidate != ".")
.take(MAX_LISTED_MIMES.saturating_sub(mimes.len()))
.map(Str::from),
);
false
},
Some(PastePhase::Listing { mimes, .. }) => {
if mimes.len() < MAX_LISTED_MIMES {
mimes.push(Str::from(mime));
}
false
},
Some(PastePhase::Reading { mime: selected, chunks, bytes })
if selected.as_str() == mime && !payload.is_empty() =>
{
*bytes = bytes.saturating_add(payload.len());
if *bytes > MAX_READ_PAYLOAD_BYTES {
true
} else {
chunks.push(Str::from(payload));
false
}
},
_ => false,
};
if overflow {
self.reset();
}
}
fn handle_done(&mut self) -> PasteProgress {
let Some(phase) = self.phase.take() else {
return PasteProgress::Consumed;
};
match phase {
PastePhase::Listing { mimes, kitty_dot, pw, loc } => {
let Some(mime) = choose_mime(&mimes) else {
return PasteProgress::Consumed;
};
let encoded = base64::encode(mime.as_bytes()).into_string();
let mut reply = String::from("\x1b]5522;type=read");
if let Some(loc) = loc {
reply.push_str(":loc=");
reply.push_str(&loc);
}
if let Some(pw) = pw {
reply.push_str(":pw=");
reply.push_str(&pw);
reply.push_str(":name=");
reply.push_str(PASTE_EVENT_NAME_BASE64);
}
reply.push_str(if kitty_dot { ";" } else { ":mime=" });
reply.push_str(&encoded);
reply.push('\x07');
self.phase = Some(PastePhase::Reading { mime, chunks: SmallVec::new(), bytes: 0 });
PasteProgress::Reply(reply)
},
PastePhase::Reading { mime, chunks, bytes: _ } => {
let mut bytes = Vec::new();
for chunk in &chunks {
let Ok(decoded) = base64::decode(chunk.as_bytes()).into_vec() else {
return PasteProgress::Consumed;
};
bytes.extend_from_slice(&decoded);
}
if bytes.is_empty() {
return PasteProgress::Consumed;
}
if mime == "text/plain" {
return PasteProgress::Done(Pasted::Text(Str::from(
String::from_utf8_lossy(&bytes).as_ref(),
)));
}
let image = crate::imagefmt::format(&bytes)
.or_else(|| mime_format(&mime))
.map(|format| PastedImage { bytes, format });
image.map_or(PasteProgress::Consumed, |image| PasteProgress::Done(Pasted::Image(image)))
},
}
}
}
fn parse_metadata(raw: &str) -> SmallVec<(Str, Str), 6> {
raw.split(':')
.filter_map(|part| {
let (key, value) = part.split_once('=')?;
(!key.is_empty()).then(|| (Str::from(key), Str::from(value)))
})
.collect()
}
fn metadata_value<'a>(metadata: &'a [(Str, Str)], key: &str) -> Option<&'a str> {
metadata
.iter()
.rev()
.find_map(|(candidate, value)| (candidate == key).then(|| value.as_str()))
}
fn decode_base64_text(encoded: &str) -> Option<String> {
let bytes = base64::decode(encoded.as_bytes()).into_vec().ok()?;
String::from_utf8(bytes).ok()
}
fn choose_mime(mimes: &[Str]) -> Option<Str> {
IMAGE_MIMES
.into_iter()
.chain(["text/plain"])
.find(|candidate| mimes.iter().any(|mime| mime == candidate))
.map(Str::from)
}
fn mime_format(mime: &str) -> Option<ImageFormat> {
match mime {
"image/png" => Some(ImageFormat::Png),
"image/jpeg" => Some(ImageFormat::Jpeg),
"image/gif" => Some(ImageFormat::Gif),
"image/webp" => Some(ImageFormat::Webp),
_ => None,
}
}
pub fn dropped_paths(text: &str) -> SmallVec<Str, 2> {
let trimmed = text.trim();
if trimmed.is_empty() {
return SmallVec::new();
}
if let Some(tokens) = split_path_tokens(trimmed) {
let mut paths = SmallVec::new();
let mut valid = true;
for token in tokens {
match normalize_path(&token) {
Some(path) if has_absolute_anchor(&path) => paths.push(path),
_ => {
valid = false;
break;
},
}
}
if valid && !paths.is_empty() {
return paths;
}
}
whole_text_image_path(trimmed)
.into_iter()
.collect::<SmallVec<Str, 2>>()
}
pub fn is_image_path(path: &str) -> bool {
let Some((_, extension)) = path.rsplit_once('.') else {
return false;
};
["png", "jpg", "jpeg", "gif", "webp"]
.into_iter()
.any(|candidate| extension.eq_ignore_ascii_case(candidate))
}
fn split_path_tokens(text: &str) -> Option<Vec<Str>> {
let mut tokens = Vec::new();
let mut token = String::new();
let mut quote = None;
let mut escaped = false;
for ch in text.chars() {
if escaped {
token.push(ch);
escaped = false;
continue;
}
if ch == '\\' && quote != Some('\'') {
token.push(ch);
escaped = true;
continue;
}
if let Some(active) = quote {
token.push(ch);
if ch == active {
quote = None;
}
continue;
}
if ch == '\'' || ch == '"' {
token.push(ch);
quote = Some(ch);
continue;
}
if is_ascii_path_whitespace(ch) {
if !token.is_empty() {
tokens.push(Str::from(std::mem::take(&mut token)));
}
continue;
}
token.push(ch);
}
if escaped || quote.is_some() {
return None;
}
if !token.is_empty() {
tokens.push(Str::from(token));
}
(!tokens.is_empty()).then_some(tokens)
}
const fn is_ascii_path_whitespace(ch: char) -> bool {
matches!(ch, ' ' | '\t' | '\r' | '\n')
}
fn normalize_path(raw: &str) -> Option<Str> {
let trimmed = raw.trim();
let unquoted = match (trimmed.chars().next(), trimmed.chars().last()) {
(Some(first @ ('\'' | '"')), Some(last)) if first == last && trimmed.len() > 1 => {
&trimmed[first.len_utf8()..trimmed.len() - last.len_utf8()]
},
_ => trimmed,
};
if unquoted
.get(..7)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("file://"))
{
return normalize_file_url(unquoted);
}
let unescaped = shell_unescape(unquoted);
if let Some(rest) = unescaped.strip_prefix("~/") {
#[allow(deprecated, reason = "the standard-library home lookup matches shell path expansion")]
let home = std::env::home_dir()?;
return Some(fmts!("{}/{}", home.display(), rest));
}
Some(unescaped)
}
fn normalize_file_url(url: &str) -> Option<Str> {
let rest = &url[7..];
let path = if rest.starts_with('/') {
rest
} else {
let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
if !host.eq_ignore_ascii_case("localhost") {
return None;
}
if path.is_empty() {
"/"
} else {
return Some(percent_decode(&format!("/{path}")));
}
};
Some(percent_decode(path))
}
fn percent_decode(text: &str) -> Str {
let bytes = text.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& index + 2 < bytes.len()
&& let (Some(high), Some(low)) =
(hex::parse_nibble(bytes[index + 1]), hex::parse_nibble(bytes[index + 2]))
{
output.push((high << 4) | low);
index += 3;
} else {
output.push(bytes[index]);
index += 1;
}
}
Str::from_utf8_lossy(&output)
}
fn shell_unescape(text: &str) -> Str {
let (mut output, text) = if let Some(rest) = text.strip_prefix("\\\\") {
(String::from("\\\\"), rest)
} else {
(String::with_capacity(text.len()), text)
};
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\'
&& let Some(&next) = chars.peek()
&& (next.is_whitespace() || "\\'\"()[]{}&;<>|?*!$`".contains(next))
{
output.push(next);
chars.next();
} else {
output.push(ch);
}
}
Str::from(output)
}
fn has_absolute_anchor(path: &str) -> bool {
path.starts_with('/')
|| path.starts_with("~/")
|| path.starts_with("\\\\")
|| is_windows_drive_path(path)
}
const fn is_windows_drive_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\')
}
fn whole_text_image_path(text: &str) -> Option<Str> {
if text.contains('\r')
|| text.contains('\n')
|| !has_raw_anchor(text)
|| !is_image_path(text)
|| has_interior_anchor(text)
{
return None;
}
if split_path_tokens(text).is_some_and(|tokens| {
tokens.len() > 1
&& tokens[..tokens.len() - 1].iter().any(|token| {
normalize_path(token)
.is_some_and(|path| has_absolute_anchor(&path) && is_image_path(&path))
})
}) {
return None;
}
let path = normalize_path(text)?;
(has_absolute_anchor(&path) && is_image_path(&path)).then_some(path)
}
fn has_raw_anchor(path: &str) -> bool {
path.starts_with('/')
|| path.starts_with("~/")
|| path
.get(..7)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("file://"))
|| path.starts_with("\\\\")
|| is_windows_drive_path(path)
}
fn has_interior_anchor(text: &str) -> bool {
let mut escaped = false;
let chars: Vec<char> = text.chars().collect();
for index in 0..chars.len() {
let ch = chars[index];
if ch == '\\' {
escaped = !escaped;
continue;
}
if is_ascii_path_whitespace(ch) && !escaped {
let suffix: String = chars[index + 1..].iter().collect();
if has_raw_anchor(&suffix)
|| suffix.starts_with("./")
|| suffix.starts_with("../")
|| suffix.starts_with(".\\")
|| suffix.starts_with("..\\")
{
return true;
}
}
escaped = false;
}
false
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Clipboard {
Text(String),
Image(PastedImage),
Paths(Vec<Str>),
}
pub fn read_clipboard() -> Option<Clipboard> {
if let Some(image) = read_clipboard_image() {
return Some(Clipboard::Image(image));
}
if let Some(paths) = read_file_urls() {
return Some(Clipboard::Paths(paths));
}
let text = read_clipboard_text()?;
(!text.is_empty()).then_some(Clipboard::Text(text))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardRead {
Smart,
Text,
}
impl ClipboardRead {
pub const fn for_key(key: Key) -> Option<Self> {
match key {
Key::Paste => Some(Self::Smart),
Key::PasteRaw => Some(Self::Text),
_ => None,
}
}
fn read(self) -> Option<Clipboard> {
match self {
Self::Smart => read_clipboard(),
Self::Text => read_clipboard_text().map(Clipboard::Text),
}
}
}
pub fn spawn_clipboard_read(
scope: ClipboardRead,
) -> tokio::sync::oneshot::Receiver<Option<Clipboard>> {
let (tx, rx) = tokio::sync::oneshot::channel();
let _ = thread::Builder::new()
.name("omp-tui-clipboard-read".into())
.spawn(move || {
let _ = tx.send(scope.read());
});
rx
}
pub fn read_clipboard_text() -> Option<String> {
if cfg!(target_os = "macos") {
return capture_text(&["pbpaste"], CLI_TIMEOUT);
}
if cfg!(windows) {
return read_powershell_text().or_else(native_read_text);
}
if std::env::var_os("TERMUX_VERSION").is_some() {
return capture_text(&["termux-clipboard-get"], CLI_TIMEOUT);
}
if is_wsl()
&& let Some(text) = read_powershell_text()
{
return Some(text);
}
if std::env::var_os("WAYLAND_DISPLAY").is_some()
&& let Some(text) =
capture_text(&["wl-paste", "--type", "text/plain", "--no-newline"], CLI_TIMEOUT)
{
return Some(text);
}
if std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some() {
return read_x11_text().or_else(native_read_text);
}
None
}
pub fn write_clipboard_text(text: &str) -> bool {
let bytes = Some(text.as_bytes());
if std::env::var_os("TERMUX_VERSION").is_some()
&& run_capture(&["termux-clipboard-set"], bytes, CLI_TIMEOUT).is_some()
{
return true;
}
if native_write_text(text) {
return true;
}
if cfg!(target_os = "macos") {
return run_capture(&["pbcopy"], bytes, CLI_TIMEOUT).is_some();
}
if cfg!(windows) {
return run_capture(&["clip.exe"], bytes, CLI_TIMEOUT).is_some();
}
if std::env::var_os("WAYLAND_DISPLAY").is_some()
&& run_capture(&["wl-copy"], bytes, CLI_TIMEOUT).is_some()
{
return true;
}
if std::env::var_os("DISPLAY").is_some() {
return run_capture(&["xclip", "-selection", "clipboard", "-i"], bytes, CLI_TIMEOUT)
.is_some()
|| run_capture(&["xsel", "--clipboard", "--input"], bytes, CLI_TIMEOUT).is_some();
}
false
}
fn read_clipboard_image() -> Option<PastedImage> {
if std::env::var_os("TERMUX_VERSION").is_some() {
return None;
}
if is_wsl()
&& let Some(image) = read_powershell_image()
{
return Some(image);
}
if cfg!(windows) {
return native_read_image().or_else(read_powershell_image);
}
if cfg!(target_os = "macos") {
return native_read_image();
}
if std::env::var_os("WAYLAND_DISPLAY").is_some()
&& let Some(image) = read_wayland_image()
{
return Some(image);
}
if std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some() {
return native_read_image().or_else(read_x11_image);
}
None
}
fn native_read_image() -> Option<PastedImage> {
let mut clipboard = arboard::Clipboard::new().ok()?;
let image = clipboard.get_image().ok()?;
encode_rgba_png(&image)
}
fn native_read_text() -> Option<String> {
arboard::Clipboard::new().ok()?.get_text().ok()
}
#[cfg(target_os = "linux")]
fn native_write_text(text: &str) -> bool {
use std::sync::LazyLock;
use parking_lot::Mutex;
static CLIPBOARD: LazyLock<Mutex<Option<arboard::Clipboard>>> =
LazyLock::new(|| Mutex::new(None));
let mut guard = CLIPBOARD.lock();
if guard.is_none() {
*guard = arboard::Clipboard::new().ok();
}
guard
.as_mut()
.is_some_and(|clipboard| clipboard.set_text(text).is_ok())
}
#[cfg(not(target_os = "linux"))]
fn native_write_text(text: &str) -> bool {
arboard::Clipboard::new()
.and_then(|mut clipboard| clipboard.set_text(text))
.is_ok()
}
fn encode_rgba_png(image: &arboard::ImageData<'_>) -> Option<PastedImage> {
let width = u32::try_from(image.width).ok()?;
let height = u32::try_from(image.height).ok()?;
let expected = image.width.checked_mul(image.height)?.checked_mul(4)?;
if image.bytes.len() != expected {
return None;
}
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().ok()?;
writer.write_image_data(&image.bytes).ok()?;
writer.finish().ok()?;
Some(PastedImage { bytes, format: ImageFormat::Png })
}
fn read_wayland_image() -> Option<PastedImage> {
let offered = capture_text(&["wl-paste", "--list-types"], CLI_TIMEOUT)?;
for mime in IMAGE_MIMES {
if !offered.lines().any(|line| line.trim() == mime) {
continue;
}
let bytes = run_capture(&["wl-paste", "--type", mime], None, CLI_TIMEOUT)?;
if !bytes.is_empty() {
let format = crate::imagefmt::format(&bytes).or_else(|| mime_format(mime))?;
return Some(PastedImage { bytes, format });
}
}
None
}
fn read_x11_image() -> Option<PastedImage> {
let targets =
capture_text(&["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], CLI_TIMEOUT)?;
if !targets
.split_ascii_whitespace()
.any(|target| target == "image/png")
{
return None;
}
let bytes = run_capture(
&["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
None,
CLI_TIMEOUT,
)?;
PastedImage::from_bytes(bytes)
}
fn read_x11_text() -> Option<String> {
capture_text(&["xclip", "-selection", "clipboard", "-o"], CLI_TIMEOUT)
.or_else(|| capture_text(&["xsel", "--clipboard", "--output"], CLI_TIMEOUT))
}
fn read_powershell_image() -> Option<PastedImage> {
let output = run_capture(
&[
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-Sta",
"-Command",
POWERSHELL_IMAGE_SCRIPT,
],
None,
POWERSHELL_TIMEOUT,
)?;
let encoded = std::str::from_utf8(&output).ok()?.trim();
if encoded.is_empty() {
return None;
}
let bytes = base64::decode(encoded.as_bytes()).into_vec().ok()?;
PastedImage::from_bytes(bytes)
}
fn read_powershell_text() -> Option<String> {
let output = run_capture(
&["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_TEXT_SCRIPT],
None,
POWERSHELL_TIMEOUT,
)?;
Some(String::from_utf8_lossy(&output).replace("\r\n", "\n"))
}
fn capture_text(argv: &[&str], timeout: Duration) -> Option<String> {
run_capture(argv, None, timeout).map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
}
fn run_capture(argv: &[&str], stdin: Option<&[u8]>, timeout: Duration) -> Option<Vec<u8>> {
let (program, args) = argv.split_first()?;
let mut command = Command::new(program);
command
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.stdin(if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = command.spawn().ok()?;
let mut stdout = child.stdout.take()?;
let reader = thread::spawn(move || {
let mut output = Vec::new();
stdout.read_to_end(&mut output).ok().map(|_| output)
});
let writer = stdin.map(|input| {
let input = input.to_vec();
let mut child_stdin = child.stdin.take().expect("piped stdin was requested");
thread::spawn(move || child_stdin.write_all(&input).ok())
});
let deadline = Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(20)),
Ok(None) => {
let _ = child.kill();
break child.wait().ok();
},
Err(_) => break None,
}
};
if let Some(writer) = writer {
let _ = writer.join();
}
let output = reader.join().ok().flatten()?;
status?.success().then_some(output)
}
fn is_wsl() -> bool {
cfg!(target_os = "linux")
&& (std::env::var_os("WSL_DISTRO_NAME").is_some()
|| std::env::var_os("WSL_INTEROP").is_some())
}
fn read_file_urls() -> Option<Vec<Str>> {
if !cfg!(target_os = "macos") {
return None;
}
let output =
run_capture(&["osascript", "-"], Some(MAC_FILE_URL_SCRIPT.as_bytes()), CLI_TIMEOUT)?;
let output = String::from_utf8_lossy(&output);
let paths: Vec<_> = output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(Str::from)
.collect();
(!paths.is_empty()).then_some(paths)
}
const MAC_FILE_URL_SCRIPT: &str = r#"on run
set output to ""
try
if (clipboard info for «class furl») is {} then return output
set theClip to the clipboard as «class furl»
if class of theClip is list then
repeat with anItem in theClip
try
set output to output & POSIX path of anItem & linefeed
end try
end repeat
else
try
set output to POSIX path of theClip & linefeed
end try
end if
end try
return output
end run
"#;
const POWERSHELL_IMAGE_SCRIPT: &str = r"
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$img = [System.Windows.Forms.Clipboard]::GetImage()
if ($img -ne $null) {
$ms = New-Object System.IO.MemoryStream
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))
}
";
const POWERSHELL_TEXT_SCRIPT: &str = r"
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.Encoding]::UTF8
[Console]::Out.Write([string](Get-Clipboard -Raw))
";
#[cfg(test)]
mod tests {
use super::*;
fn b64(text: &str) -> String {
base64::encode(text.as_bytes()).into_string()
}
fn offer(events: &mut PasteEvents, mime: &str) {
assert_eq!(
events.handle_osc(&format!("5522;type=read:status=DATA:mime={}", b64(mime))),
PasteProgress::Consumed
);
}
fn complete_text_read(events: &mut PasteEvents, text: &str) -> PasteProgress {
events.handle_osc("5522;type=read:status=OK");
offer(events, "text/plain");
events.handle_osc("5522;type=read:status=DONE");
events.handle_osc(&format!(
"5522;type=read:status=DATA:mime={};{}",
b64("text/plain"),
b64(text)
));
events.handle_osc("5522;type=read:status=DONE")
}
#[test]
fn stale_reading_state_resets_so_the_next_offer_is_not_wedged() {
let mut events = PasteEvents::default();
let start = Instant::now();
events.handle_osc_at("5522;type=read:status=OK", start);
events
.handle_osc_at(&format!("5522;type=read:status=DATA:mime={}", b64("text/plain")), start);
assert!(matches!(
events.handle_osc_at("5522;type=read:status=DONE", start),
PasteProgress::Reply(_)
));
let later = start + READ_INACTIVITY_TIMEOUT;
events.handle_osc_at("5522;type=read:status=OK", later);
events
.handle_osc_at(&format!("5522;type=read:status=DATA:mime={}", b64("text/plain")), later);
assert!(
matches!(
events.handle_osc_at("5522;type=read:status=DONE", later),
PasteProgress::Reply(_)
),
"the fresh offer lists and requests again"
);
events.handle_osc_at(
&format!("5522;type=read:status=DATA:mime={};{}", b64("text/plain"), b64("back")),
later,
);
assert_eq!(
events.handle_osc_at("5522;type=read:status=DONE", later),
PasteProgress::Done(Pasted::Text(Str::from("back")))
);
}
#[test]
fn oversized_transfer_is_dropped_and_the_machine_recovers() {
let mut events = PasteEvents::default();
events.handle_osc("5522;type=read:status=OK");
offer(&mut events, "text/plain");
events.handle_osc("5522;type=read:status=DONE");
let huge = "Q".repeat(MAX_READ_PAYLOAD_BYTES + 1);
events.handle_osc(&format!("5522;type=read:status=DATA:mime={};{huge}", b64("text/plain")));
assert_eq!(
events.handle_osc("5522;type=read:status=DONE"),
PasteProgress::Consumed,
"a transfer past the cap is dropped whole"
);
assert_eq!(
complete_text_read(&mut events, "next"),
PasteProgress::Done(Pasted::Text(Str::from("next")))
);
}
#[test]
fn spec_listing_selects_png_and_reads_matching_chunks() {
let mut events = PasteEvents::default();
assert_eq!(events.handle_osc("5522;type=read:status=OK"), PasteProgress::Consumed);
offer(&mut events, "text/plain");
offer(&mut events, "image/png");
assert_eq!(
events.handle_osc("5522;type=read:status=DONE"),
PasteProgress::Reply(format!("\x1b]5522;type=read:mime={}\x07", b64("image/png")))
);
let png = b"\x89PNG\r\n\x1a\nrest";
let encoded = base64::encode(png).into_string();
let split = encoded.len() / 2;
let wrong =
format!("5522;type=read:status=DATA:mime={};{}", b64("image/jpeg"), b64("ignored"));
events.handle_osc(&wrong);
for chunk in [&encoded[..split], &encoded[split..]] {
events
.handle_osc(&format!("5522;type=read:status=DATA:mime={};{chunk}", b64("image/png")));
}
assert_eq!(
events.handle_osc("5522;type=read:status=DONE"),
PasteProgress::Done(Pasted::Image(PastedImage {
bytes: png.to_vec(),
format: ImageFormat::Png,
}))
);
}
#[test]
fn kitty_dot_listing_preserves_offer_metadata() {
let mut events = PasteEvents::default();
events.handle_osc("5522;type=read:status=OK:pw=secret:loc=primary");
events.handle_osc(&format!(
"5522;type=read:status=DATA:mime={};{}",
b64("."),
b64("text/plain image/gif")
));
assert_eq!(
events.handle_osc("5522;type=read:status=DONE"),
PasteProgress::Reply(format!(
"\x1b]5522;type=read:loc=primary:pw=secret:name={};{}\x07",
PASTE_EVENT_NAME_BASE64,
b64("image/gif")
))
);
}
#[test]
fn text_and_empty_reads_complete_as_expected() {
let mut events = PasteEvents::default();
events.handle_osc("5522;type=read:status=OK");
offer(&mut events, "text/plain");
events.handle_osc("5522;type=read:status=DONE");
events.handle_osc(&format!(
"5522;type=read:status=DATA:mime={};{}",
b64("text/plain"),
b64("hello")
));
assert_eq!(
events.handle_osc("5522;type=read:status=DONE"),
PasteProgress::Done(Pasted::Text(Str::from("hello")))
);
events.handle_osc("5522;type=read:status=OK");
offer(&mut events, "text/plain");
events.handle_osc("5522;type=read:status=DONE");
assert_eq!(events.handle_osc("5522;type=read:status=DONE"), PasteProgress::Consumed);
}
#[test]
fn padded_chunks_decode_independently() {
let mut events = PasteEvents::default();
events.handle_osc("5522;type=read:status=OK");
offer(&mut events, "text/plain");
events.handle_osc("5522;type=read:status=DONE");
let mime = b64("text/plain");
assert_eq!(b64("a"), "YQ==");
for chunk in ["YQ==", "Yg=="] {
events.handle_osc(&format!("5522;type=read:status=DATA:mime={mime};{chunk}"));
}
assert_eq!(
events.handle_osc("5522;type=read:status=DONE"),
PasteProgress::Done(Pasted::Text(Str::from("ab")))
);
}
#[test]
fn errors_reset_and_unrelated_osc_is_not_mine() {
let mut events = PasteEvents::default();
events.handle_osc("5522;type=read:status=OK");
offer(&mut events, "text/plain");
events.handle_osc("5522;type=read:status=ERROR");
assert_eq!(events.handle_osc("5522;type=read:status=DONE"), PasteProgress::Consumed);
assert_eq!(events.handle_osc("52;c;abc"), PasteProgress::NotMine);
}
#[test]
fn classifies_dropped_paths() {
let cases: &[(&str, &[&str])] = &[
("/tmp/a.png", &["/tmp/a.png"]),
("'/tmp/a b.png'", &["/tmp/a b.png"]),
("\"/tmp/a b.png\"", &["/tmp/a b.png"]),
("/tmp/a\\ b.png", &["/tmp/a b.png"]),
("file:///tmp/a%20b.png", &["/tmp/a b.png"]),
("file://localhost/tmp/a.png", &["/tmp/a.png"]),
("'/tmp/a b.png' /tmp/c.gif", &["/tmp/a b.png", "/tmp/c.gif"]),
("C:\\Users\\me\\a.png", &["C:\\Users\\me\\a.png"]),
("\\\\server\\share\\a.png", &["\\\\server\\share\\a.png"]),
];
for (text, expected) in cases {
assert_eq!(dropped_paths(text).as_slice(), *expected, "{text:?}");
}
}
#[test]
fn whole_text_fallback_recovers_macos_screenshot() {
let path = "/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45\u{202f}PM.png";
assert_eq!(dropped_paths(path).as_slice(), [path]);
}
#[test]
fn rejects_non_paths_and_ambiguous_paths() {
for text in [
"plain prose",
"/tmp/a.png relative.png",
"http://example.com/a.png",
"file://example.com/a.png",
"/tmp/a.png /tmp/b shot.png",
] {
assert!(dropped_paths(text).is_empty(), "accepted {text:?}");
}
}
#[test]
fn expands_home_path_when_available() {
#[allow(deprecated, reason = "the production path expansion uses the same standard lookup")]
if let Some(home) = std::env::home_dir() {
assert_eq!(dropped_paths("~/image.png").as_slice(), [fmts!(
"{}/image.png",
home.display()
)]);
}
}
#[test]
fn image_extensions_are_exact_and_case_insensitive() {
for path in ["a.PNG", "a.jpg", "a.JPEG", "a.Gif", "a.webp"] {
assert!(is_image_path(path));
}
for path in ["a.bmp", "a.ppm", "png", "a.png.txt"] {
assert!(!is_image_path(path));
}
}
#[test]
fn pasted_image_sniffs_all_supported_formats() {
let cases: &[(&[u8], ImageFormat)] = &[
(b"\x89PNG\r\n\x1a\n", ImageFormat::Png),
(&[0xff, 0xd8], ImageFormat::Jpeg),
(b"GIF87a", ImageFormat::Gif),
(b"RIFF\0\0\0\0WEBP", ImageFormat::Webp),
];
for (bytes, format) in cases {
assert_eq!(PastedImage::from_bytes(bytes.to_vec()).unwrap().format, *format);
}
assert_eq!(PastedImage::from_bytes(b"garbage".to_vec()), None);
}
}