mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
Added image pasting capabilities for Wayland and X11 on Linux (#17144)
This commit is contained in:
110
integration-tests/clipboard-linux.test.ts
Normal file
110
integration-tests/clipboard-linux.test.ts
Normal 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/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user