use anyhow::{Context, Result};
use std::process::Command;
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,
}
fn tool_exists(name: &str) -> bool {
output_with_timeout(Command::new("which").arg(name), PROBE_TIMEOUT)
.map(|o| o.status.success())
.unwrap_or(false)
}
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 {
match detect_backend() {
Some(ClipboardBackend::Wayland) => {
output_with_timeout(Command::new("wl-paste").arg("--list-types"), PROBE_TIMEOUT)
.map(|o| {
let types = String::from_utf8_lossy(&o.stdout);
types.contains("image/png") || types.contains("image/jpeg")
})
.unwrap_or(false)
},
Some(ClipboardBackend::X11) => output_with_timeout(
Command::new("xclip").args(["-selection", "clipboard", "-t", "TARGETS", "-o"]),
PROBE_TIMEOUT,
)
.map(|o| {
let types = String::from_utf8_lossy(&o.stdout);
types.contains("image/png") || types.contains("image/jpeg")
})
.unwrap_or(false),
Some(ClipboardBackend::MacOS) => {
output_with_timeout(
Command::new("osascript").args(["-e", "clipboard info"]),
PROBE_TIMEOUT,
)
.map(|o| {
let info = String::from_utf8_lossy(&o.stdout);
info.contains("PNGf") || info.contains("JPEG") || info.contains("TIFF")
})
.unwrap_or(false)
},
Some(ClipboardBackend::Windows) => {
output_with_timeout(
Command::new("powershell").args([
"-NoProfile",
"-Command",
"Add-Type -AssemblyName System.Windows.Forms; \
[System.Windows.Forms.Clipboard]::ContainsImage()",
]),
POWERSHELL_TIMEOUT,
)
.map(|o| {
let out = String::from_utf8_lossy(&o.stdout);
out.trim() == "True"
})
.unwrap_or(false)
},
None => false,
}
}
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)")?;
match backend {
ClipboardBackend::Wayland | ClipboardBackend::X11 => {
for (mime, format) in [("image/png", "png"), ("image/jpeg", "jpeg")] {
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 temp_str = temp_path.to_string_lossy();
let script = format!(
"Add-Type -AssemblyName System.Windows.Forms; \
$img = [System.Windows.Forms.Clipboard]::GetImage(); \
if ($img) {{ $img.Save('{}', [System.Drawing.Imaging.ImageFormat]::Png) }}",
temp_str
);
let output = output_with_timeout(
Command::new("powershell").args(["-NoProfile", "-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(
Command::new("powershell").args(["-NoProfile", "-Command", "Get-Clipboard"]),
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 => {
let mut cmd = Command::new("powershell");
cmd.args([
"-NoProfile",
"-Command",
"[Console]::InputEncoding=[System.Text.Encoding]::UTF8; \
Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]);
(cmd, 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]
#[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:?})"
);
}
}