Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ Clipless is an Electron clipboard manager built with React and TypeScript. It mo
After making any code changes, always run the following before considering work complete:

1. **Lint and typecheck** — must produce zero errors and zero warnings:

```bash
npm run lint && npm run typecheck
```

2. **Unit tests with coverage** — must maintain 100% code coverage across statements, branches, functions, and lines:

```bash
npx vitest run --coverage
```
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ A powerful and intelligent clipboard manager built with Electron, React, and Typ

- Encrypted data storage using OS-native encryption
- Windows: DPAPI, macOS: Keychain, Linux: Secret Service
- Domain-specific storage files (settings, clips, templates) for efficient per-domain saves
- Images stored as separate encrypted files with generated thumbnails for fast rendering
- Non-blocking startup: window displays immediately, data loads in background
- Persistent clipboard history
- Lock clips to prevent automatic removal
- Export/import functionality for backup
Expand Down
115 changes: 77 additions & 38 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ This document describes the persistent storage system implemented for Clipless u

### Data Persistence

- **Clips Storage**: All clipboard items are automatically saved with their lock status
- **Settings Storage**: User preferences and application settings are persisted
- **Domain-Specific Files**: Data is split into separate encrypted files by domain for efficient per-domain saves
- **Settings Storage**: User preferences stored in `settings.enc`
- **Clips Storage**: Clipboard items stored in `clips.enc`
- **Templates Storage**: Templates, search terms, and quick tools stored in `templates.enc`
- **Metadata**: Unencrypted `meta.json` tracks storage version for migration
- **Automatic Sync**: Data is automatically loaded on startup and saved when changed

## Stored Data

### Clips
### Clips (`clips.enc`)

Each clip is stored with:

Expand All @@ -29,22 +32,57 @@ Each clip is stored with:
- **Lock Status**: Whether the clip is locked to prevent removal
- **Timestamp**: When the clip was captured

### Settings
### Settings (`settings.enc`)

User preferences including:

- **maxClips**: Maximum number of clips to store (default: 10)
- **startMinimized**: Start application minimized (default: false)
- **autoStart**: Start with system (default: false)
- **theme**: UI theme preference (light/dark/system)
- **hotkeys**: Global hotkey configuration

### Templates Data (`templates.enc`)

- **Templates**: Text generation templates with token placeholders
- **Search Terms**: Regex patterns for Quick Clips pattern scanning
- **Quick Tools**: URL templates for opening web resources with extracted data

### Metadata (`meta.json`)

- **version**: Application version that last wrote the data
- **storageVersion**: Storage format version for future migrations

### Window Bounds (`window-bounds.json`)

- Unencrypted JSON storing window position and size
- Read directly at startup without waiting for encrypted storage initialization

## Storage Location

Data is stored in the user's application data directory:

- **Windows**: `%APPDATA%\clipless\clipless-data\data.enc`
- **macOS**: `~/Library/Application Support/clipless/clipless-data/data.enc`
- **Linux**: `~/.config/clipless/clipless-data/data.enc`
- **Windows**: `%APPDATA%\clipless\clipless-data\`
- **macOS**: `~/Library/Application Support/clipless/clipless-data/`
- **Linux**: `~/.config/clipless/clipless-data/`

Files:

- `settings.enc` — encrypted user settings
- `clips.enc` — encrypted clipboard data
- `templates.enc` — encrypted templates, search terms, quick tools
- `meta.json` — unencrypted storage version metadata
- `window-bounds.json` — unencrypted window position/size
- `images/{id}.enc` — encrypted full-size clipboard images
- `images/{id}_thumb.enc` — encrypted 200px-wide image thumbnails

## Startup Flow

1. **Window bounds** are loaded directly from `window-bounds.json` (no encryption dependency)
2. **Window displays immediately** with default data
3. **Storage initializes in background**: migrates legacy data if needed, then loads domain files
4. **`storage-ready` IPC event** notifies the renderer to re-fetch real data
5. **Window settings** are re-applied (transparency, always-on-top)

## API Reference

Expand All @@ -62,7 +100,7 @@ Data is stored in the user's application data directory:

#### Data Management

- `storage-get-stats`: Get storage statistics
- `storage-get-stats`: Get storage statistics (sums all domain file sizes)
- `storage-export-data`: Export data as JSON
- `storage-import-data`: Import data from JSON
- `storage-clear-all`: Clear all stored data
Expand All @@ -72,6 +110,11 @@ Data is stored in the user's application data directory:
#### Available through `window.api`:

```typescript
// Listen for storage ready (background load complete)
window.api.onStorageReady(() => {
/* re-fetch data */
});

