use std::path::Path; use std::path::PathBuf; use tempfile::Builder; #[derive(Debug)] pub enum PasteImageError { ClipboardUnavailable(String), NoImage(String), EncodeFailed(String), IoError(String), } impl std::fmt::Display for PasteImageError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"), PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"), PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"), PasteImageError::IoError(msg) => write!(f, "io error: {msg}"), } } } impl std::error::Error for PasteImageError {} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EncodedImageFormat { Png, Jpeg, Other, } impl EncodedImageFormat { pub fn label(self) -> &'static str { match self { EncodedImageFormat::Png => "PNG", EncodedImageFormat::Jpeg => "JPEG", EncodedImageFormat::Other => "IMG", } } } #[derive(Debug, Clone)] pub struct PastedImageInfo { pub width: u32, pub height: u32, pub encoded_format: EncodedImageFormat, // Always PNG for now. } /// Capture image from system clipboard, encode to PNG, and return bytes + info. #[cfg(not(target_os = "android"))] pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { tracing::debug!("attempting clipboard image read"); let mut cb = arboard::Clipboard::new() .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?; let img = cb .get_image() .map_err(|e| PasteImageError::NoImage(e.to_string()))?; let w = img.width as u32; let h = img.height as u32; let mut png: Vec = Vec::new(); let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else { return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into())); }; let dyn_img = image::DynamicImage::ImageRgba8(rgba_img); tracing::debug!("clipboard image decoded RGBA {w}x{h}"); { let mut cursor = std::io::Cursor::new(&mut png); dyn_img .write_to(&mut cursor, image::ImageFormat::Png) .map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?; } tracing::debug!("clipboard image encoded to PNG ({}) bytes", png.len()); Ok(( png, PastedImageInfo { width: w, height: h, encoded_format: EncodedImageFormat::Png, }, )) } /// Android/Termux does not support arboard; return a clear error. #[cfg(target_os = "android")] pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { Err(PasteImageError::ClipboardUnavailable( "clipboard image paste is unsupported on Android".into(), )) } /// Convenience: write to a temp file and return its path + info. #[cfg(not(target_os = "android"))] pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { let (png, info) = paste_image_as_png()?; // Create a unique temporary file with a .png suffix to avoid collisions. let tmp = Builder::new() .prefix("codex-clipboard-") .suffix(".png") .tempfile() .map_err(|e| PasteImageError::IoError(e.to_string()))?; std::fs::write(tmp.path(), &png).map_err(|e| PasteImageError::IoError(e.to_string()))?; // Persist the file (so it remains after the handle is dropped) and return its PathBuf. let (_file, path) = tmp .keep() .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; Ok((path, info)) } #[cfg(target_os = "android")] pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { // Keep error consistent with paste_image_as_png. Err(PasteImageError::ClipboardUnavailable( "clipboard image paste is unsupported on Android".into(), )) } /// Normalize pasted text that may represent a filesystem path. /// /// Supports: /// - `file://` URLs (converted to local paths) /// - Windows/UNC paths /// - shell-escaped single paths (via `shlex`) pub fn normalize_pasted_path(pasted: &str) -> Option { let pasted = pasted.trim(); // file:// URL → filesystem path if let Ok(url) = url::Url::parse(pasted) && url.scheme() == "file" { return url.to_file_path().ok(); } // TODO: We'll improve the implementation/unit tests over time, as appropriate. // Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e // // Detect unquoted Windows paths and bypass POSIX shlex which // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). // Also handles UNC paths (\\server\share\path). let looks_like_windows_path = { // Drive letter path: C:\ or C:/ let drive = pasted .chars() .next() .map(|c| c.is_ascii_alphabetic()) .unwrap_or(false) && pasted.get(1..2) == Some(":") && pasted .get(2..3) .map(|s| s == "\\" || s == "/") .unwrap_or(false); // UNC path: \\server\share let unc = pasted.starts_with("\\\\"); drive || unc }; if looks_like_windows_path { return Some(PathBuf::from(pasted)); } // shell-escaped single path → unescaped let parts: Vec = shlex::Shlex::new(pasted).collect(); if parts.len() == 1 { return parts.into_iter().next().map(PathBuf::from); } None } /// Infer an image format for the provided path based on its extension. pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { match path .extension() .and_then(|e| e.to_str()) .map(|s| s.to_ascii_lowercase()) .as_deref() { Some("png") => EncodedImageFormat::Png, Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg, _ => EncodedImageFormat::Other, } } #[cfg(test)] mod pasted_paths_tests { use super::*; #[cfg(not(windows))] #[test] fn normalize_file_url() { let input = "file:///tmp/example.png"; let result = normalize_pasted_path(input).expect("should parse file URL"); assert_eq!(result, PathBuf::from("/tmp/example.png")); } #[test] fn normalize_file_url_windows() { let input = r"C:\Temp\example.png"; let result = normalize_pasted_path(input).expect("should parse file URL"); assert_eq!(result, PathBuf::from(r"C:\Temp\example.png")); } #[test] fn normalize_shell_escaped_single_path() { let input = "/home/user/My\\ File.png"; let result = normalize_pasted_path(input).expect("should unescape shell-escaped path"); assert_eq!(result, PathBuf::from("/home/user/My File.png")); } #[test] fn normalize_simple_quoted_path_fallback() { let input = "\"/home/user/My File.png\""; let result = normalize_pasted_path(input).expect("should trim simple quotes"); assert_eq!(result, PathBuf::from("/home/user/My File.png")); } #[test] fn normalize_single_quoted_unix_path() { let input = "'/home/user/My File.png'"; let result = normalize_pasted_path(input).expect("should trim single quotes via shlex"); assert_eq!(result, PathBuf::from("/home/user/My File.png")); } #[test] fn normalize_multiple_tokens_returns_none() { // Two tokens after shell splitting → not a single path let input = "/home/user/a\\ b.png /home/user/c.png"; let result = normalize_pasted_path(input); assert!(result.is_none()); } #[test] fn pasted_image_format_png_jpeg_unknown() { assert_eq!( pasted_image_format(Path::new("/a/b/c.PNG")), EncodedImageFormat::Png ); assert_eq!( pasted_image_format(Path::new("/a/b/c.jpg")), EncodedImageFormat::Jpeg ); assert_eq!( pasted_image_format(Path::new("/a/b/c.JPEG")), EncodedImageFormat::Jpeg ); assert_eq!( pasted_image_format(Path::new("/a/b/c")), EncodedImageFormat::Other ); assert_eq!( pasted_image_format(Path::new("/a/b/c.webp")), EncodedImageFormat::Other ); } #[test] fn normalize_single_quoted_windows_path() { let input = r"'C:\\Users\\Alice\\My File.jpeg'"; let result = normalize_pasted_path(input).expect("should trim single quotes on windows path"); assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg")); } #[test] fn normalize_unquoted_windows_path_with_spaces() { let input = r"C:\\Users\\Alice\\My Pictures\\example image.png"; let result = normalize_pasted_path(input).expect("should accept unquoted windows path"); assert_eq!( result, PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png") ); } #[test] fn normalize_unc_windows_path() { let input = r"\\\\server\\share\\folder\\file.jpg"; let result = normalize_pasted_path(input).expect("should accept UNC windows path"); assert_eq!( result, PathBuf::from(r"\\\\server\\share\\folder\\file.jpg") ); } #[test] fn pasted_image_format_with_windows_style_paths() { assert_eq!( pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")), EncodedImageFormat::Png ); assert_eq!( pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")), EncodedImageFormat::Jpeg ); assert_eq!( pasted_image_format(Path::new(r"C:\\a\\b\\noext")), EncodedImageFormat::Other ); } }