use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
use crate::utils::{output_with_timeout, write_stdin_with_timeout};
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
const DATA_TIMEOUT: Duration = Duration::from_secs(5);
const POWERSHELL_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy)]
enum ClipboardBackend {
Wayland,
X11,
MacOS,
Windows,
}
const MAX_FILE_PASTE_BYTES: u64 = 32 * 1024 * 1024;
fn tool_exists(name: &str) -> bool {
let mut cmd = if cfg!(windows) {
Command::new("where.exe")
} else {
Command::new("which")
};
output_with_timeout(cmd.arg(name), PROBE_TIMEOUT)
.map(|o| o.status.success())
.unwrap_or(false)
}
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn powershell_command(script: &str) -> Command {
static HOST: OnceLock<(&'static str, bool)> = OnceLock::new();
let (exe, sta) = *HOST.get_or_init(|| {
if tool_exists("powershell") {
("powershell", false)
} else {
("pwsh", true)
}
});
let mut cmd = Command::new(exe);
cmd.arg("-NoProfile");
if sta {
cmd.arg("-STA");
}
cmd.args(["-Command", script]);
cmd
}
fn image_format_for_path(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"png" => "png",
"jpg" | "jpeg" => "jpeg",
"gif" => "gif",
"webp" => "webp",
"bmp" => "bmp",
"tif" | "tiff" => "tiff",
_ => return None,
})
}
#[derive(Debug, Clone, PartialEq)]
enum ImageSource {
Inline,
File(PathBuf),
None,
}
fn path_from_file_uri(uri: &str) -> Option<PathBuf> {
let rest = uri.trim().strip_prefix("file://")?;
let rest = rest.strip_prefix('/').unwrap_or(rest);
let mut out = String::with_capacity(rest.len());
let mut bytes = Vec::with_capacity(rest.len());
let mut chars = rest.chars();
while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
match u8::from_str_radix(&hex, 16) {
Ok(b) => bytes.push(b),
Err(_) => return None,
}
} else {
let mut buf = [0_u8; 4];
bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
}
out.push_str(&String::from_utf8(bytes).ok()?);
let looks_windows = out.as_bytes().get(1) == Some(&b':');
if !looks_windows {
out.insert(0, '/');
}
Some(PathBuf::from(out))
}
fn first_pasteable_file<'a>(paths: impl IntoIterator<Item = &'a str>) -> Option<PathBuf> {
paths.into_iter().find_map(|raw| {
let raw = raw.trim();
if raw.is_empty() || raw.starts_with('#') {
return None;
}
let path = if raw.starts_with("file://") {
path_from_file_uri(raw)?
} else {
PathBuf::from(raw)
};
(image_format_for_path(&path).is_some() && path.is_file()).then_some(path)
})
}
fn read_image_file(path: &Path) -> Result<(Vec<u8>, String)> {
let format = image_format_for_path(path)
.with_context(|| format!("{} is not an image file", path.display()))?;
let len = std::fs::metadata(path)
.with_context(|| format!("Failed to stat {}", path.display()))?
.len();
anyhow::ensure!(
len <= MAX_FILE_PASTE_BYTES,
"{} is {len} bytes; the clipboard file-paste limit is {MAX_FILE_PASTE_BYTES}",
path.display(),
);
let bytes =
std::fs::read(path).with_context(|| format!("Failed to read {}", path.display()))?;
anyhow::ensure!(!bytes.is_empty(), "{} is empty", path.display());
Ok((bytes, format.to_string()))
}
const WINDOWS_PROBE_SCRIPT: &str = "\
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms
$d = [System.Windows.Forms.Clipboard]::GetDataObject()
if ($null -ne $d) {
foreach ($f in 'PNG', 'DeviceIndependentBitmap', 'Bitmap') {
if ($d.GetDataPresent($f)) { Write-Output 'image'; break }
}
}
if ([System.Windows.Forms.Clipboard]::ContainsFileDropList()) {
foreach ($p in [System.Windows.Forms.Clipboard]::GetFileDropList()) {
Write-Output ('file:' + $p)
}
}";
fn probe_image_source() -> ImageSource {
let Some(backend) = detect_backend() else {
return ImageSource::None;
};
match backend {
ClipboardBackend::Wayland | ClipboardBackend::X11 => {
let types = match backend {
ClipboardBackend::Wayland => {
output_with_timeout(Command::new("wl-paste").arg("--list-types"), PROBE_TIMEOUT)
},
_ => output_with_timeout(
Command::new("xclip").args(["-selection", "clipboard", "-t", "TARGETS", "-o"]),
PROBE_TIMEOUT,
),
};
let Ok(types) = types else {
return ImageSource::None;
};
let types = String::from_utf8_lossy(&types.stdout);
if LINUX_IMAGE_MIMES
.iter()
.any(|(mime, _)| types.contains(mime))
{
return ImageSource::Inline;
}
if !types.contains("text/uri-list") {
return ImageSource::None;
}
let uris = match backend {
ClipboardBackend::Wayland => output_with_timeout(
Command::new("wl-paste").args(["--type", "text/uri-list"]),
PROBE_TIMEOUT,
),
_ => output_with_timeout(
Command::new("xclip").args([
"-selection",
"clipboard",
"-t",
"text/uri-list",
"-o",
]),
PROBE_TIMEOUT,
),
};
uris.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.and_then(|list| first_pasteable_file(list.lines()))
.map_or(ImageSource::None, ImageSource::File)
},
ClipboardBackend::MacOS => {
let Ok(info) = output_with_timeout(
Command::new("osascript").args(["-e", "clipboard info"]),
PROBE_TIMEOUT,
) else {
return ImageSource::None;
};
let info = String::from_utf8_lossy(&info.stdout);
if info.contains("PNGf") || info.contains("JPEG") || info.contains("TIFF") {
return ImageSource::Inline;
}
if !info.contains("furl") {
return ImageSource::None;
}
output_with_timeout(
Command::new("osascript")
.args(["-e", "POSIX path of (the clipboard as «class furl»)"]),
PROBE_TIMEOUT,
)
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.and_then(|paths| first_pasteable_file(paths.lines()))
.map_or(ImageSource::None, ImageSource::File)
},
ClipboardBackend::Windows => {
let Ok(out) = output_with_timeout(
&mut powershell_command(WINDOWS_PROBE_SCRIPT),
POWERSHELL_TIMEOUT,
) else {
return ImageSource::None;
};
let text = String::from_utf8_lossy(&out.stdout);
if text.lines().any(|l| l.trim() == "image") {
return ImageSource::Inline;
}
first_pasteable_file(text.lines().filter_map(|l| l.trim().strip_prefix("file:")))
.map_or(ImageSource::None, ImageSource::File)
},
}
}
const LINUX_IMAGE_MIMES: &[(&str, &str)] = &[
("image/png", "png"),
("image/jpeg", "jpeg"),
("image/webp", "webp"),
("image/gif", "gif"),
];
fn detect_backend() -> Option<ClipboardBackend> {
if cfg!(target_os = "macos") && tool_exists("pbpaste") {
return Some(ClipboardBackend::MacOS);
}
if cfg!(target_os = "windows") {
return Some(ClipboardBackend::Windows);
}
if std::env::var("WAYLAND_DISPLAY").is_ok() && tool_exists("wl-paste") {
return Some(ClipboardBackend::Wayland);
}
if std::env::var("DISPLAY").is_ok() && tool_exists("xclip") {
return Some(ClipboardBackend::X11);
}
None
}
pub fn has_image() -> bool {
probe_image_source() != ImageSource::None
}
pub fn read_image_bytes() -> Result<(Vec<u8>, String)> {
let backend = detect_backend()
.context("No clipboard backend detected (need xclip, wl-paste, pbpaste, or PowerShell)")?;
if let ImageSource::File(path) = probe_image_source() {
return read_image_file(&path);
}
match backend {
ClipboardBackend::Wayland | ClipboardBackend::X11 => {
for (mime, format) in LINUX_IMAGE_MIMES {
let output = match backend {
ClipboardBackend::Wayland => output_with_timeout(
Command::new("wl-paste").args(["--type", mime]),
DATA_TIMEOUT,
),
ClipboardBackend::X11 => output_with_timeout(
Command::new("xclip").args(["-selection", "clipboard", "-t", mime, "-o"]),
DATA_TIMEOUT,
),
_ => unreachable!(),
};
if let Ok(output) = output
&& output.status.success()
&& !output.stdout.is_empty()
{
return Ok((output.stdout, (*format).to_string()));
}
}
anyhow::bail!("No image data found in clipboard")
},
ClipboardBackend::MacOS => {
let temp_path = crate::utils::private_temp_dir()?.join("mermaid-clipboard-paste.png");
let temp_str = temp_path.to_string_lossy();
let script = format!(
"set theFile to POSIX file \"{}\"\n\
tell application \"System Events\" to set theData to the clipboard as «class PNGf»\n\
set fp to open for access theFile with write permission\n\
write theData to fp\n\
close access fp",
temp_str
);
let pngpaste_output =
output_with_timeout(Command::new("pngpaste").arg(&temp_path), DATA_TIMEOUT);
let success = if let Ok(output) = pngpaste_output
&& output.status.success()
{
true
} else {
output_with_timeout(
Command::new("osascript").args(["-e", &script]),
DATA_TIMEOUT,
)
.map(|o| o.status.success())
.unwrap_or(false)
};
if success {
let bytes = std::fs::read(&temp_path)
.context("Failed to read clipboard image from temp file")?;
let _ = std::fs::remove_file(&temp_path);
if !bytes.is_empty() {
return Ok((bytes, "png".to_string()));
}
}
anyhow::bail!("No image data found in clipboard (macOS)")
},
ClipboardBackend::Windows => {
let temp_path = crate::utils::private_temp_dir()?.join("mermaid-clipboard-paste.png");
let _ = std::fs::remove_file(&temp_path);
let script = format!(
"\
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$out = {out}
$d = [System.Windows.Forms.Clipboard]::GetDataObject()
if ($null -ne $d -and $d.GetDataPresent('PNG')) {{
$s = $d.GetData('PNG')
if ($s -is [System.IO.Stream]) {{
$s.Position = 0
$fs = [System.IO.File]::Create($out)
try {{ $s.CopyTo($fs) }} finally {{ $fs.Dispose() }}
exit 0
}}
}}
if ([System.Windows.Forms.Clipboard]::ContainsImage()) {{
$img = [System.Windows.Forms.Clipboard]::GetImage()
if ($null -ne $img) {{
$img.Save($out, [System.Drawing.Imaging.ImageFormat]::Png)
exit 0
}}
}}
exit 1",
out = ps_quote(&temp_path.to_string_lossy()),
);
let output = output_with_timeout(&mut powershell_command(&script), POWERSHELL_TIMEOUT);
if let Ok(output) = output
&& output.status.success()
&& temp_path.exists()
{
let bytes = std::fs::read(&temp_path)
.context("Failed to read clipboard image from temp file")?;
let _ = std::fs::remove_file(&temp_path);
if !bytes.is_empty() {
return Ok((bytes, "png".to_string()));
}
}
anyhow::bail!("No image data found in clipboard (Windows)")
},
}
}
pub fn read_text() -> Result<String> {
let backend = detect_backend()
.context("No clipboard backend detected (need xclip, wl-paste, pbpaste, or PowerShell)")?;
let output = match backend {
ClipboardBackend::Wayland => output_with_timeout(
Command::new("wl-paste").args(["--type", "text/plain"]),
DATA_TIMEOUT,
),
ClipboardBackend::X11 => output_with_timeout(
Command::new("xclip").args(["-selection", "clipboard", "-o"]),
DATA_TIMEOUT,
),
ClipboardBackend::MacOS => output_with_timeout(&mut Command::new("pbpaste"), DATA_TIMEOUT),
ClipboardBackend::Windows => output_with_timeout(
&mut powershell_command("Get-Clipboard -Raw"),
POWERSHELL_TIMEOUT,
),
};
let output = output.context("Failed to execute clipboard command")?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
anyhow::bail!("Clipboard does not contain text")
}
}
pub fn write_text(text: &str) -> Result<()> {
let backend =
detect_backend().context("No clipboard backend detected (need xclip/wl-copy/pbcopy)")?;
let (mut cmd, timeout) = match backend {
ClipboardBackend::Wayland => (Command::new("wl-copy"), DATA_TIMEOUT),
ClipboardBackend::X11 => {
let mut cmd = Command::new("xclip");
cmd.args(["-selection", "clipboard"]);
(cmd, DATA_TIMEOUT)
},
ClipboardBackend::MacOS => (Command::new("pbcopy"), DATA_TIMEOUT),
ClipboardBackend::Windows => (
powershell_command(
"[Console]::InputEncoding=[System.Text.Encoding]::UTF8; \
Set-Clipboard -Value ([Console]::In.ReadToEnd())",
),
POWERSHELL_TIMEOUT,
),
};
let status = write_stdin_with_timeout(&mut cmd, text.as_bytes().to_vec(), timeout)
.context("clipboard write command failed to run")?;
if status.success() {
Ok(())
} else {
anyhow::bail!("clipboard write command exited with {status}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_backend() {
let _ = detect_backend();
}
#[test]
fn test_has_image_no_crash() {
let _ = has_image();
}
#[test]
fn image_format_maps_extensions_case_insensitively() {
for (name, want) in [
("shot.png", Some("png")),
("shot.PNG", Some("png")),
("photo.jpg", Some("jpeg")),
("photo.JPEG", Some("jpeg")),
("scan.tif", Some("tiff")),
("anim.gif", Some("gif")),
("logo.webp", Some("webp")),
("old.bmp", Some("bmp")),
("notes.txt", None),
("archive.tar.gz", None),
("noextension", None),
] {
assert_eq!(
image_format_for_path(Path::new(name)),
want,
"extension mapping for {name}"
);
}
}
#[test]
fn file_uris_decode_to_paths_on_both_path_shapes() {
assert_eq!(
path_from_file_uri("file:///home/noah/My%20Shot.png"),
Some(PathBuf::from("/home/noah/My Shot.png"))
);
assert_eq!(
path_from_file_uri("file:///C:/Users/noah/My%20Shot.png"),
Some(PathBuf::from("C:/Users/noah/My Shot.png"))
);
assert_eq!(
path_from_file_uri("file:///tmp/caf%C3%A9.png"),
Some(PathBuf::from("/tmp/café.png"))
);
assert_eq!(path_from_file_uri("https://example.com/x.png"), None);
assert_eq!(path_from_file_uri("file:///tmp/bad%ZZ.png"), None);
}
#[test]
fn first_pasteable_file_skips_comments_non_images_and_missing_files() {
let dir = std::env::temp_dir().join(format!("mermaid-clip-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create test dir");
let png = dir.join("real.png");
std::fs::write(&png, b"\x89PNG").expect("write test png");
let txt = dir.join("notes.txt");
std::fs::write(&txt, b"hi").expect("write test txt");
let missing = dir.join("gone.png");
let png_uri = format!("file:///{}", png.to_string_lossy().replace('\\', "/"));
let found = first_pasteable_file(vec![
"# uri-list comment",
"",
txt.to_str().expect("txt path"),
missing.to_str().expect("missing path"),
png_uri.as_str(),
]);
assert_eq!(found, Some(png.clone()));
assert_eq!(
first_pasteable_file(vec![txt.to_str().expect("txt path")]),
None
);
let (bytes, format) = read_image_file(&png).expect("read the png");
assert_eq!(bytes, b"\x89PNG");
assert_eq!(format, "png");
assert!(read_image_file(&txt).is_err(), "a .txt is not attachable");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ps_quote_escapes_embedded_quotes() {
assert_eq!(ps_quote(r"C:\Users\noah\a.png"), r"'C:\Users\noah\a.png'");
assert_eq!(
ps_quote(r"C:\Users\O'Brien\a.png"),
r"'C:\Users\O''Brien\a.png'"
);
}
#[test]
fn has_image_agrees_with_the_probe() {
assert_eq!(has_image(), probe_image_source() != ImageSource::None);
}
#[test]
#[ignore = "needs a real display server + clipboard tools"]
fn manual_clipboard_roundtrip() {
if detect_backend().is_none() {
eprintln!("no clipboard backend detected; nothing to exercise");
return;
}
let previous = read_text().ok();
let probe = "mermaid clipboard self-test";
write_text(probe).expect("write_text");
std::thread::sleep(Duration::from_millis(200));
let read_back = read_text().expect("read_text");
if let Some(prev) = previous {
let _ = write_text(&prev);
}
assert_eq!(read_back.trim_end(), probe);
}
#[cfg(unix)]
#[test]
#[ignore = "needs Wayland + wl-copy; simulates a frozen selection owner"]
fn manual_hung_owner_times_out() {
if std::env::var("WAYLAND_DISPLAY").is_err() || !tool_exists("wl-copy") {
eprintln!("no Wayland session; nothing to exercise");
return;
}
let previous = read_text().ok();
let mut owner = Command::new("wl-copy")
.args(["--foreground", "hung-owner-data"])
.spawn()
.expect("spawn wl-copy");
std::thread::sleep(Duration::from_millis(300));
let stop = Command::new("kill")
.args(["-STOP", &owner.id().to_string()])
.status()
.expect("SIGSTOP owner");
assert!(stop.success());
let start = std::time::Instant::now();
let result = read_text();
let elapsed = start.elapsed();
let _ = Command::new("kill")
.args(["-CONT", &owner.id().to_string()])
.status();
let _ = owner.kill();
let _ = owner.wait();
if let Some(prev) = previous {
let _ = write_text(&prev);
}
eprintln!("read_text against frozen owner: {result:?} after {elapsed:?}");
assert!(
result.is_err(),
"a frozen selection owner must surface as an error"
);
assert!(
elapsed < Duration::from_secs(15),
"the deadline must bound the stall (took {elapsed:?})"
);
}
}