use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
fn paste_dir() -> PathBuf {
crate::config::CACHE_DIR.join("pastes")
}
const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n";
#[derive(Debug, PartialEq, Eq)]
pub enum NoImage {
Clipboard,
NoTool,
}
impl NoImage {
pub fn message(&self) -> String {
match self {
NoImage::Clipboard => "No image on the clipboard".to_string(),
NoImage::NoTool if over_ssh() => {
"The clipboard is on the machine you sshed from — F1 says how to get one here"
.to_string()
}
NoImage::NoTool => format!("No tool here can read an image clipboard — {}", HOW),
}
}
}
fn over_ssh() -> bool {
["SSH_CONNECTION", "SSH_TTY", "SSH_CLIENT"]
.iter()
.any(|var| std::env::var_os(var).is_some_and(|v| !v.is_empty()))
}
const HOW: &str = "install wl-clipboard or xclip (WSL uses powershell.exe)";
pub fn image_to_file(ask_terminal: bool) -> Result<PathBuf, NoImage> {
let dir = paste_dir();
if std::fs::create_dir_all(&dir).is_err() {
return Err(NoImage::NoTool);
}
let Ok(dest) = reserve(&dir, "png") else {
return Err(NoImage::NoTool);
};
let mut ran_something = false;
for helper in HELPERS {
match helper.run(&dest) {
Attempt::Wrote => return Ok(dest),
Attempt::Empty => ran_something = true,
Attempt::Missing => {}
}
}
let _ = std::fs::remove_file(&dest);
if ask_terminal
&& (over_ssh() || !ran_something)
&& let Some(image) = image_from_terminal()
{
return write_image(&image).map_err(|_| NoImage::NoTool);
}
match ran_something {
true => Err(NoImage::Clipboard),
false => Err(NoImage::NoTool),
}
}
pub struct PastedImage {
pub bytes: Vec<u8>,
pub ext: &'static str,
}
pub fn image_from_paste(text: &str) -> Option<PastedImage> {
let trimmed = text.trim();
let body = match trimmed.strip_prefix("data:image/") {
Some(rest) => rest.split_once(";base64,")?.1,
None if BARE_PREFIXES.iter().any(|p| trimmed.starts_with(p)) => trimmed,
None => return None,
};
let bytes = crate::util::b64_decode(body)?;
let ext = format_of(&bytes)?;
Some(PastedImage { bytes, ext })
}
const BARE_PREFIXES: &[&str] = &[
"iVBORw0KGgo", "/9j/", "R0lGOD", "UklGR", ];
const MIN_IMAGE_BYTES: usize = 32;
fn format_of(bytes: &[u8]) -> Option<&'static str> {
if bytes.len() < MIN_IMAGE_BYTES {
return None;
}
if bytes.starts_with(PNG_MAGIC) {
Some("png")
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some("jpg")
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
Some("gif")
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(&b"WEBP"[..]) {
Some("webp")
} else if bytes.starts_with(b"BM") {
Some("bmp")
} else {
None
}
}
pub fn write_image(image: &PastedImage) -> std::io::Result<PathBuf> {
let dir = paste_dir();
std::fs::create_dir_all(&dir)?;
let dest = reserve(&dir, image.ext)?;
std::fs::write(&dest, &image.bytes)?;
Ok(dest)
}
fn reserve(dir: &Path, ext: &str) -> std::io::Result<PathBuf> {
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
for n in 1..=99u32 {
let name = match n {
1 => format!("paste-{stamp}.{ext}"),
n => format!("paste-{stamp}-{n}.{ext}"),
};
let dest = dir.join(name);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&dest)
{
Ok(_) => return Ok(dest),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"too many images pasted in the same second",
))
}
enum Attempt {
Wrote,
Empty,
Missing,
}
struct Helper {
command: &'static str,
args: &'static [&'static str],
output: Output,
}
enum Output {
Stdout,
File,
}
const HELPERS: &[Helper] = &[
Helper {
command: "wl-paste",
args: &["--no-newline", "--type", "image/png"],
output: Output::Stdout,
},
Helper {
command: "xclip",
args: &["-selection", "clipboard", "-t", "image/png", "-o"],
output: Output::Stdout,
},
Helper {
command: "powershell.exe",
args: &[
"-NoProfile",
"-STA",
"-Command",
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; \
$i=[System.Windows.Forms.Clipboard]::GetImage(); \
if ($null -eq $i) { exit 1 }; \
$i.Save('{}',[System.Drawing.Imaging.ImageFormat]::Png)",
],
output: Output::File,
},
];
const WSL_POWERSHELL: &[&str] = &[
"/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
"/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/powershell.exe",
];
impl Helper {
fn programs(&self) -> Vec<&'static str> {
let mut out = vec![self.command];
if self.command == "powershell.exe" {
out.extend(
WSL_POWERSHELL
.iter()
.filter(|p| Path::new(p).exists())
.copied(),
);
}
out
}
fn run(&self, dest: &Path) -> Attempt {
let Some(path) = self.dest_for(dest) else {
return Attempt::Missing;
};
let args: Vec<String> = self
.args
.iter()
.map(|arg| arg.replace("{}", &path))
.collect();
let mut out = None;
for program in self.programs() {
let attempt = Command::new(program)
.args(&args)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output();
if let Ok(done) = attempt {
out = Some(done);
break;
}
}
let Some(out) = out else {
return Attempt::Missing;
};
match self.output {
Output::Stdout => match out.stdout.starts_with(PNG_MAGIC) {
true => match std::fs::write(dest, &out.stdout) {
Ok(()) => Attempt::Wrote,
Err(_) => Attempt::Empty,
},
false => Attempt::Empty,
},
Output::File => match is_png(dest) {
true => Attempt::Wrote,
false => Attempt::Empty,
},
}
}
fn dest_for(&self, dest: &Path) -> Option<String> {
if self.command != "powershell.exe" {
return Some(dest.display().to_string());
}
let out = Command::new("wslpath")
.arg("-w")
.arg(dest)
.stderr(Stdio::null())
.output()
.ok()?;
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!path.is_empty()).then_some(path)
}
}
const FIRST_BYTE: Duration = Duration::from_millis(300);
const WHOLE_REPLY: Duration = Duration::from_millis(1500);
fn image_from_terminal() -> Option<PastedImage> {
use std::io::Write;
let mimes =
crate::util::b64_encode(b"image/png image/jpeg image/webp image/gif image/bmp text/plain");
let kitty = format!("\x1b]5522;type=read;{mimes}\x1b\\");
let osc52 = "\x1b]52;c;?\x07";
let mut query = format!("{kitty}{osc52}");
if std::env::var_os("TMUX").is_some() {
for seq in [kitty, osc52.to_string()] {
query.push_str("\x1bPtmux;");
for c in seq.chars() {
if c == '\x1b' {
query.push_str("\x1b\x1b");
} else {
query.push(c);
}
}
query.push_str("\x1b\\");
}
}
let mut out = std::io::stdout();
let _ = out.write_all(query.as_bytes());
let _ = out.flush();
read_clipboard_replies()
}
fn read_clipboard_replies() -> Option<PastedImage> {
let start = Instant::now();
let mut buf = Vec::new();
loop {
let budget = if buf.is_empty() {
FIRST_BYTE
} else {
WHOLE_REPLY
};
let Some(left) = budget.checked_sub(start.elapsed()) else {
break;
};
let mut pfd = libc::pollfd {
fd: 0,
events: libc::POLLIN,
revents: 0,
};
if unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) } <= 0 {
break;
}
let mut chunk = [0u8; 16384];
let n = unsafe { libc::read(0, chunk.as_mut_ptr().cast(), chunk.len()) };
if n <= 0 {
break;
}
buf.extend_from_slice(&chunk[..n as usize]);
if clipboard_reply_complete(&buf) {
break;
}
}
image_from_replies(&buf)
}
fn osc_packet(bytes: &[u8]) -> Option<(&[u8], usize)> {
for (i, &b) in bytes[2..].iter().enumerate() {
match b {
0x07 => return Some((&bytes[2..2 + i], i + 3)),
0x1b if bytes.get(2 + i + 1) == Some(&b'\\') => {
return Some((&bytes[2..2 + i], i + 4));
}
_ => {}
}
}
None
}
fn image_from_replies(buf: &[u8]) -> Option<PastedImage> {
let mut image = Vec::new();
let mut text = Vec::new();
let mut i = 0;
while i + 1 < buf.len() {
if buf[i] == 0x1b && buf[i + 1] == b']' {
let Some((content, len)) = osc_packet(&buf[i..]) else {
break;
};
let (num, rest) = match content.iter().position(|&b| b == b';') {
Some(p) => (&content[..p], &content[p + 1..]),
None => (content, &[][..]),
};
match num {
b"52" => {
if let Some(p) = rest.iter().position(|&b| b == b';')
&& let Ok(s) = std::str::from_utf8(&rest[p + 1..])
&& let Some(bytes) = crate::util::b64_decode(s)
{
text.extend_from_slice(&bytes);
}
}
b"5522" => {
let (meta, payload) = match rest.iter().position(|&b| b == b';') {
Some(p) => (&rest[..p], &rest[p + 1..]),
None => (rest, &[][..]),
};
let mut data = false;
let mut mime = String::new();
for (k, v) in std::str::from_utf8(meta)
.unwrap_or("")
.split(':')
.filter_map(|f| f.split_once('='))
{
match k {
"status" => data = v == "DATA",
"mime" => {
mime = crate::util::b64_decode(v)
.map(|b| String::from_utf8_lossy(&b).into_owned())
.unwrap_or_default();
}
_ => {}
}
}
if data
&& let Ok(s) = std::str::from_utf8(payload)
&& let Some(bytes) = crate::util::b64_decode(s)
{
if mime.starts_with("image/") {
image.extend_from_slice(&bytes);
} else {
text.extend_from_slice(&bytes);
}
}
}
_ => {}
}
i += len;
continue;
}
i += 1;
}
if let Some(ext) = format_of(&image) {
return Some(PastedImage { bytes: image, ext });
}
if let Some(ext) = format_of(&text) {
return Some(PastedImage { bytes: text, ext });
}
image_from_paste(std::str::from_utf8(&text).unwrap_or(""))
}
fn clipboard_reply_complete(buf: &[u8]) -> bool {
let mut saw_52 = false;
let mut kitty_open = false;
let mut kitty_done = false;
let mut i = 0;
while i + 1 < buf.len() {
if buf[i] == 0x1b && buf[i + 1] == b']' {
let Some((content, len)) = osc_packet(&buf[i..]) else {
break;
};
match content.split(|&b| b == b';').next() {
Some(b"52") => saw_52 = true,
Some(b"5522") => {
let after = content.get(5..).unwrap_or(&[]);
let end = after.iter().position(|&b| b == b';').unwrap_or(after.len());
let meta = std::str::from_utf8(&after[..end]).unwrap_or("");
let status = meta.split(':').find_map(|f| f.strip_prefix("status="));
match status {
Some("OK") | Some("DATA") => kitty_open = true,
Some(_) => kitty_done = true,
None => {}
}
}
_ => {}
}
i += len;
continue;
}
i += 1;
}
(saw_52 || kitty_done) && !(kitty_open && !kitty_done)
}
fn is_png(path: &Path) -> bool {
let Ok(bytes) = std::fs::read(path) else {
return false;
};
bytes.starts_with(PNG_MAGIC)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_png_counts_as_an_image() {
let dir = tempfile::tempdir().expect("tempdir");
let good = dir.path().join("good.png");
std::fs::write(&good, PNG_MAGIC).expect("write");
assert!(is_png(&good));
let text = dir.path().join("text.png");
std::fs::write(&text, "<html>not an image</html>").expect("write");
assert!(!is_png(&text));
assert!(!is_png(&dir.path().join("missing.png")));
}
#[test]
#[ignore = "reads the machine's real clipboard"]
fn the_clipboard_image_becomes_a_png_on_disk() {
match image_to_file(false) {
Ok(path) => {
assert!(is_png(&path), "{} is not a PNG", path.display());
eprintln!(
"pasted {} bytes to {}",
std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0),
path.display()
);
}
Err(why) => panic!("nothing was pasted: {}", why.message()),
}
}
#[test]
fn a_second_paste_in_the_same_second_gets_its_own_name() {
let dir = tempfile::tempdir().expect("tempdir");
let first = reserve(dir.path(), "png").expect("first");
let second = reserve(dir.path(), "png").expect("second");
let third = reserve(dir.path(), "png").expect("third");
assert_ne!(first, second);
assert_ne!(second, third);
for path in [&first, &second, &third] {
assert!(path.exists(), "{} was not claimed", path.display());
}
assert!(
second
.file_name()
.unwrap()
.to_string_lossy()
.contains("-2."),
"the second name does not say which it is: {}",
second.display()
);
}
#[test]
fn the_destination_is_substituted_into_every_argument() {
let script = HELPERS
.iter()
.find(|h| h.command == "powershell.exe")
.expect("the PowerShell helper");
let path = "/tmp/paste-1.png".to_string();
let filled: Vec<String> = script
.args
.iter()
.map(|arg| arg.replace("{}", &path))
.collect();
assert!(
filled.iter().any(|a| a.contains("/tmp/paste-1.png")),
"the path never reached the script: {filled:?}"
);
assert!(
!filled.iter().any(|a| a.contains("{}")),
"an argument kept its placeholder: {filled:?}"
);
}
fn fake(magic: &[u8]) -> Vec<u8> {
let mut bytes = magic.to_vec();
bytes.resize(64, 0);
bytes
}
#[test]
fn an_image_in_base64_is_recognised_in_both_spellings() {
let png = crate::util::b64_encode(&fake(PNG_MAGIC));
let jpeg = crate::util::b64_encode(&fake(&[0xFF, 0xD8, 0xFF]));
let gif = crate::util::b64_encode(&fake(b"GIF89a"));
let webp = crate::util::b64_encode(&fake(b"RIFF\x00\x00\x00\x00WEBP"));
for (text, ext) in [
(format!("data:image/png;base64,{png}"), "png"),
(format!("data:image/png;charset=utf-8;base64,{png}"), "png"),
(format!("data:image/jpeg;base64,{jpeg}"), "jpg"),
(png.clone(), "png"),
(jpeg, "jpg"),
(gif, "gif"),
(webp, "webp"),
] {
let image = image_from_paste(&text).unwrap_or_else(|| panic!("{text:?} was refused"));
assert_eq!(image.ext, ext, "{text:?}");
}
}
#[test]
fn prose_is_never_an_image() {
assert!(image_from_paste("please fix the flywheel").is_none());
assert!(image_from_paste("iVBORw0KGgo is not an image").is_none());
let text = format!(
"data:image/png;base64,{}",
crate::util::b64_encode(
b"<html>definitely not a picture, whatever the name says</html>"
)
);
assert!(image_from_paste(&text).is_none());
let tiny = format!(
"data:image/png;base64,{}",
crate::util::b64_encode(PNG_MAGIC)
);
assert!(image_from_paste(&tiny).is_none());
}
#[test]
fn a_wrapped_blob_decodes() {
let b64 = crate::util::b64_encode(&fake(PNG_MAGIC));
let wrapped = b64
.chars()
.collect::<Vec<_>>()
.chunks(20)
.map(|c| c.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n");
let image = image_from_paste(&wrapped).expect("wrapped base64 was refused");
assert_eq!(image.ext, "png");
}
#[test]
fn a_kitty_reply_hands_over_the_image() {
let png = fake(PNG_MAGIC);
let (a, b) = png.split_at(40);
let mime = crate::util::b64_encode(b"image/png");
let reply = format!(
"\x1b]5522;type=read:status=OK\x1b\\\
\x1b]5522;type=read:status=DATA:mime={mime};{}\x1b\\\
\x1b]5522;type=read:status=DATA:mime={mime};{}\x1b\\\
\x1b]5522;type=read:status=DONE\x1b\\",
crate::util::b64_encode(a),
crate::util::b64_encode(b),
);
let image = image_from_replies(reply.as_bytes()).expect("no image in a DATA stream");
assert_eq!(image.ext, "png");
assert_eq!(image.bytes, png);
}
#[test]
fn an_osc52_reply_can_carry_an_image_as_text() {
let inner = crate::util::b64_encode(&fake(PNG_MAGIC));
let reply = format!(
"\x1b]52;c;{}\x07",
crate::util::b64_encode(inner.as_bytes())
);
let image = image_from_replies(reply.as_bytes()).expect("no image in the OSC 52 text");
assert_eq!(image.ext, "png");
}
#[test]
fn a_reply_without_an_image_is_no_image() {
assert!(image_from_replies(b"").is_none());
assert!(image_from_replies(b"\x1b]5522;type=read:status=EPERM\x1b\\").is_none());
let mime = crate::util::b64_encode(b"text/plain");
let reply = format!(
"q\x1b]52;c;{}\x07\x1b]5522;type=read:status=DATA:mime={mime};{}\x1b\\\
\x1b]5522;type=read:status=DONE\x1b\\",
crate::util::b64_encode(b"just words"),
crate::util::b64_encode(b"more words"),
);
assert!(image_from_replies(reply.as_bytes()).is_none());
}
#[test]
fn the_reply_is_complete_when_its_senders_are() {
assert!(!clipboard_reply_complete(b""));
assert!(!clipboard_reply_complete(
b"\x1b]5522;type=read:status=OK\x1b\\"
));
assert!(clipboard_reply_complete(
b"\x1b]5522;type=read:status=OK\x1b\\\x1b]5522;type=read:status=DONE\x1b\\"
));
assert!(clipboard_reply_complete(b"\x1b]52;c;aGk=\x07"));
assert!(!clipboard_reply_complete(
b"\x1b]52;c;aGk=\x07\x1b]5522;type=read:status=OK\x1b\\"
));
}
}