Skip to main content

mermaid_cli/
clipboard.rs

1//! Clipboard access for image and text paste
2//!
3//! Auto-detects the platform and display server, then uses the appropriate
4//! system tool to read clipboard contents:
5//! - Linux/Wayland: wl-paste
6//! - Linux/X11: xclip
7//! - macOS: pbpaste / osascript (for images)
8//! - Windows: PowerShell Get-Clipboard
9//!
10//! Every one of those tools can hang: the X11/Wayland clipboard is served *by
11//! the application that owns the selection*, so a frozen owner — or a stale
12//! `$DISPLAY`/`$WAYLAND_DISPLAY` pointing at a dead server — blocks a read
13//! forever, and PowerShell can wedge on a broken CLR. Nothing here calls
14//! `Command::output()`/`wait()` directly; every subprocess runs under a
15//! kill-on-timeout deadline so a wedged helper costs a bounded stall plus a
16//! visible error, not a paste that silently never lands and a permanently
17//! leaked blocking thread.
18
19use anyhow::{Context, Result};
20use std::process::Command;
21use std::time::Duration;
22
23use crate::utils::{output_with_timeout, write_stdin_with_timeout};
24
25/// `which` existence probes and clipboard *metadata* queries (offered MIME
26/// types, `osascript` clipboard info) — tiny payloads, so a slow answer means
27/// the display server or selection owner is wedged, not that data is big.
28const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
29
30/// Actual clipboard payload transfer (text or image bytes) — generous enough
31/// for a multi-megabyte screenshot from a healthy owner, short enough that a
32/// hung one can't wedge the paste path.
33const DATA_TIMEOUT: Duration = Duration::from_secs(5);
34
35/// PowerShell invocations pay CLR/JIT startup (seconds when cold) before any
36/// clipboard work happens, so Windows gets a fatter budget.
37const POWERSHELL_TIMEOUT: Duration = Duration::from_secs(10);
38
39/// Display server / platform type
40#[derive(Debug, Clone, Copy)]
41enum ClipboardBackend {
42    Wayland,
43    X11,
44    MacOS,
45    Windows,
46}
47
48/// True if `name` resolves on PATH. Even this probe is deadline-bounded: a
49/// PATH entry on dead NFS can wedge `which` itself.
50fn 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
56/// Detect the active clipboard backend
57fn detect_backend() -> Option<ClipboardBackend> {
58    // macOS
59    if cfg!(target_os = "macos") && tool_exists("pbpaste") {
60        return Some(ClipboardBackend::MacOS);
61    }
62
63    // Windows
64    if cfg!(target_os = "windows") {
65        return Some(ClipboardBackend::Windows);
66    }
67
68    // Linux: check Wayland first
69    if std::env::var("WAYLAND_DISPLAY").is_ok() && tool_exists("wl-paste") {
70        return Some(ClipboardBackend::Wayland);
71    }
72
73    // Linux: fall back to X11
74    if std::env::var("DISPLAY").is_ok() && tool_exists("xclip") {
75        return Some(ClipboardBackend::X11);
76    }
77
78    None
79}
80
81/// Check if the clipboard contains image data
82pub 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            // Check clipboard type via AppleScript
103            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            // PowerShell: check if clipboard contains an image.
115            // `Add-Type` is required on PowerShell 7 (Core) and locked-down
116            // environments where System.Windows.Forms isn't auto-loaded.
117            // Matches the pattern used in read_image_bytes below.
118            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
137/// Read image bytes from the clipboard.
138/// Returns (bytes, format) where format is "png" or "jpeg".
139pub 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            // Try PNG first, then JPEG
146            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            // Use osascript to save clipboard image to a temp file, then read it
170            // 0700 per-user scratch dir, not a world-readable shared /tmp path
171            // another local user could read or pre-create/symlink (#11).
172            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            // Try the simpler pngpaste approach first (if available), fall back to osascript
183            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                // Fall back to osascript
191                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            // Use PowerShell to save clipboard image to temp file
211            // 0700 per-user scratch dir, not a world-readable shared /tmp path
212            // another local user could read or pre-create/symlink (#11).
213            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
242/// Read text from the clipboard (fallback when no image is found).
243pub 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
271/// Write `text` to the system clipboard. Mirrors `read_text`'s backend
272/// detection and shells out to the platform tool (no extra dependency):
273/// `wl-copy` / `xclip` / `pbcopy` / PowerShell `Set-Clipboard`. Used by the
274/// in-app drag-select copy path.
275pub 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        // Read all of stdin as UTF-8 and set the clipboard, so non-ASCII
288        // survives (plain `clip.exe` reinterprets via the console codepage).
289        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    // `wl-copy` and `xclip` fork a background process that keeps *serving*
302    // the selection after the parent exits; the helper points stdout/stderr
303    // at null so that long-lived fork can't pin any pipe of ours.
304    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        // Just verify it doesn't panic — actual result depends on environment
320        let _ = detect_backend();
321    }
322
323    #[test]
324    fn test_has_image_no_crash() {
325        // Should return false gracefully if no display server
326        let _ = has_image();
327    }
328
329    /// Manual QA for a real display server (CI has none): round-trips a
330    /// string through the system clipboard, then restores the previous text
331    /// contents. Run with:
332    /// `cargo test manual_clipboard_roundtrip -- --ignored --nocapture`
333    #[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        // Selection serving is asynchronous on Wayland/X11 — give the
344        // background fork a beat to take ownership.
345        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        // Tools may append a trailing newline (wl-paste does by default).
351        assert_eq!(read_back.trim_end(), probe);
352    }
353
354    /// Manual QA for the failure mode this module guards against: a frozen
355    /// selection owner (SIGSTOP'd `wl-copy --foreground`) must surface as a
356    /// bounded timeout error, not a read that never returns. Wayland-only;
357    /// briefly replaces the clipboard, restoring text contents afterwards.
358    /// Run with:
359    /// `cargo test manual_hung_owner_times_out -- --ignored --nocapture`
360    #[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        // A foreground wl-copy serves the selection itself; SIGSTOP freezes
371        // it mid-service so any paste request blocks forever.
372        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        // Unfreeze and clean up the owner before asserting, so a failure
388        // doesn't leave a stopped process owning the user's clipboard.
389        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}