Skip to main content

limit_cli/
clipboard_paste.rs

1//! Clipboard image paste operations with multi-platform support
2//!
3//! Provides image paste functionality supporting:
4//! - Native clipboard image data (arboard)
5//! - File list paste (e.g., Finder copy)
6//! - WSL environments (PowerShell fallback)
7
8use std::path::PathBuf;
9
10/// Error types for image paste operations
11#[derive(Debug, Clone)]
12pub enum PasteImageError {
13    ClipboardUnavailable(String),
14    NoImage(String),
15    EncodeFailed(String),
16    IoError(String),
17}
18
19/// Image format after encoding
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EncodedImageFormat {
22    Png,
23    Jpeg,
24    Other,
25}
26
27impl EncodedImageFormat {
28    /// Get label for display
29    pub fn label(&self) -> &'static str {
30        match self {
31            EncodedImageFormat::Png => "PNG",
32            EncodedImageFormat::Jpeg => "JPEG",
33            EncodedImageFormat::Other => "unknown",
34        }
35    }
36}
37
38/// Information about pasted image
39#[derive(Debug, Clone)]
40pub struct PastedImageInfo {
41    pub width: u32,
42    pub height: u32,
43    pub encoded_format: EncodedImageFormat,
44}
45
46impl std::fmt::Display for PasteImageError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            PasteImageError::ClipboardUnavailable(msg) => {
50                write!(f, "clipboard unavailable: {msg}")
51            }
52            PasteImageError::NoImage(msg) => {
53                write!(f, "no image on clipboard: {msg}")
54            }
55            PasteImageError::EncodeFailed(msg) => {
56                write!(f, "could not encode image: {msg}")
57            }
58            PasteImageError::IoError(msg) => {
59                write!(f, "io error: {msg}")
60            }
61        }
62    }
63}
64
65/// Paste image from clipboard as PNG bytes
66#[cfg(not(target_os = "android"))]
67pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
68    let _span = tracing::debug_span!("paste_image_as_png").entered();
69    tracing::debug!("attempting clipboard image read");
70
71    let mut cb = arboard::Clipboard::new()
72        .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?;
73
74    // Try image data first (most common: screenshots, browser copies)
75    // Then try file list (Finder copies, file managers)
76    let dyn_img = if let Ok(img) = cb.get_image() {
77        // Image as raw data (screenshots, browser, etc)
78        let w = img.width as u32;
79        let h = img.height as u32;
80        tracing::debug!("clipboard image from data: {}x{}", w, h);
81
82        let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {
83            return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));
84        };
85
86        image::DynamicImage::ImageRgba8(rgba_img)
87    } else if let Ok(files) = cb.get().file_list() {
88        // Image as file reference (Finder, file managers)
89        if let Some(img) = files.into_iter().find_map(|f| image::open(f).ok()) {
90            tracing::debug!(
91                "clipboard image from file: {}x{}",
92                img.width(),
93                img.height()
94            );
95            img
96        } else {
97            return Err(PasteImageError::NoImage(
98                "no valid image file in clipboard".into(),
99            ));
100        }
101    } else {
102        return Err(PasteImageError::NoImage(
103            "clipboard does not contain image data or image files".into(),
104        ));
105    };
106
107    // Codificar como PNG
108    let mut png: Vec<u8> = Vec::new();
109    let mut cursor = std::io::Cursor::new(&mut png);
110    dyn_img
111        .write_to(&mut cursor, image::ImageFormat::Png)
112        .map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
113
114    Ok((
115        png,
116        PastedImageInfo {
117            width: dyn_img.width(),
118            height: dyn_img.height(),
119            encoded_format: EncodedImageFormat::Png,
120        },
121    ))
122}
123
124/// Paste image from clipboard and save to temporary PNG file
125#[cfg(not(target_os = "android"))]
126pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
127    match paste_image_as_png() {
128        Ok((png, info)) => {
129            // Create a unique temporary file
130            let tmp = tempfile::Builder::new()
131                .prefix("clipboard-")
132                .suffix(".png")
133                .tempfile()
134                .map_err(|e| PasteImageError::IoError(e.to_string()))?;
135
136            std::fs::write(tmp.path(), &png)
137                .map_err(|e| PasteImageError::IoError(e.to_string()))?;
138
139            // Persist file (don't delete when handle is closed)
140            let (_file, path) = tmp
141                .keep()
142                .map_err(|e| PasteImageError::IoError(e.error.to_string()))?;
143
144            Ok((path, info))
145        }
146        Err(e) => {
147            // Tentar fallback WSL (Linux apenas)
148            #[cfg(target_os = "linux")]
149            {
150                try_wsl_clipboard_fallback(&e).ok_or(e)
151            }
152            #[cfg(not(target_os = "linux"))]
153            {
154                Err(e)
155            }
156        }
157    }
158}
159
160/// Fallback WSL for image paste
161#[cfg(target_os = "linux")]
162fn try_wsl_clipboard_fallback(error: &PasteImageError) -> Option<(PathBuf, PastedImageInfo)> {
163    use PasteImageError::{ClipboardUnavailable, NoImage};
164
165    if !super::clipboard_text::is_probably_wsl()
166        || !matches!(error, ClipboardUnavailable(_) | NoImage(_))
167    {
168        return None;
169    }
170
171    tracing::debug!("attempting Windows PowerShell clipboard fallback");
172
173    // Executar PowerShell para salvar imagem do clipboard
174    let Some(win_path) = try_dump_windows_clipboard_image() else {
175        return None;
176    };
177
178    tracing::debug!("powershell produced path: {}", win_path);
179
180    // Converter caminho Windows para WSL
181    let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else {
182        return None;
183    };
184
185    let Ok((w, h)) = image::image_dimensions(&mapped_path) else {
186        return None;
187    };
188
189    Some((
190        mapped_path,
191        PastedImageInfo {
192            width: w,
193            height: h,
194            encoded_format: EncodedImageFormat::Png,
195        },
196    ))
197}
198
199/// Dump Windows clipboard image via PowerShell
200#[cfg(target_os = "linux")]
201fn try_dump_windows_clipboard_image() -> Option<String> {
202    let script = r#"
203        [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; 
204        $img = Get-Clipboard -Format Image; 
205        if ($img -ne $null) { 
206            $p=[System.IO.Path]::GetTempFileName(); 
207            $p = [System.IO.Path]::ChangeExtension($p,'png'); 
208            $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); 
209            Write-Output $p 
210        } else { 
211            exit 1 
212        }
213    "#;
214
215    for cmd in ["powershell.exe", "pwsh", "powershell"] {
216        match std::process::Command::new(cmd)
217            .args(["-NoProfile", "-Command", script])
218            .output()
219        {
220            Ok(output) if output.status.success() => {
221                let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
222                if !win_path.is_empty() {
223                    return Some(win_path);
224                }
225            }
226            _ => continue,
227        }
228    }
229    None
230}
231
232/// Convert Windows path to WSL path
233#[cfg(target_os = "linux")]
234fn convert_windows_path_to_wsl(input: &str) -> Option<PathBuf> {
235    // Don't convert UNC paths
236    if input.starts_with("\\\\") {
237        return None;
238    }
239
240    let drive_letter = input.chars().next()?.to_ascii_lowercase();
241    if !drive_letter.is_ascii_lowercase() {
242        return None;
243    }
244
245    if input.get(1..2) != Some(":") {
246        return None;
247    }
248
249    // C:\Users\... -> /mnt/c/Users/...
250    let mut result = PathBuf::from(format!("/mnt/{drive_letter}"));
251    for component in input
252        .get(2..)?
253        .trim_start_matches(['\\', '/'])
254        .split(['\\', '/'])
255        .filter(|c| !c.is_empty())
256    {
257        result.push(component);
258    }
259
260    Some(result)
261}
262
263#[cfg(all(test, not(target_os = "android")))]
264mod tests {
265    #[allow(unused_imports)]
266    use super::*;
267
268    #[test]
269    fn test_normalize_file_url() {
270        let input = "file:///tmp/example.png";
271        // Note: normalize_pasted_path is not yet implemented in this module
272        // This test is a placeholder for future implementation
273        assert!(input.starts_with("file://"));
274    }
275}