1use anyhow::{Context, Result};
20use std::process::Command;
21use std::time::Duration;
22
23use crate::utils::{output_with_timeout, write_stdin_with_timeout};
24
25const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
29
30const DATA_TIMEOUT: Duration = Duration::from_secs(5);
34
35const POWERSHELL_TIMEOUT: Duration = Duration::from_secs(10);
38
39#[derive(Debug, Clone, Copy)]
41enum ClipboardBackend {
42 Wayland,
43 X11,
44 MacOS,
45 Windows,
46}
47
48fn tool_exists(name: &str) -> bool {
51 output_with_timeout(Command::new("which").arg(name), PROBE_TIMEOUT)
52 .map(|o| o.status.success())
53 .unwrap_or(false)
54}
55
56fn detect_backend() -> Option<ClipboardBackend> {
58 if cfg!(target_os = "macos") && tool_exists("pbpaste") {
60 return Some(ClipboardBackend::MacOS);
61 }
62
63 if cfg!(target_os = "windows") {
65 return Some(ClipboardBackend::Windows);
66 }
67
68 if std::env::var("WAYLAND_DISPLAY").is_ok() && tool_exists("wl-paste") {
70 return Some(ClipboardBackend::Wayland);
71 }
72
73 if std::env::var("DISPLAY").is_ok() && tool_exists("xclip") {
75 return Some(ClipboardBackend::X11);
76 }
77
78 None
79}
80
81pub fn has_image() -> bool {
83 match detect_backend() {
84 Some(ClipboardBackend::Wayland) => {
85 output_with_timeout(Command::new("wl-paste").arg("--list-types"), PROBE_TIMEOUT)
86 .map(|o| {
87 let types = String::from_utf8_lossy(&o.stdout);
88 types.contains("image/png") || types.contains("image/jpeg")
89 })
90 .unwrap_or(false)
91 },
92 Some(ClipboardBackend::X11) => output_with_timeout(
93 Command::new("xclip").args(["-selection", "clipboard", "-t", "TARGETS", "-o"]),
94 PROBE_TIMEOUT,
95 )
96 .map(|o| {
97 let types = String::from_utf8_lossy(&o.stdout);
98 types.contains("image/png") || types.contains("image/jpeg")
99 })
100 .unwrap_or(false),
101 Some(ClipboardBackend::MacOS) => {
102 output_with_timeout(
104 Command::new("osascript").args(["-e", "clipboard info"]),
105 PROBE_TIMEOUT,
106 )
107 .map(|o| {
108 let info = String::from_utf8_lossy(&o.stdout);
109 info.contains("PNGf") || info.contains("JPEG") || info.contains("TIFF")
110 })
111 .unwrap_or(false)
112 },
113 Some(ClipboardBackend::Windows) => {
114 output_with_timeout(
119 Command::new("powershell").args([
120 "-NoProfile",
121 "-Command",
122 "Add-Type -AssemblyName System.Windows.Forms; \
123 [System.Windows.Forms.Clipboard]::ContainsImage()",
124 ]),
125 POWERSHELL_TIMEOUT,
126 )
127 .map(|o| {
128 let out = String::from_utf8_lossy(&o.stdout);
129 out.trim() == "True"
130 })
131 .unwrap_or(false)
132 },
133 None => false,
134 }
135}
136
137pub fn read_image_bytes() -> Result<(Vec<u8>, String)> {
140 let backend = detect_backend()
141 .context("No clipboard backend detected (need xclip, wl-paste, pbpaste, or PowerShell)")?;
142
143 match backend {
144 ClipboardBackend::Wayland | ClipboardBackend::X11 => {
145 for (mime, format) in [("image/png", "png"), ("image/jpeg", "jpeg")] {
147 let output = match backend {
148 ClipboardBackend::Wayland => output_with_timeout(
149 Command::new("wl-paste").args(["--type", mime]),
150 DATA_TIMEOUT,
151 ),
152 ClipboardBackend::X11 => output_with_timeout(
153 Command::new("xclip").args(["-selection", "clipboard", "-t", mime, "-o"]),
154 DATA_TIMEOUT,
155 ),
156 _ => unreachable!(),
157 };
158
159 if let Ok(output) = output
160 && output.status.success()
161 && !output.stdout.is_empty()
162 {
163 return Ok((output.stdout, format.to_string()));
164 }
165 }
166 anyhow::bail!("No image data found in clipboard")
167 },
168 ClipboardBackend::MacOS => {
169 let temp_path = crate::utils::private_temp_dir()?.join("mermaid-clipboard-paste.png");
173 let temp_str = temp_path.to_string_lossy();
174 let script = format!(
175 "set theFile to POSIX file \"{}\"\n\
176 tell application \"System Events\" to set theData to the clipboard as «class PNGf»\n\
177 set fp to open for access theFile with write permission\n\
178 write theData to fp\n\
179 close access fp",
180 temp_str
181 );
182 let pngpaste_output =
184 output_with_timeout(Command::new("pngpaste").arg(&temp_path), DATA_TIMEOUT);
185 let success = if let Ok(output) = pngpaste_output
186 && output.status.success()
187 {
188 true
189 } else {
190 output_with_timeout(
192 Command::new("osascript").args(["-e", &script]),
193 DATA_TIMEOUT,
194 )
195 .map(|o| o.status.success())
196 .unwrap_or(false)
197 };
198
199 if success {
200 let bytes = std::fs::read(&temp_path)
201 .context("Failed to read clipboard image from temp file")?;
202 let _ = std::fs::remove_file(&temp_path);
203 if !bytes.is_empty() {
204 return Ok((bytes, "png".to_string()));
205 }
206 }
207 anyhow::bail!("No image data found in clipboard (macOS)")
208 },
209 ClipboardBackend::Windows => {
210 let temp_path = crate::utils::private_temp_dir()?.join("mermaid-clipboard-paste.png");
214 let temp_str = temp_path.to_string_lossy();
215 let script = format!(
216 "Add-Type -AssemblyName System.Windows.Forms; \
217 $img = [System.Windows.Forms.Clipboard]::GetImage(); \
218 if ($img) {{ $img.Save('{}', [System.Drawing.Imaging.ImageFormat]::Png) }}",
219 temp_str
220 );
221 let output = output_with_timeout(
222 Command::new("powershell").args(["-NoProfile", "-Command", &script]),
223 POWERSHELL_TIMEOUT,
224 );
225
226 if let Ok(output) = output
227 && output.status.success()
228 && temp_path.exists()
229 {
230 let bytes = std::fs::read(&temp_path)
231 .context("Failed to read clipboard image from temp file")?;
232 let _ = std::fs::remove_file(&temp_path);
233 if !bytes.is_empty() {
234 return Ok((bytes, "png".to_string()));
235 }
236 }
237 anyhow::bail!("No image data found in clipboard (Windows)")
238 },
239 }
240}
241
242pub fn read_text() -> Result<String> {
244 let backend = detect_backend()
245 .context("No clipboard backend detected (need xclip, wl-paste, pbpaste, or PowerShell)")?;
246
247 let output = match backend {
248 ClipboardBackend::Wayland => output_with_timeout(
249 Command::new("wl-paste").args(["--type", "text/plain"]),
250 DATA_TIMEOUT,
251 ),
252 ClipboardBackend::X11 => output_with_timeout(
253 Command::new("xclip").args(["-selection", "clipboard", "-o"]),
254 DATA_TIMEOUT,
255 ),
256 ClipboardBackend::MacOS => output_with_timeout(&mut Command::new("pbpaste"), DATA_TIMEOUT),
257 ClipboardBackend::Windows => output_with_timeout(
258 Command::new("powershell").args(["-NoProfile", "-Command", "Get-Clipboard"]),
259 POWERSHELL_TIMEOUT,
260 ),
261 };
262
263 let output = output.context("Failed to execute clipboard command")?;
264 if output.status.success() {
265 Ok(String::from_utf8_lossy(&output.stdout).to_string())
266 } else {
267 anyhow::bail!("Clipboard does not contain text")
268 }
269}
270
271pub fn write_text(text: &str) -> Result<()> {
276 let backend =
277 detect_backend().context("No clipboard backend detected (need xclip/wl-copy/pbcopy)")?;
278
279 let (mut cmd, timeout) = match backend {
280 ClipboardBackend::Wayland => (Command::new("wl-copy"), DATA_TIMEOUT),
281 ClipboardBackend::X11 => {
282 let mut cmd = Command::new("xclip");
283 cmd.args(["-selection", "clipboard"]);
284 (cmd, DATA_TIMEOUT)
285 },
286 ClipboardBackend::MacOS => (Command::new("pbcopy"), DATA_TIMEOUT),
287 ClipboardBackend::Windows => {
290 let mut cmd = Command::new("powershell");
291 cmd.args([
292 "-NoProfile",
293 "-Command",
294 "[Console]::InputEncoding=[System.Text.Encoding]::UTF8; \
295 Set-Clipboard -Value ([Console]::In.ReadToEnd())",
296 ]);
297 (cmd, POWERSHELL_TIMEOUT)
298 },
299 };
300
301 let status = write_stdin_with_timeout(&mut cmd, text.as_bytes().to_vec(), timeout)
305 .context("clipboard write command failed to run")?;
306 if status.success() {
307 Ok(())
308 } else {
309 anyhow::bail!("clipboard write command exited with {status}")
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_detect_backend() {
319 let _ = detect_backend();
321 }
322
323 #[test]
324 fn test_has_image_no_crash() {
325 let _ = has_image();
327 }
328
329 #[test]
334 #[ignore = "needs a real display server + clipboard tools"]
335 fn manual_clipboard_roundtrip() {
336 if detect_backend().is_none() {
337 eprintln!("no clipboard backend detected; nothing to exercise");
338 return;
339 }
340 let previous = read_text().ok();
341 let probe = "mermaid clipboard self-test";
342 write_text(probe).expect("write_text");
343 std::thread::sleep(Duration::from_millis(200));
346 let read_back = read_text().expect("read_text");
347 if let Some(prev) = previous {
348 let _ = write_text(&prev);
349 }
350 assert_eq!(read_back.trim_end(), probe);
352 }
353
354 #[cfg(unix)]
361 #[test]
362 #[ignore = "needs Wayland + wl-copy; simulates a frozen selection owner"]
363 fn manual_hung_owner_times_out() {
364 if std::env::var("WAYLAND_DISPLAY").is_err() || !tool_exists("wl-copy") {
365 eprintln!("no Wayland session; nothing to exercise");
366 return;
367 }
368 let previous = read_text().ok();
369
370 let mut owner = Command::new("wl-copy")
373 .args(["--foreground", "hung-owner-data"])
374 .spawn()
375 .expect("spawn wl-copy");
376 std::thread::sleep(Duration::from_millis(300));
377 let stop = Command::new("kill")
378 .args(["-STOP", &owner.id().to_string()])
379 .status()
380 .expect("SIGSTOP owner");
381 assert!(stop.success());
382
383 let start = std::time::Instant::now();
384 let result = read_text();
385 let elapsed = start.elapsed();
386
387 let _ = Command::new("kill")
390 .args(["-CONT", &owner.id().to_string()])
391 .status();
392 let _ = owner.kill();
393 let _ = owner.wait();
394 if let Some(prev) = previous {
395 let _ = write_text(&prev);
396 }
397
398 eprintln!("read_text against frozen owner: {result:?} after {elapsed:?}");
399 assert!(
400 result.is_err(),
401 "a frozen selection owner must surface as an error"
402 );
403 assert!(
404 elapsed < Duration::from_secs(15),
405 "the deadline must bound the stall (took {elapsed:?})"
406 );
407 }
408}