// Get clips from storage
const clips = await window.api.storageGetClips();

Expand Down Expand Up @@ -104,8 +147,9 @@ const success = await window.api.storageClearAll();
The storage system is automatically integrated into the clips provider:

1. **Startup**: Clips and settings are loaded from storage
2. **Real-time Sync**: Changes are automatically saved with debouncing
3. **Error Handling**: Graceful fallbacks when storage is unavailable
2. **Storage Ready**: When background load completes, renderer re-fetches real data
3. **Real-time Sync**: Changes are automatically saved with debouncing
4. **Error Handling**: Graceful fallbacks when storage is unavailable

### Manual Integration

Expand All @@ -117,13 +161,13 @@ import { storage } from './main/storage';
// Initialize storage
await storage.initialize();

// Save clips
// Save clips (writes only clips.enc)
await storage.saveClips(clips, lockedIndices);

// Load clips
const storedClips = await storage.getClips();

// Manage settings
// Manage settings (writes only settings.enc)
await storage.saveSettings({ maxClips: 20 });
const settings = await storage.getSettings();
```
Expand All @@ -135,6 +179,7 @@ const settings = await storage.getSettings();
- Data is encrypted at rest using platform-native encryption
- Encryption keys are managed by the operating system
- No encryption key management required in application code
- JSON is serialized without pretty-printing to minimize encrypted payload size

### Access Control

Expand All @@ -150,9 +195,18 @@ const settings = await storage.getSettings();

## Data Migration

The storage system includes automatic data migration:
### Legacy Migration (v1 → v2)

- **Version Detection**: Checks data format version
When upgrading from the monolithic `data.enc` format:

1. Detects `data.enc` exists but `clips.enc` does not
2. Reads and validates the legacy blob
3. Splits into domain-specific files (`settings.enc`, `clips.enc`, `templates.enc`, `meta.json`)
4. Renames `data.enc` to `data.enc.migrated`

### Ongoing Migration

- **Version Detection**: Checks data format version in `meta.json`
- **Graceful Upgrades**: Migrates old data formats to new schemas
- **Validation**: Ensures data integrity during migration
- **Fallback**: Uses defaults for invalid or missing data
Expand All @@ -163,25 +217,28 @@ The storage system includes automatic data migration:

Users can export their data as unencrypted JSON:

- Includes all clips and settings
- Human-readable format
- Includes all clips, settings, templates, search terms, and quick tools
- Human-readable format (pretty-printed for export only)
- Can be used for manual backup

### Import Data

Users can import previously exported data:

- Validates JSON format
- Merges with existing data
- Splits into domain-specific files
- Preserves data integrity

## Performance

### Optimizations

- **Debounced Saves**: Prevents excessive disk writes
- **Lazy Loading**: Storage is initialized only when needed
- **Efficient Serialization**: Minimal data transformation
- **Domain-Specific Saves**: Each save writes only the affected file, not the entire dataset
- **No Pretty-Printing**: Internal storage uses compact JSON to minimize encrypted size
- **Per-Domain Save Queuing**: Prevents concurrent writes to the same file while allowing parallel saves across domains
- **Debounced Saves**: Prevents excessive disk writes from the renderer
- **Non-Blocking Startup**: Window displays immediately; data loads in background
- **Direct Window Bounds**: `window-bounds.json` is read without encryption for instant position restore
- **Error Recovery**: Continues operation even if storage fails

### Storage Size
Expand Down Expand Up @@ -212,21 +269,3 @@ Users can import previously exported data:
- User data directory may not be accessible
- App falls back to temporary storage
- Consider running with proper permissions

### Debug Information

Enable debug logging by setting environment variable:

```bash
DEBUG=clipless:storage
```

## Future Enhancements

### Planned Features

- **Cloud Sync**: Optional cloud storage synchronization
- **Encryption Options**: User-selectable encryption methods
- **Data Compression**: Reduce storage size for large datasets
- **Selective Backup**: Export/import specific data types
- **Storage Quotas**: Limit storage size with automatic cleanup
Binary file added e2e/fixtures/test-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
81 changes: 81 additions & 0 deletions e2e/image-clipboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { test, expect, _electron as electron } from '@playwright/test';
import { resolve } from 'path';

test.describe('Image Clipboard', () => {
test('image clip appears after copying an image', async () => {
const app = await electron.launch({
args: [resolve(__dirname, '../out/main/index.js')],
});

const window = await app.firstWindow();
await window.waitForSelector('#root > *');

// Load a test image and write it to the clipboard via Electron's nativeImage
const testImagePath = resolve(__dirname, 'fixtures/test-image.png');
await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => {
const image = nativeImage.createFromPath(imgPath);
clipboard.writeImage(image);
}, testImagePath);

// Wait for clipboard polling to detect the image (250ms interval + processing)
await window.waitForTimeout(2000);

// Verify an image clip appeared with the expected elements
const imgPreview = window.locator('img[alt="Clipboard image preview"]');
await expect(imgPreview.first()).toBeVisible({ timeout: 5000 });

// Verify image metadata is displayed
const body = await window.textContent('body');
expect(body).toContain('Image (PNG)');

await app.close();
});

test('copying a second image adds another clip', async () => {
const app = await electron.launch({
args: [resolve(__dirname, '../out/main/index.js')],
});

const window = await app.firstWindow();
await window.waitForSelector('#root > *');

const testImagePath = resolve(__dirname, 'fixtures/test-image.png');

// Copy first image
await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => {
const image = nativeImage.createFromPath(imgPath);
clipboard.writeImage(image);
}, testImagePath);

await window.waitForTimeout(2000);
await expect(window.locator('img[alt="Clipboard image preview"]').first()).toBeVisible({
timeout: 5000,
});

// Clear clipboard with text to reset, then copy a different image
await app.evaluate(async ({ clipboard }) => {
clipboard.writeText('separator');
});
await window.waitForTimeout(1000);

// Copy image again (will have different fingerprint due to fresh nativeImage instance)
await app.evaluate(async ({ clipboard, nativeImage }, imgPath) => {
// Create a slightly different image by modifying pixels
const image = nativeImage.createFromPath(imgPath);
const size = image.getSize();
const buf = image.toBitmap();
// Flip a pixel to ensure different fingerprint
buf[0] = buf[0] === 0 ? 1 : 0;
const modified = nativeImage.createFromBitmap(buf, { width: size.width, height: size.height });
clipboard.writeImage(modified);
}, testImagePath);

await window.waitForTimeout(2000);

// Should now have two image clips
const imgPreviews = window.locator('img[alt="Clipboard image preview"]');
await expect(imgPreviews).toHaveCount(2, { timeout: 5000 });

await app.close();
});
});
32 changes: 30 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clipless",
"version": "1.7.1",
"version": "1.7.2",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "Daniel Essig",
Expand Down Expand Up @@ -37,6 +37,7 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-virtual": "^3.13.23",
"classnames": "^2.5.1",
"electron-updater": "^6.3.9",
"react-outside-click-handler": "^1.3.0",
Expand Down
Loading
Loading