feat: Add clipboard image paste support for macOS (#1580)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Scott Densmore <scottdensmore@mac.com>
This commit is contained in:
Jayson Dasher
2025-07-12 00:06:49 -04:00
committed by GitHub
parent c4ea17692f
commit c9e194ec6a
5 changed files with 412 additions and 4 deletions

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
} from './clipboardUtils.js';
describe('clipboardUtils', () => {
describe('clipboardHasImage', () => {
it('should return false on non-macOS platforms', async () => {
if (process.platform !== 'darwin') {
const result = await clipboardHasImage();
expect(result).toBe(false);
} else {
// Skip on macOS as it would require actual clipboard state
expect(true).toBe(true);
}
});
it('should return boolean on macOS', async () => {
if (process.platform === 'darwin') {
const result = await clipboardHasImage();
expect(typeof result).toBe('boolean');
} else {
// Skip on non-macOS
expect(true).toBe(true);
}
});
});
describe('saveClipboardImage', () => {
it('should return null on non-macOS platforms', async () => {
if (process.platform !== 'darwin') {
const result = await saveClipboardImage();
expect(result).toBe(null);
} else {
// Skip on macOS
expect(true).toBe(true);
}
});
it('should handle errors gracefully', async () => {
// Test with invalid directory (should not throw)
const result = await saveClipboardImage(
'/invalid/path/that/does/not/exist',
);
if (process.platform === 'darwin') {
// On macOS, 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);
}
});
});
describe('cleanupOldClipboardImages', () => {
it('should not throw errors', async () => {
// Should handle missing directories gracefully
await expect(
cleanupOldClipboardImages('/path/that/does/not/exist'),
).resolves.not.toThrow();
});
it('should complete without errors on valid directory', async () => {
await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
const execAsync = promisify(exec);
/**
* Checks if the system clipboard contains an image (macOS only for now)
* @returns true if clipboard contains an image
*/
export async function clipboardHasImage(): Promise<boolean> {
if (process.platform !== 'darwin') {
return false;
}
try {
// Use osascript to check clipboard type
const { stdout } = await execAsync(
`osascript -e 'clipboard info' 2>/dev/null | grep -qE "«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»" && echo "true" || echo "false"`,
{ shell: '/bin/bash' },
);
return stdout.trim() === 'true';
} catch {
return false;
}
}
/**
* Saves the image from clipboard to a temporary file (macOS only for now)
* @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') {
return null;
}
try {
// Create a temporary directory for clipboard images within the target directory
// This avoids security restrictions on paths outside the target directory
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.gemini-clipboard');
await fs.mkdir(tempDir, { recursive: true });
// Generate a unique filename with timestamp
const timestamp = new Date().getTime();
// Try different image formats in order of preference
const formats = [
{ class: 'PNGf', extension: 'png' },
{ class: 'JPEG', extension: 'jpg' },
{ class: 'TIFF', extension: 'tiff' },
{ class: 'GIFf', extension: 'gif' },
];
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 execAsync(`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 {
// File doesn't exist, continue to next format
}
}
// Clean up failed attempt
try {
await fs.unlink(tempFilePath);
} catch {
// Ignore cleanup errors
}
}
// No format worked
return null;
} catch (error) {
console.error('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<void> {
try {
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.gemini-clipboard');
const files = await fs.readdir(tempDir);
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const file of files) {
if (
file.startsWith('clipboard-') &&
(file.endsWith('.png') ||
file.endsWith('.jpg') ||
file.endsWith('.tiff') ||
file.endsWith('.gif'))
) {
const filePath = path.join(tempDir, file);
const stats = await fs.stat(filePath);
if (stats.mtimeMs < oneHourAgo) {
await fs.unlink(filePath);
}
}
}
} catch {
// Ignore errors in cleanup
}
}