/** * @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, 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 a pasted text block up into escaped path segements if it's a legal * drag-and-drop string. * * There are multiple ways drag-and-drop paths might be escaped: * - Bare (only if there are no special chars): /path/to/myfile.png * - Wrapped in double quotes (Windows only): "/path/to/my file~!.png" * - Escaped with backslashes (POSIX only): /path/to/my\ file~!.png * - Wrapped in single quotes: '/path/to/my file~!.png' * * When wrapped in single quotes, actual single quotes in the filename are * escaped with "'\''". For example: '/path/to/my '\''fancy file'\''.png' * * When wrapped in double quotes, actual double quotes are not an issue becuase * windows doesn't allow them in filenames. * * On all systems, a single drag-and-drop may include both wrapped and bare * paths, so we need to handle both simultaneously. * * @param text * @returns An iterable of escaped paths */ export function* splitDragAndDropPaths(text: string): Generator { let current = ''; let mode: 'NORMAL' | 'DOUBLE' | 'SINGLE' = 'NORMAL'; const isWindows = process.platform === 'win32'; let i = 0; while (i < text.length) { const char = text[i]; if (mode === 'NORMAL') { if (char === ' ') { if (current.length > 0) { yield current; current = ''; } } else if (char === '"') { mode = 'DOUBLE'; } else if (char === "'") { mode = 'SINGLE'; } else if (char === '\\' && !isWindows) { // POSIX escape in normal mode if (i + 1 < text.length) { const next = text[i + 1]; current += next; i++; } } else { current += char; } } else if (mode === 'DOUBLE') { if (char === '"') { mode = 'NORMAL'; } else { current += char; } } else if (mode === 'SINGLE') { if (char === "'") { mode = 'NORMAL'; } else { current += char; } } i++; } if (current.length > 0) { yield current; } } /** * Helper to validate if a path exists and is a file. */ function isValidFilePath(p: string): boolean { try { return PATH_PREFIX_PATTERN.test(p) && existsSync(p) && statSync(p).isFile(); } catch { return false; } } /** * Processes pasted text containing file paths (like those from drag and drop), * adding @ prefix to valid paths and escaping them in a standard way. * * @param text The pasted text * @returns Processed string with @ prefixes or null if any paths are invalid */ export function parsePastedPaths(text: string): string | null { // First, check if the entire text is a single valid path if (isValidFilePath(text)) { return `@${escapePath(text)} `; } const validPaths = []; for (const segment of splitDragAndDropPaths(text)) { if (isValidFilePath(segment)) { validPaths.push(`@${escapePath(segment)}`); } else { return null; // If any segment is invalid, return null for the whole string } } if (validPaths.length === 0) { return null; } return validPaths.join(' ') + ' '; }