limit_cli/
clipboard_paste.rs1use std::path::PathBuf;
9
10#[derive(Debug, Clone)]
12pub enum PasteImageError {
13 ClipboardUnavailable(String),
14 NoImage(String),
15 EncodeFailed(String),
16 IoError(String),
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EncodedImageFormat {
22 Png,
23 Jpeg,
24 Other,
25}
26
27impl EncodedImageFormat {
28 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#[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#[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 let dyn_img = if let Ok(img) = cb.get_image() {
77 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 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 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#[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 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 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 #[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#[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 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 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#[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#[cfg(target_os = "linux")]
234fn convert_windows_path_to_wsl(input: &str) -> Option<PathBuf> {
235 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 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 assert!(input.starts_with("file://"));
274 }
275}