Added image pasting capabilities for Wayland and X11 on Linux (#17144)

This commit is contained in:
Dev Randalpura
2026-01-22 12:02:56 -05:00
committed by GitHub
parent 87a0db20d2
commit 7962801a14
3 changed files with 594 additions and 48 deletions

View File

@@ -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/);
},
);
});

View File

@@ -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<typeof import('node:child_process')>();
return {
...actual,
spawn: vi.fn(),
execSync: vi.fn(),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
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

View File

@@ -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<boolean> {
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<boolean> {
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<boolean> {
}
/**
* 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<string | null> {
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.