/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'node:fs/promises'; import { createWriteStream, existsSync, statSync } from 'node:fs'; import { execSync, spawn } from 'node:child_process'; import * as path from 'node:path'; import { debugLogger, spawnAsync, unescapePath, escapePath, Storage, } from '@google/gemini-cli-core'; /** * Supported image file extensions based on Gemini API. * See: https://ai.google.dev/gemini-api/docs/image-understanding */ export const IMAGE_EXTENSIONS = [ '.png', '.jpg', '.jpeg', '.webp', '.heic', '.heif', ]; /** Matches strings that start with a path prefix (/, ~, ., Windows drive letter, or UNC path) */ const PATH_PREFIX_PATTERN = /^([/~.]|[a-zA-Z]:|\\\\)/; // Track which tool works on Linux to avoid redundant checks/failures let linuxClipboardTool: 'wl-paste' | 'xclip' | null = null; // Helper to check the user's display server and whether they have a compatible clipboard tool installed function getUserLinuxClipboardTool(): typeof linuxClipboardTool { if (linuxClipboardTool !== null) { return linuxClipboardTool; } let toolName: 'wl-paste' | 'xclip' | null = null; const displayServer = process.env['XDG_SESSION_TYPE']; if (displayServer === 'wayland') toolName = 'wl-paste'; else if (displayServer === 'x11') toolName = 'xclip'; else return null; try { // output is piped to stdio: 'ignore' to suppress the path printing to console execSync(`command -v ${toolName}`, { stdio: 'ignore' }); linuxClipboardTool = toolName; return toolName; } catch (e) { debugLogger.warn(`${toolName} not found. Please install it: ${e}`); return null; } } /** * Helper to save command stdout to a file while preventing shell injections and race conditions */ async function saveFromCommand( command: string, args: string[], destination: string, ): Promise { return new Promise((resolve) => { const child = spawn(command, args); const fileStream = createWriteStream(destination); let resolved = false; const safeResolve = (value: boolean) => { if (!resolved) { resolved = true; resolve(value); } }; child.stdout.pipe(fileStream); child.on('error', (err) => { debugLogger.debug(`Failed to spawn ${command}:`, err); safeResolve(false); }); fileStream.on('error', (err) => { debugLogger.debug(`File stream error for ${destination}:`, err); safeResolve(false); }); child.on('close', async (code) => { if (resolved) return; if (code !== 0) { debugLogger.debug( `${command} exited with code ${code}. Args: ${args.join(' ')}`, ); safeResolve(false); return; } // Helper to check file size const checkFile = async () => { try { const stats = await fs.stat(destination); safeResolve(stats.size > 0); } catch (e) { debugLogger.debug(`Failed to stat output file ${destination}:`, e); safeResolve(false); } }; if (fileStream.writableFinished) { await checkFile(); } else { fileStream.on('finish', checkFile); // In case finish never fires due to error (though error handler should catch it) fileStream.on('close', async () => { if (!resolved) await checkFile(); }); } }); }); } /** * Checks if the Wayland clipboard contains an image using wl-paste. */ async function checkWlPasteForImage() { try { const { stdout } = await spawnAsync('wl-paste', ['--list-types']); return stdout.includes('image/'); } catch (e) { debugLogger.warn('Error checking wl-clipboard for image:', e); } return false; } /** * Checks if the X11 clipboard contains an image using xclip. */ async function checkXclipForImage() { try { const { stdout } = await spawnAsync('xclip', [ '-selection', 'clipboard', '-t', 'TARGETS', '-o', ]); return stdout.includes('image/'); } catch (e) { debugLogger.warn('Error checking xclip for image:', e); } return false; } /** * Checks if the system clipboard contains an image (macOS, Windows, and Linux) * @returns true if clipboard contains an image */ export async function clipboardHasImage(): Promise { if (process.platform === 'linux') { const tool = getUserLinuxClipboardTool(); if (tool === 'wl-paste') { if (await checkWlPasteForImage()) return true; } else if (tool === 'xclip') { if (await checkXclipForImage()) return true; } return false; } if (process.platform === 'win32') { try { const { stdout } = await spawnAsync('powershell', [ '-NoProfile', '-Command', 'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()', ]); return stdout.trim() === 'True'; } catch (error) { debugLogger.warn('Error checking clipboard for image:', error); return false; } } if (process.platform !== 'darwin') { return false; } try { // Use osascript to check clipboard type const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']); const imageRegex = /«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/; return imageRegex.test(stdout); } catch (error) { debugLogger.warn('Error checking clipboard for image:', error); return false; } } /** * Saves clipboard content to a file using wl-paste (Wayland). */ async function saveFileWithWlPaste(tempFilePath: string) { const success = await saveFromCommand( 'wl-paste', ['--no-newline', '--type', 'image/png'], tempFilePath, ); if (success) { return true; } // Cleanup on failure try { await fs.unlink(tempFilePath); } catch { /* ignore */ } return false; } /** * Saves clipboard content to a file using xclip (X11). */ const saveFileWithXclip = async (tempFilePath: string) => { const success = await saveFromCommand( 'xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o'], tempFilePath, ); if (success) { return true; } // Cleanup on failure try { await fs.unlink(tempFilePath); } catch { /* ignore */ } return false; }; /** * Gets the directory where clipboard images should be stored for a specific project. * * This uses the global temporary directory but creates a project-specific subdirectory * based on the hash of the project path (via `Storage.getProjectTempDir()`). * This prevents path conflicts between different projects while keeping the images * outside of the user's project directory. * * @param targetDir The root directory of the current project. * @returns The absolute path to the images directory. */ async function getProjectClipboardImagesDir( targetDir: string, ): Promise { const storage = new Storage(targetDir); await storage.initialize(); const baseDir = storage.getProjectTempDir(); return path.join(baseDir, 'images'); } /** * Saves the image from clipboard to a temporary file (macOS, Windows, and Linux) * @param targetDir The target directory to create temp files within * @returns The path to the saved image file, or null if no image or error */ export async function saveClipboardImage( targetDir: string, ): Promise { try { const tempDir = await getProjectClipboardImagesDir(targetDir); await fs.mkdir(tempDir, { recursive: true }); // Generate a unique filename with timestamp const timestamp = new Date().getTime(); if (process.platform === 'linux') { const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); const tool = getUserLinuxClipboardTool(); if (tool === 'wl-paste') { if (await saveFileWithWlPaste(tempFilePath)) return tempFilePath; return null; } if (tool === 'xclip') { if (await saveFileWithXclip(tempFilePath)) return tempFilePath; return null; } return null; } if (process.platform === 'win32') { const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); // The path is used directly in the PowerShell script. const psPath = tempFilePath.replace(/'/g, "''"); const script = ` Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing if ([System.Windows.Forms.Clipboard]::ContainsImage()) { $image = [System.Windows.Forms.Clipboard]::GetImage() $image.Save('${psPath}', [System.Drawing.Imaging.ImageFormat]::Png) Write-Output "success" } `; const { stdout } = await spawnAsync('powershell', [ '-NoProfile', '-Command', script, ]); if (stdout.trim() === 'success') { try { const stats = await fs.stat(tempFilePath); if (stats.size > 0) { return tempFilePath; } } catch { // File doesn't exist } } return null; } // AppleScript clipboard classes to try, in order of preference. // macOS converts clipboard images to these formats (WEBP/HEIC/HEIF not supported by osascript). const formats = [ { class: 'PNGf', extension: 'png' }, { class: 'JPEG', extension: 'jpg' }, ]; for (const format of formats) { const tempFilePath = path.join( tempDir, `clipboard-${timestamp}.${format.extension}`, ); // Try to save clipboard as this format const script = ` try set imageData to the clipboard as «class ${format.class}» set fileRef to open for access POSIX file "${tempFilePath}" with write permission write imageData to fileRef close access fileRef return "success" on error errMsg try close access POSIX file "${tempFilePath}" end try return "error" end try `; const { stdout } = await spawnAsync('osascript', ['-e', script]); if (stdout.trim() === 'success') { // Verify the file was created and has content try { const stats = await fs.stat(tempFilePath); if (stats.size > 0) { return tempFilePath; } } catch (e) { // File doesn't exist, continue to next format debugLogger.debug('Clipboard image file not found:', tempFilePath, e); } } // Clean up failed attempt try { await fs.unlink(tempFilePath); } catch (e) { // Ignore cleanup errors debugLogger.debug('Failed to clean up temp file:', tempFilePath, e); } } // No format worked return null; } catch (error) { debugLogger.warn('Error saving clipboard image:', error); return null; } } /** * Cleans up old temporary clipboard image files * Removes files older than 1 hour * @param targetDir The target directory where temp files are stored */ export async function cleanupOldClipboardImages( targetDir: string, ): Promise { try { const tempDir = await getProjectClipboardImagesDir(targetDir); const files = await fs.readdir(tempDir); const oneHourAgo = Date.now() - 60 * 60 * 1000; for (const file of files) { const ext = path.extname(file).toLowerCase(); if (file.startsWith('clipboard-') && IMAGE_EXTENSIONS.includes(ext)) { const filePath = path.join(tempDir, file); const stats = await fs.stat(filePath); if (stats.mtimeMs < oneHourAgo) { await fs.unlink(filePath); } } } } catch (e) { // Ignore errors in cleanup debugLogger.debug('Failed to clean up old clipboard images:', e); } } /** * Splits text into individual path segments, respecting escaped spaces. * Unescaped spaces act as separators between paths, while "\ " is preserved * as part of a filename. * * Example: "/img1.png /path/my\ image.png" → ["/img1.png", "/path/my\ image.png"] * * @param text The text to split * @returns Array of path segments (still escaped) */ export function splitEscapedPaths(text: string): string[] { const paths: string[] = []; let current = ''; let i = 0; while (i < text.length) { const char = text[i]; if (char === '\\' && i + 1 < text.length && text[i + 1] === ' ') { // Escaped space - part of filename, preserve the escape sequence current += '\\ '; i += 2; } else if (char === ' ') { // Unescaped space - path separator if (current.trim()) { paths.push(current.trim()); } current = ''; i++; } else { current += char; i++; } } // Don't forget the last segment if (current.trim()) { paths.push(current.trim()); } return paths; } /** * Helper to validate if a path exists and is a file. */ function isValidFilePath(p: string): boolean { try { return existsSync(p) && statSync(p).isFile(); } catch { return false; } } /** * Processes pasted text containing file paths, adding @ prefix to valid paths. * Handles both single and multiple space-separated paths. * * @param text The pasted text (potentially space-separated paths) * @returns Processed string with @ prefixes on valid paths, or null if no valid paths */ export function parsePastedPaths(text: string): string | null { // First, check if the entire text is a single valid path if (PATH_PREFIX_PATTERN.test(text) && isValidFilePath(text)) { return `@${escapePath(text)} `; } // Otherwise, try splitting on unescaped spaces const segments = splitEscapedPaths(text); if (segments.length === 0) { return null; } let anyValidPath = false; const processedPaths = segments.map((segment) => { // Quick rejection: skip segments that can't be paths if (!PATH_PREFIX_PATTERN.test(segment)) { return segment; } const unescaped = unescapePath(segment); if (isValidFilePath(unescaped)) { anyValidPath = true; return `@${segment}`; } return segment; }); return anyValidPath ? processedPaths.join(' ') + ' ' : null; }