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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ The things you don't notice until you'd miss them.

- **⌨️ Global hotkeys** — reach recent clips and quick look from anywhere, even when Clipless is minimized. Quick-clip hotkeys (1–5) grab your most recent items, and a focus hotkey snaps the window to you. Hotkeys are off by default — flip the master switch in Settings → Hotkeys to turn them on.
- **🔒 Encrypted storage** — history is encrypted with your OS keystore (DPAPI, Keychain or Secret Service) and never leaves your machine. Data is split into domain-specific files for efficient saves, with images stored as separate encrypted files and fast-loading thumbnails.
- **🚀 Non-blocking startup** — the window appears immediately while your history loads in the background.
- **🚀 Non-blocking startup** — the window appears immediately while your history loads in the background. Nothing is written back until that load succeeds, and if the history can't be read, a banner tells you saving is paused so the stored history isn't overwritten.
- **🖥️ Starts with you** — auto-launch on boot, start minimized to the tray, and update quietly in the background (auto-update works on Windows and Linux; macOS still needs a manual reinstall — see [Installing on macOS](#-installing-on-macos)).
- **💾 Backup-friendly** — export and import your clips, patterns, tools and templates.

Expand Down
46 changes: 2 additions & 44 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clipless",
"version": "2.2.2",
"version": "2.2.3",
"description": "A Clipboard manager for busy people",
"main": "./out/main/index.js",
"author": "Daniel Essig",
Expand Down
4 changes: 2 additions & 2 deletions src/main/clipboard/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
setSkipNextImageChange,
} from './monitoring';
import {
getClips,
getClipsSnapshot,
saveClips,
getSettings,
saveSettings,
Expand Down Expand Up @@ -104,7 +104,7 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void {
ipcMain.handle('stop-clipboard-monitoring', () => stopClipboardMonitoring());

// Storage integration handlers
ipcMain.handle('storage-get-clips', async () => getClips());
ipcMain.handle('storage-get-clips-snapshot', async () => getClipsSnapshot());
ipcMain.handle(
'storage-save-clips',
async (_event, clips: ClipItem[], lockedIndices: Record<number, boolean>) =>
Expand Down
181 changes: 181 additions & 0 deletions src/main/clipboard/storage-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('../storage', () => ({
storage: {
getClipsSnapshot: vi.fn(),
getLoadState: vi.fn(),
saveClips: vi.fn(),
getSettings: vi.fn(),
saveSettings: vi.fn(),
getStorageStats: vi.fn(),
exportData: vi.fn(),
importData: vi.fn(),
clearAllData: vi.fn(),
},
}));

import { storage } from '../storage';
import { DEFAULT_SETTINGS } from '../storage/defaults';
import type { StoredClipsSnapshot } from '../../shared/types';
import {
getClipsSnapshot,
saveClips,
getSettings,
saveSettings,
getStorageStats,
exportData,
importData,
clearAllData,
} from './storage-integration';

const mocked = vi.mocked(storage);
const failure = new Error('disk gone');

const snapshot: StoredClipsSnapshot = {
loadState: { complete: true, error: null },
clips: [{ clip: { id: 'a', type: 'text', content: 'kept' }, isLocked: false, timestamp: 1 }],
};

let consoleError: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.clearAllMocks();
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
});

describe('getClipsSnapshot', () => {
it('hands the snapshot through', async () => {
mocked.getClipsSnapshot.mockResolvedValue(snapshot);

expect(await getClipsSnapshot()).toBe(snapshot);
expect(consoleError).not.toHaveBeenCalled();
});

it('reports the current load state with no clips when the read throws', async () => {
const loadState = {
complete: true,
error: { message: 'Stored clips are not a list', recoverable: false },
};
mocked.getClipsSnapshot.mockRejectedValue(failure);
mocked.getLoadState.mockReturnValue(loadState);

expect(await getClipsSnapshot()).toEqual({ loadState, clips: [] });
expect(consoleError).toHaveBeenCalledWith('Failed to get clips from storage:', failure);
});
});

describe('saveClips', () => {
it('returns true once the clips are saved', async () => {
mocked.saveClips.mockResolvedValue(undefined);
const clips = [{ id: 'a', type: 'text' as const, content: 'kept' }];

expect(await saveClips(clips, { 0: true })).toBe(true);
expect(mocked.saveClips).toHaveBeenCalledWith(clips, { 0: true });
});

it('returns false when the save is refused', async () => {
mocked.saveClips.mockRejectedValue(new Error('Storage has not finished loading'));

expect(await saveClips([], {})).toBe(false);
expect(consoleError).toHaveBeenCalledWith(
'Failed to save clips to storage:',
expect.any(Error)
);
});
});

describe('getSettings', () => {
it('hands the settings through', async () => {
const settings = { ...DEFAULT_SETTINGS, maxClips: 7 };
mocked.getSettings.mockResolvedValue(settings);

expect(await getSettings()).toBe(settings);
});

it('falls back to the defaults when the read throws', async () => {
mocked.getSettings.mockRejectedValue(failure);

const settings = await getSettings();
expect(settings).toEqual(DEFAULT_SETTINGS);
expect(settings).not.toBe(DEFAULT_SETTINGS);
expect(consoleError).toHaveBeenCalledWith('Failed to get settings from storage:', failure);
});
});

describe('saveSettings', () => {
it('returns true once the settings are saved', async () => {
mocked.saveSettings.mockResolvedValue(undefined);

expect(await saveSettings(DEFAULT_SETTINGS)).toBe(true);
expect(mocked.saveSettings).toHaveBeenCalledWith(DEFAULT_SETTINGS);
});

it('returns false when the save throws', async () => {
mocked.saveSettings.mockRejectedValue(failure);

expect(await saveSettings(DEFAULT_SETTINGS)).toBe(false);
expect(consoleError).toHaveBeenCalledWith('Failed to save settings to storage:', failure);
});
});

describe('getStorageStats', () => {
it('hands the stats through', async () => {
const stats = { clipCount: 2, lockedCount: 1, dataSize: 40 };
mocked.getStorageStats.mockResolvedValue(stats);

expect(await getStorageStats()).toBe(stats);
});

it('reports zeroes when the read throws', async () => {
mocked.getStorageStats.mockRejectedValue(failure);

expect(await getStorageStats()).toEqual({ clipCount: 0, lockedCount: 0, dataSize: 0 });
expect(consoleError).toHaveBeenCalledWith('Failed to get storage stats:', failure);
});
});

describe('exportData', () => {
it('hands the export through', async () => {
mocked.exportData.mockResolvedValue('{"clips":[]}');

expect(await exportData()).toBe('{"clips":[]}');
});

it('logs and rethrows when the export fails', async () => {
mocked.exportData.mockRejectedValue(failure);

await expect(exportData()).rejects.toBe(failure);
expect(consoleError).toHaveBeenCalledWith('Failed to export data:', failure);
});
});

describe('importData', () => {
it('returns true once the data is imported', async () => {
mocked.importData.mockResolvedValue(undefined);

expect(await importData('{}')).toBe(true);
expect(mocked.importData).toHaveBeenCalledWith('{}');
});

it('logs and rethrows when the import fails', async () => {
mocked.importData.mockRejectedValue(failure);

await expect(importData('nonsense')).rejects.toBe(failure);
expect(consoleError).toHaveBeenCalledWith('Failed to import data:', failure);
});
});

describe('clearAllData', () => {
it('returns true once the data is cleared', async () => {
mocked.clearAllData.mockResolvedValue(undefined);

expect(await clearAllData()).toBe(true);
});

it('returns false when clearing throws', async () => {
mocked.clearAllData.mockRejectedValue(failure);

expect(await clearAllData()).toBe(false);
expect(consoleError).toHaveBeenCalledWith('Failed to clear all data:', failure);
});
});
11 changes: 7 additions & 4 deletions src/main/clipboard/storage-integration.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { storage } from '../storage';
import { DEFAULT_SETTINGS } from '../storage/defaults';
import type { ClipItem, StoredClip, UserSettings } from '../../shared/types';
import type { ClipItem, StoredClipsSnapshot, UserSettings } from '../../shared/types';

// Storage integration functions
export const getClips = async (): Promise<StoredClip[]> => {

// The clips come with the load state they were read under: until `loadState.complete` they
// are the empty placeholder, and with `loadState.error` set they are not the stored history
export const getClipsSnapshot = async (): Promise<StoredClipsSnapshot> => {
try {
return await storage.getClips();
return await storage.getClipsSnapshot();
} catch (error) {
console.error('Failed to get clips from storage:', error);
return [];
return { loadState: storage.getLoadState(), clips: [] };
}
};

Expand Down
Loading
Loading