Codex Handbook
tui/src/clipboard_paste.rs 568 lines
use std::path::Path;use std::path::PathBuf;use tempfile::Builder;#[derive(Debug, Clone)]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<u8>, PastedImageInfo), PasteImageError> {    let _span = tracing::debug_span!("paste_image_as_png").entered();    tracing::debug!("attempting clipboard image read");    let mut cb = arboard::Clipboard::new()        .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?;    // Sometimes images on the clipboard come as files (e.g. when copy/pasting from    // Finder), sometimes they come as image data (e.g. when pasting from Chrome).    // Accept both, and prefer files if both are present.    let files = cb        .get()        .file_list()        .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()));    let dyn_img = if let Some(img) = files        .unwrap_or_default()        .into_iter()        .find_map(|f| image::open(f).ok())    {        tracing::debug!(            "clipboard image opened from file: {}x{}",            img.width(),            img.height()        );        img    } else {        let _span = tracing::debug_span!("get_image").entered();        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;        tracing::debug!("clipboard image opened from image: {}x{}", w, h);        let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else {            return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into()));        };        image::DynamicImage::ImageRgba8(rgba_img)    };    let mut png: Vec<u8> = Vec::new();    {        let span =            tracing::debug_span!("encode_image", byte_length = tracing::field::Empty).entered();        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()))?;        span.record("byte_length", png.len());    }    Ok((        png,        PastedImageInfo {            width: dyn_img.width(),            height: dyn_img.height(),            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<u8>, 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> {    // First attempt: read image from system clipboard via arboard (native paths or image data).    match paste_image_as_png() {        Ok((png, info)) => {            // 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))        }        Err(e) => {            #[cfg(target_os = "linux")]            {                try_wsl_clipboard_fallback(&e).or(Err(e))            }            #[cfg(not(target_os = "linux"))]            {                Err(e)            }        }    }}/// Attempt WSL fallback for clipboard image paste.////// If clipboard is unavailable (common under WSL because arboard cannot access/// the Windows clipboard), attempt a WSL fallback that calls PowerShell on the/// Windows side to write the clipboard image to a temporary file, then return/// the corresponding WSL path.#[cfg(target_os = "linux")]fn try_wsl_clipboard_fallback(    error: &PasteImageError,) -> Result<(PathBuf, PastedImageInfo), PasteImageError> {    use PasteImageError::ClipboardUnavailable;    use PasteImageError::NoImage;    if !is_probably_wsl() || !matches!(error, ClipboardUnavailable(_) | NoImage(_)) {        return Err(error.clone());    }    tracing::debug!("attempting Windows PowerShell clipboard fallback");    let Some(win_path) = try_dump_windows_clipboard_image() else {        return Err(error.clone());    };    tracing::debug!("powershell produced path: {}", win_path);    let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else {        return Err(error.clone());    };    let Ok((w, h)) = image::image_dimensions(&mapped_path) else {        return Err(error.clone());    };    // Return the mapped path directly without copying.    // The file will be read and base64-encoded during serialization.    Ok((        mapped_path,        PastedImageInfo {            width: w,            height: h,            encoded_format: EncodedImageFormat::Png,        },    ))}/// Try to call a Windows PowerShell command (several common names) to save the/// clipboard image to a temporary PNG and return the Windows path to that file./// Returns None if no command succeeded or no image was present.#[cfg(target_os = "linux")]fn try_dump_windows_clipboard_image() -> Option<String> {    // Powershell script: save image from clipboard to a temp png and print the path.    // Force UTF-8 output to avoid encoding issues between powershell.exe (UTF-16LE default)    // and pwsh (UTF-8 default).    let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $img = Get-Clipboard -Format Image; if ($img -ne $null) { $p=[System.IO.Path]::GetTempFileName(); $p = [System.IO.Path]::ChangeExtension($p,'png'); $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); Write-Output $p } else { exit 1 }"#;    for cmd in ["powershell.exe", "pwsh", "powershell"] {        match std::process::Command::new(cmd)            .args(["-NoProfile", "-Command", script])            .output()        {            // Executing PowerShell command            Ok(output) => {                if output.status.success() {                    // Decode as UTF-8 (forced by the script above).                    let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string();                    if !win_path.is_empty() {                        tracing::debug!("{} saved clipboard image to {}", cmd, win_path);                        return Some(win_path);                    }                } else {                    tracing::debug!("{} returned non-zero status", cmd);                }            }            Err(err) => {                tracing::debug!("{} not executable: {}", cmd, err);            }        }    }    None}#[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 for a single-line search query.pub(crate) fn normalize_pasted_search_query(pasted: &str) -> Option<String> {    let normalized = pasted.split_whitespace().collect::<Vec<_>>().join(" ");    (!normalized.is_empty()).then_some(normalized)}/// 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<PathBuf> {    let pasted = pasted.trim();    let unquoted = pasted        .strip_prefix('"')        .and_then(|s| s.strip_suffix('"'))        .or_else(|| pasted.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))        .unwrap_or(pasted);    // file:// URL → filesystem path    if let Ok(url) = url::Url::parse(unquoted)        && 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).    if let Some(path) = normalize_windows_path(unquoted) {        return Some(path);    }    // shell-escaped single path → unescaped    let parts: Vec<String> = shlex::Shlex::new(pasted).collect();    if parts.len() == 1 {        let part = parts.into_iter().next()?;        if let Some(path) = normalize_windows_path(&part) {            return Some(path);        }        return Some(PathBuf::from(part));    }    None}#[cfg(target_os = "linux")]pub(crate) fn is_probably_wsl() -> bool {    // Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL).    if let Ok(version) = std::fs::read_to_string("/proc/version") {        let version_lower = version.to_lowercase();        if version_lower.contains("microsoft") || version_lower.contains("wsl") {            return true;        }    }    // Fallback: Check WSL environment variables. This handles edge cases like    // custom Linux kernels installed in WSL where /proc/version may not contain    // "microsoft" or "WSL".    std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some()}#[cfg(target_os = "linux")]fn convert_windows_path_to_wsl(input: &str) -> Option<PathBuf> {    if input.starts_with("\\\\") {        return None;    }    let drive_letter = input.chars().next()?.to_ascii_lowercase();    if !drive_letter.is_ascii_lowercase() {        return None;    }    if input.get(1..2) != Some(":") {        return None;    }    let mut result = PathBuf::from(format!("/mnt/{drive_letter}"));    for component in input        .get(2..)?        .trim_start_matches(['\\', '/'])        .split(['\\', '/'])        .filter(|component| !component.is_empty())    {        result.push(component);    }    Some(result)}fn normalize_windows_path(input: &str) -> Option<PathBuf> {    // Drive letter path: C:\ or C:/    let drive = input        .chars()        .next()        .map(|c| c.is_ascii_alphabetic())        .unwrap_or(false)        && input.get(1..2) == Some(":")        && input            .get(2..3)            .map(|s| s == "\\" || s == "/")            .unwrap_or(false);    // UNC path: \\server\share    let unc = input.starts_with("\\\\");    if !drive && !unc {        return None;    }    #[cfg(target_os = "linux")]    {        if is_probably_wsl()            && let Some(converted) = convert_windows_path_to_wsl(input)        {            return Some(converted);        }    }    Some(PathBuf::from(input))}/// 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(str::to_ascii_lowercase)        .as_deref()    {        Some("png") => EncodedImageFormat::Png,        Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg,        _ => EncodedImageFormat::Other,    }}#[cfg(test)]mod pasted_search_query_tests {    use super::*;    #[test]    fn collapses_whitespace() {        assert_eq!(            normalize_pasted_search_query("  alpha\n\tbeta\r\n gamma  "),            Some(String::from("alpha beta gamma"))        );    }}#[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");        #[cfg(target_os = "linux")]        let expected = if is_probably_wsl()            && let Some(converted) = convert_windows_path_to_wsl(input)        {            converted        } else {            PathBuf::from(r"C:\Temp\example.png")        };        #[cfg(not(target_os = "linux"))]        let expected = PathBuf::from(r"C:\Temp\example.png");        assert_eq!(result, expected);    }    #[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 unquoted = r"C:\\Users\\Alice\\My File.jpeg";        let result =            normalize_pasted_path(input).expect("should trim single quotes on windows path");        #[cfg(target_os = "linux")]        let expected = if is_probably_wsl()            && let Some(converted) = convert_windows_path_to_wsl(unquoted)        {            converted        } else {            PathBuf::from(unquoted)        };        #[cfg(not(target_os = "linux"))]        let expected = PathBuf::from(unquoted);        assert_eq!(result, expected);    }    #[test]    fn normalize_double_quoted_windows_path() {        let input = r#""C:\\Users\\Alice\\My File.jpeg""#;        let unquoted = r"C:\\Users\\Alice\\My File.jpeg";        let result =            normalize_pasted_path(input).expect("should trim double quotes on windows path");        #[cfg(target_os = "linux")]        let expected = if is_probably_wsl()            && let Some(converted) = convert_windows_path_to_wsl(unquoted)        {            converted        } else {            PathBuf::from(unquoted)        };        #[cfg(not(target_os = "linux"))]        let expected = PathBuf::from(unquoted);        assert_eq!(result, expected);    }    #[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");        #[cfg(target_os = "linux")]        let expected = if is_probably_wsl()            && let Some(converted) = convert_windows_path_to_wsl(input)        {            converted        } else {            PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png")        };        #[cfg(not(target_os = "linux"))]        let expected = PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png");        assert_eq!(result, expected);    }    #[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        );    }    #[cfg(target_os = "linux")]    #[test]    fn normalize_windows_path_in_wsl() {        // This test only runs on actual WSL systems        if !is_probably_wsl() {            // Skip test if not on WSL            return;        }        let input = r"C:\\Users\\Alice\\Pictures\\example image.png";        let result = normalize_pasted_path(input).expect("should convert windows path on wsl");        assert_eq!(            result,            PathBuf::from("/mnt/c/Users/Alice/Pictures/example image.png")        );    }}