use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
use mermaid_model::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
}
#[must_use]
pub fn has_image() -> bool {
probe_image_source() != ImageSource::None
}
#[expect(
clippy::too_many_lines,
reason = "predates the lint; see .github/baselines/expect_budget.txt"
)]
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 =
mermaid_model::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 \"{temp_str}\"\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"
);
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 =
mermaid_model::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 = "copy an image FILE in your file manager first, then run this"]
fn manual_file_reference_paste() {
let Some(backend) = detect_backend() else {
eprintln!("no clipboard backend detected; nothing to exercise");
return;
};
eprintln!("backend: {backend:?}");
match probe_image_source() {
ImageSource::File(path) => {
eprintln!("resolved file reference: {}", path.display());
let (bytes, format) = read_image_file(&path).expect("read the referenced file");
eprintln!("read {} bytes as {format}", bytes.len());
assert!(!bytes.is_empty());
assert!(has_image(), "has_image must agree with the probe");
},
ImageSource::Inline => panic!(
"the clipboard holds inline image data, not a file reference — \
copy an image FILE in your file manager (not the image itself) \
and re-run"
),
ImageSource::None => panic!(
"no image source detected. If you did copy an image file, THIS is \
the bug: the platform probe did not recognize the file reference."
),
}
}
#[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:?})"
);
}
}