From 7962801a1489050cee3adabbf30162fb5f857e6d Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Thu, 22 Jan 2026 12:02:56 -0500 Subject: [PATCH] Added image pasting capabilities for Wayland and X11 on Linux (#17144) --- integration-tests/clipboard-linux.test.ts | 110 ++++++ .../cli/src/ui/utils/clipboardUtils.test.ts | 330 +++++++++++++++--- packages/cli/src/ui/utils/clipboardUtils.ts | 202 ++++++++++- 3 files changed, 594 insertions(+), 48 deletions(-) create mode 100644 integration-tests/clipboard-linux.test.ts diff --git a/integration-tests/clipboard-linux.test.ts b/integration-tests/clipboard-linux.test.ts new file mode 100644 index 0000000000..ed1b9a847f --- /dev/null +++ b/integration-tests/clipboard-linux.test.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { execSync, spawnSync } from 'node:child_process'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// Minimal 1x1 PNG image base64 +const DUMMY_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +describe('Linux Clipboard Integration', () => { + let rig: TestRig; + let dummyImagePath: string; + + beforeEach(() => { + rig = new TestRig(); + // Create a dummy image file for testing + dummyImagePath = path.join( + os.tmpdir(), + `gemini-test-clipboard-${Date.now()}.png`, + ); + fs.writeFileSync(dummyImagePath, Buffer.from(DUMMY_PNG_BASE64, 'base64')); + }); + + afterEach(async () => { + await rig.cleanup(); + try { + if (fs.existsSync(dummyImagePath)) { + fs.unlinkSync(dummyImagePath); + } + } catch { + // Ignore cleanup errors + } + }); + + // Only run this test on Linux + const runIfLinux = os.platform() === 'linux' ? it : it.skip; + + runIfLinux( + 'should paste image from system clipboard when Ctrl+V is pressed', + async () => { + // 1. Setup rig + await rig.setup('linux-clipboard-paste'); + + // 2. Inject image into system clipboard + // We attempt both Wayland and X11 tools. + let clipboardSet = false; + + // Try wl-copy (Wayland) + let sessionType = ''; + const wlCopy = spawnSync('wl-copy', ['--type', 'image/png'], { + input: fs.readFileSync(dummyImagePath), + }); + if (wlCopy.status === 0) { + clipboardSet = true; + sessionType = 'wayland'; + } else { + // Try xclip (X11) + try { + execSync( + `xclip -selection clipboard -t image/png -i "${dummyImagePath}"`, + { stdio: 'ignore' }, + ); + clipboardSet = true; + sessionType = 'x11'; + } catch { + // Both failed + } + } + + if (!clipboardSet) { + console.warn( + 'Skipping test: Could not access system clipboard (wl-copy or xclip required)', + ); + return; + } + + // 3. Launch CLI and simulate Ctrl+V + // We send the control character \u0016 (SYN) which corresponds to Ctrl+V + // Note: The CLI must be running and accepting input. + // The TestRig usually sends args/stdin and waits for exit or output. + // To properly test "interactive" pasting, we need the rig to support sending input *while* running. + // Assuming rig.run with 'stdin' sends it immediately. + // The CLI treats stdin as typed input if it's interactive. + + // We append a small delay or a newline to ensure processing? + // Ctrl+V (\u0016) followed by a newline (\r) to submit? + // Or just Ctrl+V and check if the buffer updates (which we can't easily see in non-verbose rig output). + // If we send Ctrl+V then Enter, the CLI should submit the prompt containing the image path. + + const result = await rig.run({ + stdin: '\u0016\r', // Ctrl+V then Enter + env: { XDG_SESSION_TYPE: sessionType }, + }); + + // 4. Verify Output + // Expect the CLI to have processed the image and echoed back the path (or the prompt containing it) + // The output usually contains the user's input echoed back + model response. + // The pasted image path should look like @.../clipboard-....png + expect(result).toMatch(/@\/.*\.gemini-clipboard\/clipboard-.*\.png/); + }, + ); +}); diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 101a5085f7..025deea516 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -4,65 +4,311 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; import { - clipboardHasImage, - saveClipboardImage, + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import * as fs from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +import { spawn, execSync } from 'node:child_process'; +import EventEmitter from 'node:events'; +import { Stream } from 'node:stream'; +import * as path from 'node:path'; + +// Mock dependencies BEFORE imports +vi.mock('node:fs/promises'); +vi.mock('node:fs', () => ({ + createWriteStream: vi.fn(), +})); +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(), + execSync: vi.fn(), + }; +}); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + spawnAsync: vi.fn(), + debugLogger: { + debug: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +import { spawnAsync } from '@google/gemini-cli-core'; +// Keep static imports for stateless functions +import { cleanupOldClipboardImages, splitEscapedPaths, parsePastedPaths, } from './clipboardUtils.js'; +// Define the type for the module to use in tests +type ClipboardUtilsModule = typeof import('./clipboardUtils.js'); + describe('clipboardUtils', () => { - describe('clipboardHasImage', () => { - it('should return false on unsupported platforms', async () => { - if (process.platform !== 'darwin' && process.platform !== 'win32') { - const result = await clipboardHasImage(); - expect(result).toBe(false); - } else { - // Skip on macOS/Windows as it would require actual clipboard state - expect(true).toBe(true); - } - }); + let originalPlatform: string; + let originalEnv: NodeJS.ProcessEnv; + // Dynamic module instance for stateful functions + let clipboardUtils: ClipboardUtilsModule; - it('should return boolean on macOS or Windows', async () => { - if (process.platform === 'darwin' || process.platform === 'win32') { - const result = await clipboardHasImage(); - expect(typeof result).toBe('boolean'); - } else { - // Skip on unsupported platforms - expect(true).toBe(true); - } - }, 10000); + beforeEach(async () => { + vi.resetAllMocks(); + originalPlatform = process.platform; + originalEnv = process.env; + process.env = { ...originalEnv }; + + // Reset modules to clear internal state (linuxClipboardTool variable) + vi.resetModules(); + // Dynamically import the module to get a fresh instance for each test + clipboardUtils = await import('./clipboardUtils.js'); }); - describe('saveClipboardImage', () => { - it('should return null on unsupported platforms', async () => { - if (process.platform !== 'darwin' && process.platform !== 'win32') { - const result = await saveClipboardImage(); - expect(result).toBe(null); - } else { - // Skip on macOS/Windows - expect(true).toBe(true); - } + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, }); + process.env = originalEnv; + vi.restoreAllMocks(); + }); - it('should handle errors gracefully', async () => { - // Test with invalid directory (should not throw) - const result = await saveClipboardImage( - '/invalid/path/that/does/not/exist', + const setPlatform = (platform: string) => { + Object.defineProperty(process, 'platform', { + value: platform, + }); + }; + + describe('clipboardHasImage (Linux)', () => { + it('should return true when wl-paste shows image type (Wayland)', async () => { + setPlatform('linux'); + process.env['XDG_SESSION_TYPE'] = 'wayland'; + (execSync as Mock).mockReturnValue(Buffer.from('')); // command -v succeeds + (spawnAsync as Mock).mockResolvedValueOnce({ + stdout: 'image/png\ntext/plain', + }); + + const result = await clipboardUtils.clipboardHasImage(); + + expect(result).toBe(true); + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('wl-paste'), + expect.anything(), ); + expect(spawnAsync).toHaveBeenCalledWith('wl-paste', ['--list-types']); + }); - if (process.platform === 'darwin' || process.platform === 'win32') { - // On macOS/Windows, might return null due to various errors - expect(result === null || typeof result === 'string').toBe(true); - } else { - // On other platforms, should always return null - expect(result).toBe(null); - } + it('should return true when xclip shows image type (X11)', async () => { + setPlatform('linux'); + process.env['XDG_SESSION_TYPE'] = 'x11'; + (execSync as Mock).mockReturnValue(Buffer.from('')); // command -v succeeds + (spawnAsync as Mock).mockResolvedValueOnce({ + stdout: 'image/png\nTARGETS', + }); + + const result = await clipboardUtils.clipboardHasImage(); + + expect(result).toBe(true); + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('xclip'), + expect.anything(), + ); + expect(spawnAsync).toHaveBeenCalledWith('xclip', [ + '-selection', + 'clipboard', + '-t', + 'TARGETS', + '-o', + ]); + }); + + it('should return false if tool fails', async () => { + setPlatform('linux'); + process.env['XDG_SESSION_TYPE'] = 'wayland'; + (execSync as Mock).mockReturnValue(Buffer.from('')); + (spawnAsync as Mock).mockRejectedValueOnce(new Error('wl-paste failed')); + + const result = await clipboardUtils.clipboardHasImage(); + + expect(result).toBe(false); + }); + + it('should return false if no image type is found', async () => { + setPlatform('linux'); + process.env['XDG_SESSION_TYPE'] = 'wayland'; + (execSync as Mock).mockReturnValue(Buffer.from('')); + (spawnAsync as Mock).mockResolvedValueOnce({ stdout: 'text/plain' }); + + const result = await clipboardUtils.clipboardHasImage(); + + expect(result).toBe(false); + }); + + it('should return false if tool not found', async () => { + setPlatform('linux'); + process.env['XDG_SESSION_TYPE'] = 'wayland'; + (execSync as Mock).mockImplementation(() => { + throw new Error('Command not found'); + }); + + const result = await clipboardUtils.clipboardHasImage(); + + expect(result).toBe(false); }); }); + describe('saveClipboardImage (Linux)', () => { + const mockTargetDir = '/tmp/target'; + const mockTempDir = path.join(mockTargetDir, '.gemini-clipboard'); + + beforeEach(() => { + setPlatform('linux'); + (fs.mkdir as Mock).mockResolvedValue(undefined); + (fs.unlink as Mock).mockResolvedValue(undefined); + }); + + const createMockChildProcess = ( + shouldSucceed: boolean, + exitCode: number = 0, + ) => { + const child = new EventEmitter() as EventEmitter & { + stdout: Stream & { pipe: Mock }; + }; + child.stdout = new Stream() as Stream & { pipe: Mock }; // Dummy stream + child.stdout.pipe = vi.fn(); + + // Simulate process execution + setTimeout(() => { + if (!shouldSucceed) { + child.emit('error', new Error('Spawn failed')); + } else { + child.emit('close', exitCode); + } + }, 10); + + return child; + }; + + // Helper to prime the internal linuxClipboardTool state + const primeClipboardTool = async ( + type: 'wayland' | 'x11', + hasImage = true, + ) => { + process.env['XDG_SESSION_TYPE'] = type; + (execSync as Mock).mockReturnValue(Buffer.from('')); + (spawnAsync as Mock).mockResolvedValueOnce({ + stdout: hasImage ? 'image/png' : 'text/plain', + }); + await clipboardUtils.clipboardHasImage(); + (spawnAsync as Mock).mockClear(); + (execSync as Mock).mockClear(); + }; + + it('should save image using wl-paste if detected', async () => { + await primeClipboardTool('wayland'); + + // Mock fs.stat to return size > 0 + (fs.stat as Mock).mockResolvedValue({ size: 100, mtimeMs: Date.now() }); + + // Mock spawn to return a successful process for wl-paste + const mockChild = createMockChildProcess(true, 0); + (spawn as Mock).mockReturnValueOnce(mockChild); + + // Mock createWriteStream + const mockStream = new EventEmitter() as EventEmitter & { + writableFinished: boolean; + }; + mockStream.writableFinished = false; + (createWriteStream as Mock).mockReturnValue(mockStream); + + // Use dynamic instance + const promise = clipboardUtils.saveClipboardImage(mockTargetDir); + + // Simulate stream finishing successfully BEFORE process closes + mockStream.writableFinished = true; + mockStream.emit('finish'); + + const result = await promise; + + expect(result).toMatch(/clipboard-\d+\.png$/); + expect(spawn).toHaveBeenCalledWith('wl-paste', expect.any(Array)); + expect(fs.mkdir).toHaveBeenCalledWith(mockTempDir, { recursive: true }); + }); + + it('should return null if wl-paste fails', async () => { + await primeClipboardTool('wayland'); + + // Mock fs.stat to return size > 0 + (fs.stat as Mock).mockResolvedValue({ size: 100, mtimeMs: Date.now() }); + + // wl-paste fails (non-zero exit code) + const child1 = createMockChildProcess(true, 1); + (spawn as Mock).mockReturnValueOnce(child1); + + const mockStream1 = new EventEmitter() as EventEmitter & { + writableFinished: boolean; + }; + (createWriteStream as Mock).mockReturnValueOnce(mockStream1); + + const promise = clipboardUtils.saveClipboardImage(mockTargetDir); + + mockStream1.writableFinished = true; + mockStream1.emit('finish'); + + const result = await promise; + + expect(result).toBe(null); + // Should NOT try xclip + expect(spawn).toHaveBeenCalledTimes(1); + }); + + it('should save image using xclip if detected', async () => { + await primeClipboardTool('x11'); + + // Mock fs.stat to return size > 0 + (fs.stat as Mock).mockResolvedValue({ size: 100, mtimeMs: Date.now() }); + + // Mock spawn to return a successful process for xclip + const mockChild = createMockChildProcess(true, 0); + (spawn as Mock).mockReturnValueOnce(mockChild); + + // Mock createWriteStream + const mockStream = new EventEmitter() as EventEmitter & { + writableFinished: boolean; + }; + mockStream.writableFinished = false; + (createWriteStream as Mock).mockReturnValue(mockStream); + + const promise = clipboardUtils.saveClipboardImage(mockTargetDir); + + mockStream.writableFinished = true; + mockStream.emit('finish'); + + const result = await promise; + + expect(result).toMatch(/clipboard-\d+\.png$/); + expect(spawn).toHaveBeenCalledWith('xclip', expect.any(Array)); + }); + + it('should return null if tool is not yet detected', async () => { + // Don't prime the tool + const result = await clipboardUtils.saveClipboardImage(mockTargetDir); + expect(result).toBe(null); + expect(spawn).not.toHaveBeenCalled(); + }); + }); + + // Stateless functions continue to use static imports describe('cleanupOldClipboardImages', () => { it('should not throw errors', async () => { // Should handle missing directories gracefully diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 9296bfce99..c9c34d3ccc 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -5,6 +5,8 @@ */ import * as fs from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +import { execSync, spawn } from 'node:child_process'; import * as path from 'node:path'; import { debugLogger, @@ -29,11 +31,147 @@ export const IMAGE_EXTENSIONS = [ /** 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; + } +} + /** - * Checks if the system clipboard contains an image (macOS and Windows) + * 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') { + linuxClipboardTool = getUserLinuxClipboardTool(); + if (linuxClipboardTool === 'wl-paste') { + if (await checkWlPasteForImage()) return true; + } else if (linuxClipboardTool === 'xclip') { + if (await checkXclipForImage()) return true; + } + return false; + } + if (process.platform === 'win32') { try { const { stdout } = await spawnAsync('powershell', [ @@ -65,17 +203,55 @@ export async function clipboardHasImage(): Promise { } /** - * Saves the image from clipboard to a temporary file (macOS and Windows) + * 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; +}; + +/** + * 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 { - if (process.platform !== 'darwin' && process.platform !== 'win32') { - return null; - } - try { // Create a temporary directory for clipboard images within the target directory // This avoids security restrictions on paths outside the target directory @@ -86,6 +262,20 @@ export async function saveClipboardImage( // Generate a unique filename with timestamp const timestamp = new Date().getTime(); + if (process.platform === 'linux') { + const tempFilePath = path.join(tempDir, `clipboard-${timestamp}.png`); + + if (linuxClipboardTool === 'wl-paste') { + if (await saveFileWithWlPaste(tempFilePath)) return tempFilePath; + return null; + } + if (linuxClipboardTool === '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.