diff options
| author | Jayson Dasher <[email protected]> | 2025-07-12 00:06:49 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-12 04:06:49 +0000 |
| commit | c9e194ec6ae514cd35f1bdd8cebd6cc556a74208 (patch) | |
| tree | cb2414f7050ef972ba0549a888376d099e3d6bc8 /packages/cli/src/ui/utils | |
| parent | c4ea17692f4fac8df304993d2271704e79344968 (diff) | |
feat: Add clipboard image paste support for macOS (#1580)
Co-authored-by: Jacob Richman <[email protected]>
Co-authored-by: Scott Densmore <[email protected]>
Diffstat (limited to 'packages/cli/src/ui/utils')
| -rw-r--r-- | packages/cli/src/ui/utils/clipboardUtils.test.ts | 76 | ||||
| -rw-r--r-- | packages/cli/src/ui/utils/clipboardUtils.ts | 149 |
2 files changed, 225 insertions, 0 deletions
diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts new file mode 100644 index 00000000..30258889 --- /dev/null +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts new file mode 100644 index 00000000..74554495 --- /dev/null +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -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 + } +} |
