diff --git a/README.md b/README.md index 84bf51bb..d2db5f58 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 727153b8..9e669a2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "2.2.2", + "version": "2.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "2.2.2", + "version": "2.2.3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1756,9 +1756,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1999,9 +1996,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2015,9 +2009,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2031,9 +2022,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2047,9 +2035,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2063,9 +2048,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2079,9 +2061,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2095,9 +2074,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2111,9 +2087,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2127,9 +2100,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2143,9 +2113,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2159,9 +2126,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2175,9 +2139,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2191,9 +2152,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 62cf8304..c856dffc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/clipboard/ipc.ts b/src/main/clipboard/ipc.ts index 1a36aaab..460a4644 100644 --- a/src/main/clipboard/ipc.ts +++ b/src/main/clipboard/ipc.ts @@ -13,7 +13,7 @@ import { setSkipNextImageChange, } from './monitoring'; import { - getClips, + getClipsSnapshot, saveClips, getSettings, saveSettings, @@ -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) => diff --git a/src/main/clipboard/storage-integration.test.ts b/src/main/clipboard/storage-integration.test.ts new file mode 100644 index 00000000..5dd25776 --- /dev/null +++ b/src/main/clipboard/storage-integration.test.ts @@ -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; + +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); + }); +}); diff --git a/src/main/clipboard/storage-integration.ts b/src/main/clipboard/storage-integration.ts index 1a949949..4af8bbe6 100644 --- a/src/main/clipboard/storage-integration.ts +++ b/src/main/clipboard/storage-integration.ts @@ -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 => { + +// 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 => { 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: [] }; } }; diff --git a/src/main/storage/index.test.ts b/src/main/storage/index.test.ts new file mode 100644 index 00000000..bcfba163 --- /dev/null +++ b/src/main/storage/index.test.ts @@ -0,0 +1,912 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('electron', () => ({ + app: { getPath: vi.fn().mockReturnValue('/mock/userData') }, + nativeImage: { createFromDataURL: vi.fn() }, + safeStorage: { + isEncryptionAvailable: vi.fn().mockReturnValue(true), + encryptString: vi.fn((str: string) => Buffer.from(str)), + decryptString: vi.fn((buf: Buffer) => buf.toString()), + }, +})); + +vi.mock('fs', () => ({ + promises: { + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn(), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })), + mkdir: vi.fn().mockResolvedValue(undefined), + readdir: vi.fn().mockResolvedValue([]), + stat: vi.fn().mockRejectedValue(new Error('ENOENT')), + }, +})); + +vi.mock('./file-operations', () => ({ + saveEncryptedJson: vi.fn().mockResolvedValue(undefined), + loadEncryptedJson: vi.fn(), + saveJsonFile: vi.fn().mockResolvedValue(undefined), + loadJsonFile: vi.fn().mockRejectedValue(new Error('FILE_NOT_FOUND')), + ensureDataDirectory: vi.fn().mockResolvedValue(undefined), + isEncryptionAvailable: vi.fn().mockReturnValue(true), +})); + +vi.mock('./image-store', () => ({ + saveImage: vi.fn(), + deleteImage: vi.fn().mockResolvedValue(undefined), + deleteAllImages: vi.fn().mockResolvedValue(undefined), +})); + +import type { + StoredClip, + Template, + SearchTerm, + QuickTool, + QuickClipsConfig, +} from '../../shared/types'; +import { DEFAULT_SETTINGS } from './defaults'; + +const history: StoredClip[] = [ + { clip: { id: 'a', type: 'text', content: 'kept' }, isLocked: false, timestamp: 1 }, + { + clip: { id: 'b', type: 'image', content: 'img-1', imageId: 'img-1' }, + isLocked: true, + timestamp: 2, + }, +]; + +const notFound = () => Promise.reject(new Error('FILE_NOT_FOUND')); +const decryptFailure = () => + Promise.reject(new Error('Error while decrypting the ciphertext provided to safeStorage.')); + +// The module exports one instance, so each test imports a fresh copy of it along with the +// mocked collaborators that copy is wired to +let storage: typeof import('./index.js').storage; +let fileOperations: typeof import('./file-operations.js'); +let imageStore: typeof import('./image-store.js'); + +beforeEach(async () => { + vi.resetModules(); + fileOperations = await import('./file-operations.js'); + imageStore = await import('./image-store.js'); + ({ storage } = await import('./index.js')); + // The mocked modules keep their spies across the reset, so clear what earlier tests set + vi.clearAllMocks(); + vi.mocked(fileOperations.isEncryptionAvailable).mockReturnValue(true); +}); + +// Resolves once the background load has run to completion +const initialiseAndWaitForLoad = async () => { + const done = new Promise((resolve) => storage.setOnBackgroundLoadComplete(resolve)); + await storage.initialize(); + await done; +}; + +// Every domain file is missing except clips, which is served by `clips` +const serveFiles = (clips: () => Promise) => { + vi.mocked(fileOperations.loadEncryptedJson).mockImplementation((filePath: string) => + filePath.endsWith('clips.enc') ? (clips() as Promise) : (notFound() as Promise) + ); +}; + +describe('SecureStorage load state', () => { + it('reports the load as incomplete until the background load finishes', async () => { + let finishClips!: (value: StoredClip[]) => void; + const pending = new Promise((resolve) => (finishClips = resolve)); + serveFiles(() => pending); + + const done = new Promise((resolve) => storage.setOnBackgroundLoadComplete(resolve)); + await storage.initialize(); + + expect(storage.getLoadState()).toEqual({ complete: false, error: null }); + expect(await storage.getClips()).toEqual([]); + + finishClips(history); + await done; + + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); + expect((await storage.getClips()).map((c) => c.clip.id)).toEqual(['a', 'b']); + }); + + it('hands out the clips with the load state they were read under', async () => { + let finishClips!: (value: StoredClip[]) => void; + const pending = new Promise((resolve) => (finishClips = resolve)); + serveFiles(() => pending); + + const done = new Promise((resolve) => storage.setOnBackgroundLoadComplete(resolve)); + await storage.initialize(); + + expect(await storage.getClipsSnapshot()).toEqual({ + loadState: { complete: false, error: null }, + clips: [], + }); + + finishClips(history); + await done; + + const loaded = await storage.getClipsSnapshot(); + expect(loaded.loadState).toEqual({ complete: true, error: null }); + expect(loaded.clips.map((c) => c.clip.id)).toEqual(['a', 'b']); + }); + + it('treats a missing clips file as a successful, empty load', async () => { + serveFiles(notFound); + await initialiseAndWaitForLoad(); + + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); + }); + + it('reports a failed load when the clips file cannot be decrypted', async () => { + serveFiles(decryptFailure); + await initialiseAndWaitForLoad(); + + const state = storage.getLoadState(); + expect(state.complete).toBe(true); + expect(state.error).toEqual({ + message: expect.stringMatching(/decrypting/), + recoverable: false, + }); + }); + + it('reports a failed load when the clips file does not hold a list', async () => { + serveFiles(() => Promise.resolve({ clips: history })); + await initialiseAndWaitForLoad(); + + const state = storage.getLoadState(); + expect(state.complete).toBe(true); + expect(state.error).toEqual({ + message: expect.stringMatching(/not a list/), + recoverable: false, + }); + expect(await storage.getClips()).toEqual([]); + }); + + it('reports a failed load when encryption is unavailable', async () => { + vi.mocked(fileOperations.isEncryptionAvailable).mockReturnValue(false); + await initialiseAndWaitForLoad(); + + expect(storage.getLoadState()).toEqual({ + complete: true, + error: { message: expect.stringMatching(/Encryption is not available/), recoverable: true }, + }); + }); +}); + +describe('SecureStorage.saveClips guard', () => { + it('refuses to save while the background load is still running', async () => { + serveFiles(() => new Promise(() => {})); + await storage.initialize(); + + await expect(storage.saveClips([], {})).rejects.toThrow(/not finished loading/); + expect(fileOperations.saveEncryptedJson).not.toHaveBeenCalled(); + expect(imageStore.deleteImage).not.toHaveBeenCalled(); + }); + + it('refuses to save over a history that could not be read', async () => { + serveFiles(decryptFailure); + await initialiseAndWaitForLoad(); + + await expect(storage.saveClips([], {})).rejects.toThrow(/could not be loaded/); + expect(fileOperations.saveEncryptedJson).not.toHaveBeenCalled(); + expect(imageStore.deleteImage).not.toHaveBeenCalled(); + }); + + it('saves normally once the history has loaded', async () => { + serveFiles(() => Promise.resolve(history)); + await initialiseAndWaitForLoad(); + + await storage.saveClips([{ id: 'c', type: 'text', content: 'new' }], {}); + + const mockedSave = vi.mocked(fileOperations.saveEncryptedJson); + expect(mockedSave).toHaveBeenCalledTimes(1); + const [saved, filePath] = mockedSave.mock.calls[0]; + expect(filePath).toMatch(/clips\.enc$/); + expect((saved as StoredClip[]).map((c) => c.clip.id)).toEqual(['c']); + // The image the replaced history referenced is cleaned up as before + expect(imageStore.deleteImage).toHaveBeenCalledWith('img-1', expect.any(String)); + }); +}); + +// ===== The rest of the class: every domain the load feeds and every write that follows ===== + +const term = (id: string, pattern = '(?\\d+)'): SearchTerm => ({ + id, + name: id, + pattern, + enabled: true, + createdAt: 1, + updatedAt: 1, + order: 0, +}); +const tool = (id: string): QuickTool => ({ + id, + name: id, + url: 'https://example.test/?q={ip}', + captureGroups: ['ip'], + createdAt: 1, + updatedAt: 1, + order: 0, +}); +const template = (id: string): Template => ({ + id, + name: id, + content: `${id} body`, + createdAt: 1, + updatedAt: 1, + order: 0, +}); +const quickClips = (overrides: Partial = {}): QuickClipsConfig => ({ + searchTerms: [], + tools: [], + version: '2.0.0', + ...overrides, +}); + +type Domain = 'settings' | 'clips' | 'templates'; + +// Serve the named domain files; anything else is missing +const serveDomains = (files: Partial Promise>>) => { + vi.mocked(fileOperations.loadEncryptedJson).mockImplementation((filePath: string) => { + const domain = (Object.keys(files) as Domain[]).find((d) => filePath.endsWith(`${d}.enc`)); + return (domain ? files[domain]!() : notFound()) as Promise; + }); +}; + +let fs: typeof import('fs').promises; +let consoleError: ReturnType; + +beforeEach(async () => { + ({ promises: fs } = await import('fs')); + // The fs mock keeps its instance across the module reset, so restore the empty-disk default + vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT')); + serveDomains({}); + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +describe('SecureStorage initialisation', () => { + it('initialises once', async () => { + await initialiseAndWaitForLoad(); + await storage.initialize(); + + expect(fileOperations.ensureDataDirectory).toHaveBeenCalledTimes(1); + }); + + it('keeps the defaults and reports a recoverable failure when the load itself throws', async () => { + vi.mocked(fileOperations.ensureDataDirectory).mockRejectedValueOnce(new Error('EACCES')); + await initialiseAndWaitForLoad(); + + expect(storage.getLoadState()).toEqual({ + complete: true, + error: { message: 'EACCES', recoverable: true }, + }); + await expect(storage.saveClips([], {})).rejects.toThrow(/EACCES/); + expect(consoleError).toHaveBeenCalledWith( + 'Failed to load data in background:', + expect.any(Error) + ); + }); + + it('describes a failure that is not an Error object', async () => { + serveFiles(() => Promise.reject('keystore locked')); + await initialiseAndWaitForLoad(); + + expect(storage.getLoadState().error).toEqual({ + message: 'keystore locked', + recoverable: false, + }); + }); + + it('calls a completion callback at once when the load has already finished', async () => { + await initialiseAndWaitForLoad(); + const callback = vi.fn(); + + storage.setOnBackgroundLoadComplete(callback); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + const firstUse: [string, () => Promise][] = [ + ['getClips', () => storage.getClips()], + ['getClipsSnapshot', () => storage.getClipsSnapshot()], + ['saveClips', () => storage.saveClips([], {})], + ['getSettings', () => storage.getSettings()], + ['saveSettings', () => storage.saveSettings({ maxClips: 4 })], + ['updateSetting', () => storage.updateSetting('startMinimized', true)], + ['getTemplates', () => storage.getTemplates()], + ['createTemplate', () => storage.createTemplate('t', 'body')], + ['updateTemplate', () => storage.updateTemplate('missing', { name: 'x' })], + ['deleteTemplate', () => storage.deleteTemplate('missing')], + ['reorderTemplates', () => storage.reorderTemplates([])], + ['generateTextFromTemplate', () => storage.generateTextFromTemplate('missing', [])], + ['getSearchTerms', () => storage.getSearchTerms()], + ['createSearchTerm', () => storage.createSearchTerm('s', '(?\\d+)')], + ['updateSearchTerm', () => storage.updateSearchTerm('missing', { name: 'x' })], + ['deleteSearchTerm', () => storage.deleteSearchTerm('missing')], + ['getQuickTools', () => storage.getQuickTools()], + ['createQuickTool', () => storage.createQuickTool('q', 'https://example.test/{ip}', ['ip'])], + ['updateQuickTool', () => storage.updateQuickTool('missing', { name: 'x' })], + ['deleteQuickTool', () => storage.deleteQuickTool('missing')], + ['getGroupColours', () => storage.getGroupColours()], + ['setGroupColours', () => storage.setGroupColours({})], + ['importQuickClipsConfig', () => storage.importQuickClipsConfig(quickClips())], + ['clearAllData', () => storage.clearAllData()], + ['exportData', () => storage.exportData()], + ['importData', () => storage.importData('{}')], + ['getStorageStats', () => storage.getStorageStats()], + ]; + + it.each(firstUse)('%s initialises storage on first use', async (_name, call) => { + await call().catch(() => undefined); + + expect(fileOperations.ensureDataDirectory).toHaveBeenCalledTimes(1); + }); +}); + +describe('SecureStorage domain loading', () => { + it('merges stored settings over the defaults', async () => { + serveDomains({ settings: () => Promise.resolve({ maxClips: 5 }) }); + await initialiseAndWaitForLoad(); + + const settings = await storage.getSettings(); + expect(settings.maxClips).toBe(5); + expect(settings.theme).toBe(DEFAULT_SETTINGS.theme); + expect(settings.hotkeys).toEqual(DEFAULT_SETTINGS.hotkeys); + }); + + it('keeps the default settings when the file does not hold an object', async () => { + serveDomains({ settings: () => Promise.resolve(null) }); + await initialiseAndWaitForLoad(); + + expect((await storage.getSettings()).maxClips).toBe(DEFAULT_SETTINGS.maxClips); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('logs a settings file that cannot be read and still completes the load', async () => { + serveDomains({ settings: () => Promise.reject(new Error('corrupt')) }); + await initialiseAndWaitForLoad(); + + expect(consoleError).toHaveBeenCalledWith('Failed to load settings:', expect.any(Error)); + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); + }); + + it('loads templates, search terms, tools and colours from the templates file', async () => { + serveDomains({ + templates: () => + Promise.resolve({ + templates: [template('t1')], + searchTerms: [term('s1')], + quickTools: [tool('q1')], + groupColours: { ip: 3 }, + }), + }); + await initialiseAndWaitForLoad(); + + expect((await storage.getTemplates()).map((t) => t.id)).toEqual(['t1']); + expect((await storage.getSearchTerms()).map((t) => t.id)).toEqual(['s1']); + expect((await storage.getQuickTools()).map((t) => t.id)).toEqual(['q1']); + expect(await storage.getGroupColours()).toEqual({ ip: 3 }); + }); + + it('loads a templates file that carries no colours', async () => { + serveDomains({ + templates: () => + Promise.resolve({ templates: [], searchTerms: [term('s1')], quickTools: [] }), + }); + await initialiseAndWaitForLoad(); + + expect((await storage.getSearchTerms()).map((t) => t.id)).toEqual(['s1']); + expect(await storage.getGroupColours()).toEqual({}); + }); + + it('keeps empty lists when the templates file does not hold an object', async () => { + serveDomains({ templates: () => Promise.resolve(null) }); + await initialiseAndWaitForLoad(); + + expect(await storage.getTemplates()).toEqual([]); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('logs a templates file that cannot be read', async () => { + serveDomains({ templates: () => Promise.reject(new Error('corrupt')) }); + await initialiseAndWaitForLoad(); + + expect(consoleError).toHaveBeenCalledWith('Failed to load templates data:', expect.any(Error)); + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); + }); + + it('reads the stored meta', async () => { + vi.mocked(fileOperations.loadJsonFile).mockResolvedValueOnce({ + version: '1.2.3', + storageVersion: 1, + }); + await initialiseAndWaitForLoad(); + + expect(JSON.parse(await storage.exportData()).version).toBe('1.2.3'); + }); + + it('fills in meta fields the file lacks', async () => { + vi.mocked(fileOperations.loadJsonFile).mockResolvedValueOnce({}); + await initialiseAndWaitForLoad(); + + expect(JSON.parse(await storage.exportData()).version).toBe('0.0.0-test'); + }); + + it('keeps the default meta when the file does not hold an object', async () => { + vi.mocked(fileOperations.loadJsonFile).mockResolvedValueOnce(null); + await initialiseAndWaitForLoad(); + + expect(JSON.parse(await storage.exportData()).version).toBe('0.0.0-test'); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('logs a meta file that cannot be read', async () => { + vi.mocked(fileOperations.loadJsonFile).mockRejectedValueOnce(new Error('corrupt')); + await initialiseAndWaitForLoad(); + + expect(consoleError).toHaveBeenCalledWith('Failed to load meta:', expect.any(Error)); + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); + }); + + it('moves inline base64 images into image files and saves the clips once', async () => { + vi.mocked(imageStore.saveImage).mockResolvedValue('thumb'); + serveFiles(() => + Promise.resolve([ + { + clip: { id: 'inline', type: 'image', content: 'data:image/png;base64,AAAA' }, + isLocked: false, + timestamp: 1, + }, + { + clip: { id: 'filed', type: 'image', content: 'img-1', imageId: 'img-1' }, + isLocked: false, + timestamp: 2, + }, + { + clip: { id: 'odd', type: 'image', content: 'not-a-data-url' }, + isLocked: false, + timestamp: 3, + }, + { clip: { id: 'text', type: 'text', content: 'words' }, isLocked: false, timestamp: 4 }, + ]) + ); + await initialiseAndWaitForLoad(); + + const clips = await storage.getClips(); + const moved = clips.find((c) => c.clip.id === 'inline')!.clip; + expect(imageStore.saveImage).toHaveBeenCalledTimes(1); + expect(imageStore.saveImage).toHaveBeenCalledWith( + moved.imageId, + 'data:image/png;base64,AAAA', + expect.any(String) + ); + expect(moved.content).toBe(moved.imageId); + expect(moved.thumbnailDataUrl).toBe('thumb'); + expect(clips.find((c) => c.clip.id === 'odd')!.clip.content).toBe('not-a-data-url'); + const mockedSave = vi.mocked(fileOperations.saveEncryptedJson); + expect(mockedSave).toHaveBeenCalledTimes(1); + expect(mockedSave.mock.calls[0][1]).toMatch(/clips\.enc$/); + }); + + it('keeps an inline image in place when it cannot be moved, and writes nothing', async () => { + vi.mocked(imageStore.saveImage).mockRejectedValue(new Error('disk full')); + serveFiles(() => + Promise.resolve([ + { + clip: { id: 'inline', type: 'image', content: 'data:image/png;base64,AAAA' }, + isLocked: false, + timestamp: 1, + }, + ]) + ); + await initialiseAndWaitForLoad(); + + expect((await storage.getClips())[0].clip.content).toBe('data:image/png;base64,AAAA'); + expect(consoleError).toHaveBeenCalledWith('Failed to migrate inline image:', expect.any(Error)); + expect(fileOperations.saveEncryptedJson).not.toHaveBeenCalled(); + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); + }); +}); + +describe('SecureStorage.flush', () => { + it('resolves once every pending write has finished', async () => { + await initialiseAndWaitForLoad(); + let finishWrite!: () => void; + vi.mocked(fileOperations.saveEncryptedJson).mockReturnValueOnce( + new Promise((resolve) => (finishWrite = resolve)) + ); + + const save = storage.saveSettings({ maxClips: 9 }); + let flushed = false; + const flush = storage.flush().then(() => { + flushed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(flushed).toBe(false); + + finishWrite(); + await Promise.all([save, flush]); + expect(flushed).toBe(true); + }); +}); + +describe('SecureStorage settings', () => { + it('persists a settings migration the first time it applies', async () => { + serveDomains({ + settings: () => + Promise.resolve({ + maxClips: 5, + hotkeys: { openToolsLauncher: { enabled: true, key: 'CommandOrControl+Shift+X' } }, + }), + }); + await initialiseAndWaitForLoad(); + + const settings = await storage.getSettings(); + expect(settings.hotkeys?.quickLook.key).toBe('CommandOrControl+Shift+X'); + const mockedSave = vi.mocked(fileOperations.saveEncryptedJson); + expect(mockedSave).toHaveBeenCalledTimes(1); + expect(mockedSave.mock.calls[0][1]).toMatch(/settings\.enc$/); + + await storage.getSettings(); + expect(mockedSave).toHaveBeenCalledTimes(1); + }); + + it('merges saved settings into the stored ones', async () => { + await initialiseAndWaitForLoad(); + + await storage.saveSettings({ maxClips: 9 }); + await storage.updateSetting('startMinimized', true); + + const settings = await storage.getSettings(); + expect(settings.maxClips).toBe(9); + expect(settings.startMinimized).toBe(true); + expect(settings.theme).toBe(DEFAULT_SETTINGS.theme); + expect(fileOperations.saveEncryptedJson).toHaveBeenCalledTimes(2); + }); +}); + +describe('SecureStorage templates', () => { + it('creates, updates, reorders, renders and deletes templates', async () => { + await initialiseAndWaitForLoad(); + + const a = await storage.createTemplate('A', 'plain text'); + const b = await storage.createTemplate('B', 'other text'); + expect((await storage.getTemplates()).map((t) => t.id)).toEqual([a.id, b.id]); + + expect((await storage.updateTemplate(b.id, { name: 'B2' })).name).toBe('B2'); + + await storage.reorderTemplates([b, a, { ...a, id: 'ghost' }]); + expect((await storage.getTemplates()).map((t) => t.id)).toEqual([b.id, a.id]); + + expect(await storage.generateTextFromTemplate(a.id, ['clip'])).toBe('plain text'); + + await storage.deleteTemplate(a.id); + const remaining = await storage.getTemplates(); + expect(remaining.map((t) => [t.id, t.order])).toEqual([[b.id, 0]]); + }); + + it('rejects unknown template ids', async () => { + await initialiseAndWaitForLoad(); + + await expect(storage.updateTemplate('missing', { name: 'x' })).rejects.toThrow( + 'Template not found' + ); + await expect(storage.deleteTemplate('missing')).rejects.toThrow('Template not found'); + await expect(storage.generateTextFromTemplate('missing', [])).rejects.toThrow( + 'Template not found' + ); + }); +}); + +describe('SecureStorage search terms', () => { + it('creates, updates and deletes search terms', async () => { + await initialiseAndWaitForLoad(); + + const a = await storage.createSearchTerm('A', '(?\\d+)'); + const b = await storage.createSearchTerm('B', '(?\\w+)'); + expect((await storage.getSearchTerms()).map((t) => t.id)).toEqual([a.id, b.id]); + + expect((await storage.updateSearchTerm(a.id, { enabled: false })).enabled).toBe(false); + + await storage.deleteSearchTerm(a.id); + expect((await storage.getSearchTerms()).map((t) => [t.id, t.order])).toEqual([[b.id, 0]]); + }); + + it('rejects unknown search term ids', async () => { + await initialiseAndWaitForLoad(); + + await expect(storage.updateSearchTerm('missing', { name: 'x' })).rejects.toThrow( + 'Search term not found' + ); + await expect(storage.deleteSearchTerm('missing')).rejects.toThrow('Search term not found'); + }); +}); + +describe('SecureStorage quick tools', () => { + it('creates, updates and deletes quick tools', async () => { + await initialiseAndWaitForLoad(); + + const a = await storage.createQuickTool('A', 'https://a.test/{ip}', ['ip']); + const b = await storage.createQuickTool('B', 'https://b.test/{ip}', ['ip']); + expect((await storage.getQuickTools()).map((t) => t.id)).toEqual([a.id, b.id]); + + expect((await storage.updateQuickTool(a.id, { name: 'A2' })).name).toBe('A2'); + + await storage.deleteQuickTool(a.id); + expect((await storage.getQuickTools()).map((t) => [t.id, t.order])).toEqual([[b.id, 0]]); + }); + + it('rejects unknown quick tool ids', async () => { + await initialiseAndWaitForLoad(); + + await expect(storage.updateQuickTool('missing', { name: 'x' })).rejects.toThrow( + 'Quick tool not found' + ); + await expect(storage.deleteQuickTool('missing')).rejects.toThrow('Quick tool not found'); + }); +}); + +describe('SecureStorage group colours', () => { + it('starts empty, keeps colours for groups in use and drops the rest on save', async () => { + await initialiseAndWaitForLoad(); + expect(await storage.getGroupColours()).toEqual({}); + + await storage.createSearchTerm('ips', '(?\\d+)'); + expect(await storage.setGroupColours({ ip: 2, stale: 5 })).toEqual({ ip: 2 }); + expect(await storage.getGroupColours()).toEqual({ ip: 2 }); + + expect(await storage.setGroupColours({ ip: 3 })).toEqual({ ip: 3 }); + }); +}); + +describe('SecureStorage.importQuickClipsConfig', () => { + it('appends terms, tools and templates after the existing ones when merging', async () => { + await initialiseAndWaitForLoad(); + + await storage.importQuickClipsConfig( + quickClips({ searchTerms: [term('s1')], tools: [tool('q1')], templates: [template('t1')] }) + ); + await storage.importQuickClipsConfig( + quickClips({ searchTerms: [term('s2')], tools: [tool('q2')], templates: [template('t2')] }), + 'merge' + ); + + expect((await storage.getSearchTerms()).map((t) => [t.name, t.order])).toEqual([ + ['s1', 0], + ['s2', 1], + ]); + expect((await storage.getQuickTools()).map((t) => [t.name, t.order])).toEqual([ + ['q1', 0], + ['q2', 1], + ]); + expect((await storage.getTemplates()).map((t) => [t.id, t.order])).toEqual([ + ['t1', 0], + ['t2', 1], + ]); + expect(fileOperations.saveEncryptedJson).toHaveBeenCalledTimes(2); + }); + + it('skips templates missing an id, name or content, and a templates field that is not a list', async () => { + await initialiseAndWaitForLoad(); + + await storage.importQuickClipsConfig( + quickClips({ + templates: [ + template('ok'), + { ...template('no-id'), id: '' }, + { ...template('no-name'), name: '' }, + { ...template('no-content'), content: '' }, + ], + }) + ); + await storage.importQuickClipsConfig( + quickClips({ templates: 'nope' as unknown as Template[] }) + ); + await storage.importQuickClipsConfig(quickClips({ templates: [] })); + + expect((await storage.getTemplates()).map((t) => t.id)).toEqual(['ok']); + }); + + it('writes nothing when a merge brings nothing new', async () => { + await initialiseAndWaitForLoad(); + + await storage.importQuickClipsConfig(quickClips()); + + expect(fileOperations.saveEncryptedJson).not.toHaveBeenCalled(); + }); + + it('replaces the lists and the colour map when replacing', async () => { + await initialiseAndWaitForLoad(); + await storage.createSearchTerm('old', '(?\\d+)'); + await storage.setGroupColours({ old: 1 }); + + await storage.importQuickClipsConfig( + quickClips({ searchTerms: [term('s1')], groupColours: { ip: 4 } }), + 'replace' + ); + + expect((await storage.getSearchTerms()).map((t) => t.name)).toEqual(['s1']); + expect(await storage.getGroupColours()).toEqual({ ip: 4 }); + }); + + it('adds missing colours when merging and keeps the existing ones', async () => { + await initialiseAndWaitForLoad(); + await storage.createSearchTerm('a', '(?\\d+):(?\\d+)'); + await storage.setGroupColours({ ip: 1 }); + + await storage.importQuickClipsConfig(quickClips({ groupColours: { ip: 9, port: 2 } })); + + expect(await storage.getGroupColours()).toEqual({ ip: 1, port: 2 }); + }); +}); + +describe('SecureStorage window bounds', () => { + it('writes and reads the bounds file', async () => { + const bounds = { x: 1, y: 2, width: 300, height: 400 }; + + await storage.saveWindowBounds(bounds); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/window-bounds\.json$/), + JSON.stringify(bounds, null, 2) + ); + + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(bounds) as never); + expect(await storage.getWindowBounds()).toEqual(bounds); + }); +}); + +describe('SecureStorage.clearAllData', () => { + it('drops every domain file and image and resets to the defaults', async () => { + serveFiles(() => Promise.resolve(history)); + await initialiseAndWaitForLoad(); + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT')); + + await storage.clearAllData(); + + expect(fs.unlink).toHaveBeenCalledTimes(4); + expect(imageStore.deleteAllImages).toHaveBeenCalledWith(expect.any(String)); + expect(await storage.getClips()).toEqual([]); + }); +}); + +describe('SecureStorage.exportData', () => { + it('exports every domain, with colours when some are set', async () => { + serveDomains({ + clips: () => Promise.resolve(history), + templates: () => + Promise.resolve({ + templates: [], + searchTerms: [term('s1')], + quickTools: [], + groupColours: { ip: 2 }, + }), + }); + vi.mocked(fileOperations.loadJsonFile).mockResolvedValueOnce({ + version: '1.2.3', + storageVersion: 1, + }); + await initialiseAndWaitForLoad(); + + const data = JSON.parse(await storage.exportData()); + expect(data.clips.map((c: StoredClip) => c.clip.id)).toEqual(['a', 'b']); + expect(data.searchTerms.map((t: SearchTerm) => t.id)).toEqual(['s1']); + expect(data.groupColours).toEqual({ ip: 2 }); + expect(data.version).toBe('1.2.3'); + }); + + it('leaves colours out of the export when none are set', async () => { + await initialiseAndWaitForLoad(); + + const data = JSON.parse(await storage.exportData()); + expect(data).not.toHaveProperty('groupColours'); + expect(data.version).toBe('0.0.0-test'); + }); +}); + +describe('SecureStorage.importData', () => { + it('replaces every domain from a backup and writes them all', async () => { + await initialiseAndWaitForLoad(); + const backup = { + clips: history, + settings: { maxClips: 3 }, + templates: [template('t1')], + searchTerms: [term('s1')], + quickTools: [], + groupColours: { ip: 1 }, + version: '9.9.9', + }; + + await storage.importData(JSON.stringify(backup)); + + expect(fileOperations.saveEncryptedJson).toHaveBeenCalledTimes(3); + expect(fileOperations.saveJsonFile).toHaveBeenCalledWith( + { version: '9.9.9', storageVersion: 1 }, + expect.stringMatching(/meta\.json$/) + ); + expect((await storage.getClips()).map((c) => c.clip.id)).toEqual(['a', 'b']); + expect((await storage.getSettings()).maxClips).toBe(3); + expect((await storage.getTemplates()).map((t) => t.id)).toEqual(['t1']); + expect(await storage.getGroupColours()).toEqual({ ip: 1 }); + }); + + it('imports a backup that carries no colours', async () => { + await initialiseAndWaitForLoad(); + + await storage.importData(JSON.stringify({ clips: history, version: '1.0.0' })); + + expect(await storage.getGroupColours()).toEqual({}); + expect(JSON.parse(await storage.exportData())).not.toHaveProperty('groupColours'); + }); + + it('rejects a backup that is not JSON', async () => { + await initialiseAndWaitForLoad(); + + await expect(storage.importData('not json')).rejects.toThrow('Invalid data format'); + expect(consoleError).toHaveBeenCalledWith('Failed to import data:', expect.any(Error)); + expect(fileOperations.saveEncryptedJson).not.toHaveBeenCalled(); + }); +}); + +describe('SecureStorage.getStorageStats', () => { + it('sums the domain files and the images that can be measured', async () => { + serveFiles(() => Promise.resolve(history)); + await initialiseAndWaitForLoad(); + vi.mocked(fs.stat).mockImplementation(async (filePath) => { + const path = String(filePath); + if (path.endsWith('clips.enc')) return { size: 10 } as never; + if (path.endsWith('one.enc')) return { size: 5 } as never; + throw new Error('ENOENT'); + }); + vi.mocked(fs.readdir).mockResolvedValueOnce(['one.enc', 'two.enc'] as never); + + expect(await storage.getStorageStats()).toEqual({ clipCount: 2, lockedCount: 1, dataSize: 15 }); + }); + + it('reports the clip counts alone when nothing on disk can be measured', async () => { + serveFiles(() => Promise.resolve(history)); + await initialiseAndWaitForLoad(); + vi.mocked(fs.readdir).mockRejectedValueOnce(new Error('ENOENT')); + + expect(await storage.getStorageStats()).toEqual({ clipCount: 2, lockedCount: 1, dataSize: 0 }); + }); +}); + +describe('SecureStorage.saveClips image cleanup', () => { + it('keeps the images the new list still references', async () => { + serveFiles(() => Promise.resolve(history)); + await initialiseAndWaitForLoad(); + + await storage.saveClips( + [ + { id: 'b', type: 'image', content: 'img-1', imageId: 'img-1' }, + { id: 'c', type: 'image', content: 'img-2', imageId: 'img-2' }, + ], + { 1: true } + ); + + expect(imageStore.deleteImage).not.toHaveBeenCalled(); + const [saved] = vi.mocked(fileOperations.saveEncryptedJson).mock.calls[0]; + expect((saved as StoredClip[]).map((c) => [c.clip.id, c.isLocked])).toEqual([ + ['b', false], + ['c', true], + ]); + }); +}); + +describe('SecureStorage.saveClips orphaned images', () => { + it('logs an orphaned image that could not be deleted and still saves', async () => { + serveFiles(() => Promise.resolve(history)); + await initialiseAndWaitForLoad(); + vi.mocked(imageStore.deleteImage).mockRejectedValueOnce(new Error('EPERM')); + + await storage.saveClips([{ id: 'c', type: 'text', content: 'new' }], {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(imageStore.deleteImage).toHaveBeenCalledWith('img-1', expect.any(String)); + expect(consoleError).toHaveBeenCalledWith( + 'Failed to delete orphaned image:', + expect.any(Error) + ); + expect(fileOperations.saveEncryptedJson).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 9cfe1021..995c2279 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -15,6 +15,9 @@ import type { GroupColours, TemplatesData, StorageMeta, + StorageLoadError, + StorageLoadState, + StoredClipsSnapshot, } from '../../shared/types'; // Import utility modules @@ -58,6 +61,10 @@ import { saveImage, deleteImage, deleteAllImages } from './image-store'; const CURRENT_STORAGE_VERSION = 1; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + const DEFAULT_TEMPLATES_DATA: TemplatesData = { templates: [], searchTerms: [], @@ -72,6 +79,8 @@ class SecureStorage { private metaPath: string; private isInitialized = false; private isBackgroundLoadComplete = false; + // Set when the stored history could not be read; saves stay refused so it is not overwritten + private loadError: StorageLoadError | null = null; // Domain-specific data stores private settings: UserSettings = DEFAULT_SETTINGS; @@ -123,6 +132,10 @@ class SecureStorage { // Check if safeStorage is available if (!isEncryptionAvailable()) { console.warn('Encryption not available, keeping default data'); + this.loadError = { + message: 'Encryption is not available on this system', + recoverable: true, + }; this.isBackgroundLoadComplete = true; this.onBackgroundLoadComplete?.(); return; @@ -139,7 +152,10 @@ class SecureStorage { this.onBackgroundLoadComplete?.(); } catch (error) { console.error('Failed to load data in background:', error); - // Keep using default data + // Keep using default data, but refuse to write over the unread history. Nothing that + // lands here (a missing directory, a failed migration) is known to repeat on the next + // launch, so a restart is worth suggesting. + this.loadError = { message: errorMessage(error), recoverable: true }; this.isBackgroundLoadComplete = true; this.onBackgroundLoadComplete?.(); } @@ -168,10 +184,19 @@ class SecureStorage { // Validate clips through migrateData const validated = migrateData({ clips: loadedClips }); this.clips = validated.clips; + } else { + // The file decrypted and parsed, but not to the shape we write. Treat it as unread + // rather than as an empty history, so the guard below keeps the next save from + // replacing it. + throw new Error('Stored clips are not a list'); } } catch (error) { if ((error as Error).message !== 'FILE_NOT_FOUND') { + // A clips file exists but cannot be read (for example the keystore changed). + // Reporting the history as empty here would let the renderer save over it, and a + // decrypt failure repeats on every launch, so a restart will not clear it. console.error('Failed to load clips:', error); + this.loadError = { message: errorMessage(error), recoverable: false }; } } @@ -252,10 +277,6 @@ class SecureStorage { * always reaches disk (see SaveQueue). */ private async saveDomain(key: string, data: unknown, filePath: string): Promise { - if (!this.isInitialized) { - throw new Error('Storage not initialized'); - } - await this.saveQueue.run(key, () => saveEncryptedJson(data, filePath)); } @@ -299,6 +320,24 @@ class SecureStorage { await saveJsonFile(this.meta, this.metaPath); } + /** + * Whether the background load has finished, and whether the stored history was readable. + */ + getLoadState(): StorageLoadState { + return { + complete: this.isBackgroundLoadComplete, + error: this.loadError, + }; + } + + /** + * True once the stored history has been read successfully, so a save cannot replace it + * with the empty defaults that stand in for it until then. + */ + private get canSaveClips(): boolean { + return this.isBackgroundLoadComplete && this.loadError === null; + } + /** * Set callback to be called when background loading completes */ @@ -323,6 +362,17 @@ class SecureStorage { return [...this.clips]; } + /** + * The clips and the load state read in the same step, so a caller is told whether what it + * holds is the stored history or the placeholder served until that has loaded. + */ + async getClipsSnapshot(): Promise { + if (!this.isInitialized) { + await this.initialize(); + } + return { loadState: this.getLoadState(), clips: [...this.clips] }; + } + /** * Save clips to storage. * Cleans up orphaned image files for deleted image clips. @@ -332,6 +382,16 @@ class SecureStorage { await this.initialize(); } + // Until the history has loaded successfully, this.clips is a placeholder; replacing it + // would overwrite the real history and delete every image it references. + if (!this.canSaveClips) { + throw new Error( + this.loadError === null + ? 'Storage has not finished loading' + : `Storage could not be loaded: ${this.loadError.message}` + ); + } + // Collect image IDs from old clips for cleanup comparison const oldImageIds = new Set( this.clips.filter((c) => c.clip.imageId).map((c) => c.clip.imageId!) @@ -659,7 +719,8 @@ class SecureStorage { } this.templatesData = { ...this.templatesData, groupColours: { ...groupColours } }; await this.saveTemplatesData(); - return { ...(this.templatesData.groupColours ?? {}) }; + // The save may have pruned the map, so read it back rather than echo the argument + return { ...this.templatesData.groupColours }; } /** diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index fa1a254b..216165f1 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -10,6 +10,7 @@ import type { Template, UpdateState, SettingsApplyResult, + StoredClipsSnapshot, StorageStats, UserSettings, } from '../shared/types'; @@ -48,7 +49,7 @@ declare global { hotkeysGetDefaults: () => Promise; // Storage APIs onStorageReady: (callback: () => void) => () => void; - storageGetClips: () => Promise; + storageGetClipsSnapshot: () => Promise; storageSaveClips: (clips: any[], lockedIndices: Record) => Promise; storageGetSettings: () => Promise; storageSaveSettings: (settings: Partial) => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index 8e2fdc4b..4e6f258d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import type { UserSettings, HotkeySettings, StoredClip, + StoredClipsSnapshot, Template, SearchTerm, QuickTool, @@ -85,7 +86,8 @@ const api = { // Storage APIs onStorageReady: (callback: () => void) => subscribe('storage-ready', () => callback()), - storageGetClips: () => electronAPI.ipcRenderer.invoke('storage-get-clips'), + storageGetClipsSnapshot: (): Promise => + electronAPI.ipcRenderer.invoke('storage-get-clips-snapshot'), storageSaveClips: (clips: StoredClip[], lockedIndices: Record) => electronAPI.ipcRenderer.invoke('storage-save-clips', clips, lockedIndices), storageGetSettings: () => electronAPI.ipcRenderer.invoke('storage-get-settings'), diff --git a/src/renderer/src/components/clips/Clips.module.css b/src/renderer/src/components/clips/Clips.module.css index 359ff06c..162bf742 100644 --- a/src/renderer/src/components/clips/Clips.module.css +++ b/src/renderer/src/components/clips/Clips.module.css @@ -1,6 +1,42 @@ +.clips { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} + +.loadFailed { + flex: none; + margin: 8px 10px 0; + padding: 8px 12px; + border: 1px solid var(--danger-bd); + border-radius: 8px; + background: var(--danger-bg); + font-size: 12.5px; + color: var(--text); +} + +.loadFailedTitle { + color: var(--err-text); + font-weight: 600; +} + +.loadFailedDetail { + list-style: none; + padding: 0; + margin: 3px 0 0; +} + +.loadFailedError { + font-family: var(--mono); + font-size: 11.5px; + color: var(--muted); +} + .clipsContainer { width: 100%; - height: 100%; + flex: 1; + min-height: 0; min-width: 0; overflow-y: auto; background: var(--app-bg); diff --git a/src/renderer/src/components/clips/Clips.test.tsx b/src/renderer/src/components/clips/Clips.test.tsx index 851f462e..9e369f69 100644 --- a/src/renderer/src/components/clips/Clips.test.tsx +++ b/src/renderer/src/components/clips/Clips.test.tsx @@ -18,6 +18,7 @@ const { virtual, state } = vi.hoisted(() => ({ isSearchVisible: false, setIsSearchVisible: vi.fn(), focusRequest: null as { index: number; seq: number } | null, + loadError: null as { message: string; recoverable: boolean } | null, }, })); @@ -63,6 +64,7 @@ vi.mock('../../providers/clips', () => ({ clipCopyId: null, isSearchVisible: state.isSearchVisible, setIsSearchVisible: state.setIsSearchVisible, + loadError: state.loadError, }), useQuickLook: () => ({ focusRequest: state.focusRequest }), })); @@ -91,6 +93,7 @@ beforeEach(() => { state.pinnedOnly = false; state.isSearchVisible = false; state.focusRequest = null; + state.loadError = null; }); afterEach(() => { @@ -200,3 +203,38 @@ describe('Clips empty states', () => { expect(screen.getByText('No clips contain a pinned value')).toBeInTheDocument(); }); }); + +describe('Clips load failure', () => { + it('shows a banner with the reason for as long as the history is unreadable', () => { + state.loadError = { + message: 'Error while decrypting the ciphertext provided to safeStorage.', + recoverable: false, + }; + const { rerender } = render(); + const banner = screen.getByTestId('load-failed-banner'); + expect(banner).toHaveTextContent(/clip history/i); + expect(banner).toHaveTextContent(/Saving is paused/); + expect(banner).toHaveTextContent(/decrypting/); + expect(screen.getByTestId('row-0')).toBeInTheDocument(); + + state.loadError = null; + rerender(); + expect(screen.queryByTestId('load-failed-banner')).toBeNull(); + }); + + it('only suggests a restart when the main process says the failure may clear', () => { + state.loadError = { message: 'Error while decrypting', recoverable: false }; + const { rerender } = render(); + let banner = screen.getByTestId('load-failed-banner'); + expect(banner).toHaveTextContent(/can't be read with this computer's keystore/); + expect(banner).not.toHaveTextContent(/Restart Clipless/); + expect(banner).toHaveTextContent(/clear all data in Settings and restart/); + + state.loadError = { message: 'Encryption is not available on this system', recoverable: true }; + rerender(); + banner = screen.getByTestId('load-failed-banner'); + expect(banner).toHaveTextContent(/Restart Clipless to try again/); + expect(banner).not.toHaveTextContent(/keystore/); + expect(banner).not.toHaveTextContent(/clear all data/); + }); +}); diff --git a/src/renderer/src/components/clips/Clips.tsx b/src/renderer/src/components/clips/Clips.tsx index 32cc221d..a0060b51 100644 --- a/src/renderer/src/components/clips/Clips.tsx +++ b/src/renderer/src/components/clips/Clips.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useClipsData, useClipsMeta, useQuickLook } from '../../providers/clips'; +import type { ClipsLoadError } from '../../providers/clips/types'; import { Clip } from './clip'; import { SEARCH_INPUT_ID } from '../SearchBar'; import styles from './Clips.module.css'; @@ -16,7 +17,7 @@ const isTypingTarget = (target: EventTarget | null): boolean => */ export function Clips(): React.JSX.Element { const { filteredClips, searchTerm, isFiltering, pinnedOnly } = useClipsData(); - const { clipCopyId, isSearchVisible, setIsSearchVisible } = useClipsMeta(); + const { clipCopyId, isSearchVisible, setIsSearchVisible, loadError } = useClipsMeta(); const { focusRequest } = useQuickLook(); const scrollContainerRef = useRef(null); @@ -82,47 +83,79 @@ export function Clips(): React.JSX.Element { : `No clips match "${searchTerm.trim()}"`; return ( -
- {showEmpty ? ( -
{emptyMessage}
- ) : ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const { clip, originalIndex } = items[virtualRow.index]; - return ( -
- -
- ); - })} -
- )} +
+ {loadError !== null && } +
+ {showEmpty ? ( +
{emptyMessage}
+ ) : ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const { clip, originalIndex } = items[virtualRow.index]; + return ( +
+ +
+ ); + })} +
+ )} +
+
+ ); +} + +const LOAD_FAILED_TITLE = "Couldn't load your clip history"; +const LOAD_FAILED_PAUSED = 'Saving is paused so the stored history is not overwritten.'; +const LOAD_FAILED_RETRY = 'Restart Clipless to try again.'; +const LOAD_FAILED_UNREADABLE = + "The stored history can't be read with this computer's keystore, so it won't load."; +const LOAD_FAILED_RESET = 'To use Clipless again, clear all data in Settings and restart.'; + +/** + * Shown above the list for as long as the stored history is unreadable. It stays because + * saving is off for the whole session, and a hidden window would miss a passing toast. + * A restart is only suggested when the main process says the failure may clear on the + * next launch; a key mismatch repeats on every launch, so the banner says so instead and + * points at the one way out: clearing the stored data removes the unreadable file, and a + * restart is needed because saving stays off for the rest of this session. + */ +function LoadFailedBanner({ error }: { error: ClipsLoadError }): React.JSX.Element { + return ( +
+
{LOAD_FAILED_TITLE}
+
    +
  • {LOAD_FAILED_PAUSED}
  • +
  • {error.recoverable ? LOAD_FAILED_RETRY : LOAD_FAILED_UNREADABLE}
  • + {!error.recoverable &&
  • {LOAD_FAILED_RESET}
  • } +
  • {error.message}
  • +
); } diff --git a/src/renderer/src/components/settings/general/ClearAll.tsx b/src/renderer/src/components/settings/general/ClearAll.tsx index 9c5f54e7..c70ca248 100644 --- a/src/renderer/src/components/settings/general/ClearAll.tsx +++ b/src/renderer/src/components/settings/general/ClearAll.tsx @@ -7,7 +7,7 @@ import { HOTKEY_ROWS } from '../hotkeys/conflicts'; import { useStats } from './stats'; import { useSettingsStore } from './useSetting'; import { formatBytes } from './backup'; -import { errorText } from '../shell/errorText'; +import { errorText } from '../../../utils/errorText'; import w from '../shell/widgets.module.css'; interface ClearAllProps { diff --git a/src/renderer/src/components/settings/general/General.tsx b/src/renderer/src/components/settings/general/General.tsx index 27432c3e..88d93299 100644 --- a/src/renderer/src/components/settings/general/General.tsx +++ b/src/renderer/src/components/settings/general/General.tsx @@ -10,7 +10,7 @@ import { About } from './About'; import { ClearAll } from './ClearAll'; import { ImportPreview } from './ImportPreview'; import { backupFileName, downloadText, formatBytes } from './backup'; -import { errorText } from '../shell/errorText'; +import { errorText } from '../../../utils/errorText'; import w from '../shell/widgets.module.css'; import styles from './General.module.css'; diff --git a/src/renderer/src/components/settings/general/ImportPreview.tsx b/src/renderer/src/components/settings/general/ImportPreview.tsx index 511d9225..61d8f0ee 100644 --- a/src/renderer/src/components/settings/general/ImportPreview.tsx +++ b/src/renderer/src/components/settings/general/ImportPreview.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import { ConfirmDialog } from '../../ConfirmDialog'; import { useToast } from '../../Toast'; import { formatBytes, readFileText, summarizeBackup, type BackupSummary } from './backup'; -import { errorText } from '../shell/errorText'; +import { errorText } from '../../../utils/errorText'; import w from '../shell/widgets.module.css'; import styles from './General.module.css'; diff --git a/src/renderer/src/components/settings/general/SettingsProvider.tsx b/src/renderer/src/components/settings/general/SettingsProvider.tsx index 7f846287..416cf1c7 100644 --- a/src/renderer/src/components/settings/general/SettingsProvider.tsx +++ b/src/renderer/src/components/settings/general/SettingsProvider.tsx @@ -9,7 +9,7 @@ import { type RowStatus, type SettingsStore, } from './useSetting'; -import { errorText } from '../shell/errorText'; +import { errorText } from '../../../utils/errorText'; /** * One load of the settings for the whole window (spec 15.2) and the one write path. diff --git a/src/renderer/src/components/settings/tools/ExportImport.tsx b/src/renderer/src/components/settings/tools/ExportImport.tsx index 45caff78..97f2a6f2 100644 --- a/src/renderer/src/components/settings/tools/ExportImport.tsx +++ b/src/renderer/src/components/settings/tools/ExportImport.tsx @@ -5,7 +5,7 @@ import { patternGroups } from '../../../../../shared/readiness'; import { ConfirmDialog } from '../../ConfirmDialog'; import { useToast } from '../../Toast'; import { downloadText, readFileText } from '../general/backup'; -import { errorText } from '../shell/errorText'; +import { errorText } from '../../../utils/errorText'; import { GroupPill } from './GroupPill'; import { useToolsData } from './useToolsData'; import w from '../shell/widgets.module.css'; diff --git a/src/renderer/src/components/settings/tools/Tools.tsx b/src/renderer/src/components/settings/tools/Tools.tsx index b6e2b4d2..e01e662d 100644 --- a/src/renderer/src/components/settings/tools/Tools.tsx +++ b/src/renderer/src/components/settings/tools/Tools.tsx @@ -18,7 +18,7 @@ import { TemplateEditor, type TemplateDraft } from './TemplateEditor'; import { EditorHostContext, type EditorHost } from './editorHost'; import type { FixActions } from './Fixes'; import { KIND_LABEL, dependents, itemOf, listDot, type ToolsItem, type ToolsKind } from './model'; -import { errorText } from '../shell/errorText'; +import { errorText } from '../../../utils/errorText'; import shell from '../shell/Shell.module.css'; import styles from './Tools.module.css'; diff --git a/src/renderer/src/components/settings/tools/harness.tsx b/src/renderer/src/components/settings/tools/harness.tsx index 6039d8a2..430ff73e 100644 --- a/src/renderer/src/components/settings/tools/harness.tsx +++ b/src/renderer/src/components/settings/tools/harness.tsx @@ -96,13 +96,16 @@ export function installConfig( ...settings, }); a.settingsChanged.mockResolvedValue({ ok: true, failed: [] }); - a.storageGetClips.mockResolvedValue([ - { - clip: { id: 'c1', type: 'text', content: 'newest clip text with 10.0.0.1' }, - isLocked: false, - timestamp: 1, - }, - ]); + a.storageGetClipsSnapshot.mockResolvedValue({ + loadState: { complete: true, error: null }, + clips: [ + { + clip: { id: 'c1', type: 'text', content: 'newest clip text with 10.0.0.1' }, + isLocked: false, + timestamp: 1, + }, + ], + }); a.searchTermsGetAll.mockImplementation(async () => [...config.terms]); a.quickToolsGetAll.mockImplementation(async () => [...config.tools]); a.templatesGetAll.mockImplementation(async () => [...config.templates]); diff --git a/src/renderer/src/components/settings/tools/useToolsData.test.tsx b/src/renderer/src/components/settings/tools/useToolsData.test.tsx index 5b473425..417db254 100644 --- a/src/renderer/src/components/settings/tools/useToolsData.test.tsx +++ b/src/renderer/src/components/settings/tools/useToolsData.test.tsx @@ -95,7 +95,7 @@ describe('useToolsData', () => { it('copes with no clips at all, and a blur with the saved text writes nothing', async () => { installConfig(defaultConfig(), { toolsSampleText: 'same' }); - api().storageGetClips.mockResolvedValue(undefined); + api().storageGetClipsSnapshot.mockResolvedValue(undefined); await renderTools(); const box = screen.getByTestId('sample-text'); fireEvent.change(box, { target: { value: 'other' } }); @@ -108,7 +108,7 @@ describe('useToolsData', () => { it('reset with no saved text is a no-op, and a clip read failure is logged', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}); installConfig(defaultConfig()); - api().storageGetClips.mockRejectedValue(new Error('no clips')); + api().storageGetClipsSnapshot.mockRejectedValue(new Error('no clips')); await renderTools(); expect(error).toHaveBeenCalledWith('Failed to read the newest clip:', expect.any(Error)); expect(screen.getByTestId('sample-text')).toHaveValue(''); @@ -123,7 +123,7 @@ describe('useToolsData', () => { it('ignores the clips when unmounted before they arrive', async () => { installConfig(defaultConfig()); let resolve: (v: unknown) => void = () => {}; - api().storageGetClips.mockReturnValue(new Promise((r) => (resolve = r))); + api().storageGetClipsSnapshot.mockReturnValue(new Promise((r) => (resolve = r))); const { unmount } = await renderTools(); unmount(); await act(async () => resolve([])); diff --git a/src/renderer/src/components/settings/tools/useToolsData.ts b/src/renderer/src/components/settings/tools/useToolsData.ts index 6a8a6bae..9c1d2df5 100644 --- a/src/renderer/src/components/settings/tools/useToolsData.ts +++ b/src/renderer/src/components/settings/tools/useToolsData.ts @@ -58,9 +58,9 @@ export function useToolsDataValue(): ToolsData { useEffect(() => { let live = true; window.api - .storageGetClips() - .then((clips) => { - if (live) setClip(newestClipText(clips ?? [])); + .storageGetClipsSnapshot() + .then((snapshot) => { + if (live) setClip(newestClipText(snapshot?.clips ?? [])); }) .catch((error) => console.error('Failed to read the newest clip:', error)); return () => { diff --git a/src/renderer/src/providers/clips/README.md b/src/renderer/src/providers/clips/README.md index 3130ce94..fdc53c95 100644 --- a/src/renderer/src/providers/clips/README.md +++ b/src/renderer/src/providers/clips/README.md @@ -19,6 +19,7 @@ This directory contains the modular implementation of the clips provider, which - Hook for managing storage operations (`useClipsStorage`) - Handles loading clips and settings from storage on mount +- Saving stays disabled until the main process reports its background load complete and successful, so the empty placeholder served during that load (or after a failed decrypt) is never written back over the stored history; a failed load is reported through `loadError`, which the list shows as a persistent banner - Manages saving clips and settings with debouncing - Listens for settings updates from other windows diff --git a/src/renderer/src/providers/clips/index.test.tsx b/src/renderer/src/providers/clips/index.test.tsx new file mode 100644 index 00000000..ca16d934 --- /dev/null +++ b/src/renderer/src/providers/clips/index.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, cleanup } from '@testing-library/react'; +import type { StoredClipsSnapshot } from '../../../../shared/types'; +import { ToastProvider } from '../../components/Toast'; +import { LanguageDetectionProvider } from '../languageDetection'; +import { ScanIndexProvider } from '../scan'; +import { ClipsProvider, useClipsMeta } from './index'; + +const DECRYPT_ERROR = 'Error while decrypting the ciphertext provided to safeStorage.'; + +const failed = (): StoredClipsSnapshot => ({ + loadState: { complete: true, error: { message: DECRYPT_ERROR, recoverable: false } }, + clips: [], +}); +const loaded = (): StoredClipsSnapshot => ({ + loadState: { complete: true, error: null }, + clips: [{ clip: { id: 'a', type: 'text', content: 'back' }, isLocked: false, timestamp: 1 }], +}); + +function Probe() { + const { loadError } = useClipsMeta(); + return ( +
+ {loadError === null ? 'none' : `${loadError.recoverable}:${loadError.message}`} +
+ ); +} + +const mount = () => + render( + + + + + + + + + + ); + +const api = () => window.api as unknown as Record>; + +let storageReady: (() => void) | null = null; + +beforeEach(() => { + storageReady = null; + api().storageGetClipsSnapshot.mockReset().mockResolvedValue(failed()); + api() + .onStorageReady.mockReset() + .mockImplementation((cb: () => void) => { + storageReady = cb; + return () => { + storageReady = null; + }; + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('ClipsProvider load error', () => { + it('hands the storage load error to the meta context and clears it once the history loads', async () => { + mount(); + + expect(await screen.findByText(`false:${DECRYPT_ERROR}`)).toBeInTheDocument(); + + api().storageGetClipsSnapshot.mockResolvedValue(loaded()); + await act(async () => { + storageReady?.(); + }); + + expect(await screen.findByText('none')).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/src/providers/clips/index.tsx b/src/renderer/src/providers/clips/index.tsx index 31366aae..f2371fed 100644 --- a/src/renderer/src/providers/clips/index.tsx +++ b/src/renderer/src/providers/clips/index.tsx @@ -151,7 +151,7 @@ export function ClipsProvider({ children }: { children: React.ReactNode }) { const toast = useToast(); // Use storage hook for loading/saving data - useClipsStorage( + const { loadError } = useClipsStorage( clips, lockedClips, maxClips, @@ -413,8 +413,9 @@ export function ClipsProvider({ children }: { children: React.ReactNode }) { isSearchVisible, setIsSearchVisible, hideSearch, + loadError, }), - [clipCopyId, maxClips, isSearchVisible, hideSearch] + [clipCopyId, maxClips, isSearchVisible, hideSearch, loadError] ); const pinsValue = useMemo( diff --git a/src/renderer/src/providers/clips/storage.test.tsx b/src/renderer/src/providers/clips/storage.test.tsx new file mode 100644 index 00000000..259e13ec --- /dev/null +++ b/src/renderer/src/providers/clips/storage.test.tsx @@ -0,0 +1,324 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup } from '@testing-library/react'; +import { useState } from 'react'; +import type { StoredClip, StoredClipsSnapshot } from '../../../../shared/types'; +import { DEFAULT_MAX_CLIPS } from '../constants'; +import { ClipItem, ClipsLoadError } from './types'; +import { updateClipsLength } from './utils'; +import { useClipsStorage } from './storage'; + +const stored = (id: string, content: string, isLocked = false): StoredClip => ({ + clip: { id, type: 'text', content }, + isLocked, + timestamp: 1, +}); + +const DECRYPT_ERROR = + 'Error while decrypting the ciphertext provided to safeStorage.decryptString.'; + +const notLoaded = (): StoredClipsSnapshot => ({ + loadState: { complete: false, error: null }, + clips: [], +}); +const loaded = (clips: StoredClip[] = []): StoredClipsSnapshot => ({ + loadState: { complete: true, error: null }, + clips, +}); +const failed = (): StoredClipsSnapshot => ({ + loadState: { complete: true, error: { message: DECRYPT_ERROR, recoverable: false } }, + clips: [], +}); + +let storageReady: (() => void) | null = null; +let settingsUpdated: ((settings: unknown) => void) | null = null; +let observed: { + clips: ClipItem[]; + lockedClips: Record; + maxClips: number; + isInitiallyLoading: boolean; + loadError: ClipsLoadError | null; +} = { + clips: [], + lockedClips: {}, + maxClips: DEFAULT_MAX_CLIPS, + isInitiallyLoading: true, + loadError: null, +}; + +function Probe() { + const [clips, setClips] = useState(updateClipsLength([], DEFAULT_MAX_CLIPS)); + const [lockedClips, setLockedClips] = useState>({}); + const [maxClips, setMaxClips] = useState(DEFAULT_MAX_CLIPS); + const [isInitiallyLoading, setIsInitiallyLoading] = useState(true); + const { loadError } = useClipsStorage( + clips, + lockedClips, + maxClips, + isInitiallyLoading, + setClips, + setLockedClips, + setMaxClips, + setIsInitiallyLoading + ); + observed = { clips, lockedClips, maxClips, isInitiallyLoading, loadError }; + return null; +} + +const mount = () => render(); + +const flush = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const settle = async () => { + await flush(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1500); + }); +}; + +const api = () => window.api as unknown as Record>; + +beforeEach(() => { + vi.useFakeTimers(); + storageReady = null; + api().storageGetClipsSnapshot.mockReset().mockResolvedValue(loaded()); + api().storageSaveClips.mockReset().mockResolvedValue(true); + api().storageSaveSettings.mockReset().mockResolvedValue(undefined); + api().storageGetSettings.mockReset().mockResolvedValue({ maxClips: DEFAULT_MAX_CLIPS }); + settingsUpdated = null; + api() + .onSettingsUpdated.mockReset() + .mockImplementation((cb: (settings: unknown) => void) => { + settingsUpdated = cb; + return () => { + settingsUpdated = null; + }; + }); + api() + .onStorageReady.mockReset() + .mockImplementation((cb: () => void) => { + storageReady = cb; + return () => { + storageReady = null; + }; + }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('useClipsStorage load guard', () => { + it('never saves the blank list when clips read as empty before the background load completes', async () => { + api().storageGetClipsSnapshot.mockResolvedValueOnce(notLoaded()); + mount(); + await settle(); + + expect(api().storageSaveClips).not.toHaveBeenCalled(); + expect(observed.isInitiallyLoading).toBe(true); + + // The background decrypt finishes and the real history becomes available + api().storageGetClipsSnapshot.mockResolvedValue( + loaded([stored('a', 'first'), stored('b', 'second', true)]) + ); + await act(async () => { + storageReady?.(); + }); + await settle(); + + expect(observed.isInitiallyLoading).toBe(false); + expect(observed.clips.slice(0, 2).map((c) => c.content)).toEqual(['first', 'second']); + // The only save, if any, carries the loaded history rather than the blank seed + for (const [saved] of api().storageSaveClips.mock.calls) { + expect(saved.slice(0, 2).map((c: ClipItem) => c.content)).toEqual(['first', 'second']); + } + expect(observed.loadError).toBeNull(); + }); + + it('enables saving once the load is reported complete', async () => { + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'kept')])); + mount(); + await settle(); + + expect(observed.isInitiallyLoading).toBe(false); + expect(api().storageSaveClips).toHaveBeenCalledTimes(1); + expect(api().storageSaveClips.mock.calls[0][0][0].content).toBe('kept'); + }); + + it('keeps saves disabled and reports the error when the background load failed', async () => { + api().storageGetClipsSnapshot.mockResolvedValue(failed()); + mount(); + await settle(); + await act(async () => { + storageReady?.(); + }); + await settle(); + + expect(api().storageSaveClips).not.toHaveBeenCalled(); + expect(observed.isInitiallyLoading).toBe(true); + expect(observed.loadError).toEqual({ message: DECRYPT_ERROR, recoverable: false }); + }); + + it('keeps saves disabled and reports the error when reading storage throws', async () => { + api().storageGetClipsSnapshot.mockRejectedValue(new Error('ipc down')); + mount(); + await settle(); + + expect(api().storageSaveClips).not.toHaveBeenCalled(); + expect(observed.isInitiallyLoading).toBe(true); + expect(observed.loadError).toEqual({ message: 'ipc down', recoverable: true }); + }); + + it('clears the error once a later load succeeds', async () => { + api().storageGetClipsSnapshot.mockResolvedValueOnce(failed()); + mount(); + await settle(); + expect(observed.loadError).toEqual({ message: DECRYPT_ERROR, recoverable: false }); + + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'back')])); + await act(async () => { + storageReady?.(); + }); + await settle(); + + expect(observed.loadError).toBeNull(); + expect(observed.isInitiallyLoading).toBe(false); + expect(observed.clips[0].content).toBe('back'); + }); +}); + +describe('useClipsStorage without the preload api', () => { + it('finishes loading at once and never tries to save', async () => { + const preload = window.api; + (window as unknown as { api: unknown }).api = undefined; + try { + mount(); + await settle(); + expect(observed.isInitiallyLoading).toBe(false); + expect(observed.loadError).toBeNull(); + } finally { + window.api = preload; + } + + expect(preload.storageSaveClips).not.toHaveBeenCalled(); + expect(preload.storageSaveSettings).not.toHaveBeenCalled(); + }); +}); + +describe('useClipsStorage stored data', () => { + it('keeps the default limit when the settings carry none', async () => { + api().storageGetSettings.mockResolvedValue({}); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'one')])); + mount(); + await settle(); + + expect(observed.maxClips).toBe(DEFAULT_MAX_CLIPS); + expect(observed.clips).toHaveLength(DEFAULT_MAX_CLIPS); + expect(observed.clips[0].content).toBe('one'); + }); + + it('copes with settings that are missing altogether', async () => { + api().storageGetSettings.mockResolvedValue(null); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'one')])); + mount(); + await settle(); + + expect(observed.clips[0].content).toBe('one'); + expect(observed.isInitiallyLoading).toBe(false); + }); + + it('applies the stored limit and restores locks for every clip but the newest', async () => { + api().storageGetSettings.mockResolvedValue({ maxClips: 5 }); + api().storageGetClipsSnapshot.mockResolvedValue( + loaded([stored('a', 'one', true), stored('b', 'two', true), stored('c', 'three')]) + ); + mount(); + await settle(); + + expect(observed.maxClips).toBe(5); + expect(observed.clips).toHaveLength(5); + expect(observed.clips.slice(0, 3).map((c) => c.content)).toEqual(['one', 'two', 'three']); + expect(observed.lockedClips).toEqual({ 1: true }); + }); + + it('skips stored entries without usable content', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + api().storageGetClipsSnapshot.mockResolvedValue( + loaded([ + stored('a', ' '), + stored('b', ''), + { clip: undefined, isLocked: false, timestamp: 1 } as unknown as StoredClip, + ]) + ); + mount(); + await settle(); + + expect(observed.clips.every((c) => c.content === '')).toBe(true); + expect(observed.isInitiallyLoading).toBe(false); + expect(log).not.toHaveBeenCalledWith(expect.stringMatching(/Successfully loaded/)); + expect(log).not.toHaveBeenCalledWith('No stored clips found'); + }); + + it('treats a snapshot without a clip list as an empty history', async () => { + api().storageGetClipsSnapshot.mockResolvedValue({ + loadState: { complete: true, error: null }, + clips: undefined as unknown as StoredClip[], + }); + mount(); + await settle(); + + expect(observed.isInitiallyLoading).toBe(false); + expect(observed.loadError).toBeNull(); + }); +}); + +describe('useClipsStorage settings updates from another window', () => { + it('applies a lower limit by dropping the oldest unlocked clips', async () => { + api().storageGetClipsSnapshot.mockResolvedValue( + loaded([stored('a', 'one'), stored('b', 'two', true), stored('c', 'three')]) + ); + mount(); + await settle(); + expect(observed.lockedClips).toEqual({ 1: true }); + + await act(async () => { + settingsUpdated?.({ maxClips: 2 }); + }); + + expect(observed.maxClips).toBe(2); + expect(observed.clips.map((c) => c.content)).toEqual(['one', 'two']); + expect(observed.lockedClips).toEqual({ 1: true }); + }); + + it('ignores an update that carries no numeric limit', async () => { + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'one')])); + mount(); + await settle(); + + await act(async () => { + settingsUpdated?.({ theme: 'dark' }); + settingsUpdated?.(null); + }); + + expect(observed.maxClips).toBe(DEFAULT_MAX_CLIPS); + expect(observed.clips).toHaveLength(DEFAULT_MAX_CLIPS); + }); +}); + +describe('useClipsStorage save failures', () => { + it('logs a refused clip save and a failed settings save', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + api().storageSaveClips.mockRejectedValue(new Error('Storage could not be loaded')); + api().storageSaveSettings.mockRejectedValue(new Error('no disk')); + mount(); + await settle(); + + expect(error).toHaveBeenCalledWith('Failed to save clips to storage:', expect.any(Error)); + expect(error).toHaveBeenCalledWith('Failed to save settings to storage:', expect.any(Error)); + }); +}); diff --git a/src/renderer/src/providers/clips/storage.ts b/src/renderer/src/providers/clips/storage.ts index f0213404..e6af0a29 100644 --- a/src/renderer/src/providers/clips/storage.ts +++ b/src/renderer/src/providers/clips/storage.ts @@ -1,11 +1,15 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { ClipItem } from './types'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ClipItem, ClipsLoadError } from './types'; import { DEFAULT_MAX_CLIPS } from '../constants'; import { shrinkClips, updateClipsLength } from './utils'; +import { errorText } from '../../utils/errorText'; import { UserSettings, StoredClip } from '../../../../shared/types'; /** - * Hook for managing storage operations for clips and settings + * Hook for managing storage operations for clips and settings. + * + * Returns `loadError`, the reason the stored history could not be read, or null. While it + * is set the list shows a banner and saving stays off for the rest of the session. */ export const useClipsStorage = ( clips: ClipItem[], @@ -16,8 +20,13 @@ export const useClipsStorage = ( setLockedClips: React.Dispatch>>, setMaxClips: React.Dispatch>, setIsInitiallyLoading: React.Dispatch> -) => { - // Shared function to load all stored data (clips + settings) +): { loadError: ClipsLoadError | null } => { + const [loadError, setLoadError] = useState(null); + + // Shared function to load all stored data (clips + settings). + // Saving stays disabled (isInitiallyLoading) until this has applied a successfully loaded + // history: the main process serves empty defaults while its background load runs, and a + // save of those would overwrite the real history and delete the images it references. const loadStoredData = useCallback(async () => { if (!window.api) { setIsInitiallyLoading(false); @@ -32,8 +41,23 @@ export const useClipsStorage = ( } // Note: codeDetectionEnabled is now handled by LanguageDetectionProvider - // Load clips from storage - const storedClips = await window.api.storageGetClips(); + // The clips arrive with the load state they were read under, so the placeholder + // served during the background load cannot be mistaken for an empty history + const { loadState, clips: storedClips } = await window.api.storageGetClipsSnapshot(); + + if (!loadState.complete) { + // The storage-ready event triggers another load once the history is available + console.log('Storage still loading, waiting for storage-ready'); + return; + } + + if (loadState.error !== null) { + // The history is unreadable, so the clips returned are not it: leave the window + // as it is and leave saving disabled rather than write blank state over the file. + console.error('Stored clip history could not be loaded:', loadState.error.message); + setLoadError(loadState.error); + return; + } if (storedClips && storedClips.length > 0) { const loadedClips: ClipItem[] = []; @@ -52,11 +76,6 @@ export const useClipsStorage = ( } }); - // Ensure the first clip (index 0) is never locked - if (loadedLocks[0]) { - delete loadedLocks[0]; - } - // Always update clips state, even if empty, to ensure proper initialization const currentMaxClips = settings?.maxClips || DEFAULT_MAX_CLIPS; const paddedClips = updateClipsLength(loadedClips, currentMaxClips); @@ -69,10 +88,14 @@ export const useClipsStorage = ( } else { console.log('No stored clips found'); } + + // Only a load that got this far may enable saving + setLoadError(null); + setIsInitiallyLoading(false); } catch (error) { console.error('Failed to load data from storage:', error); - } finally { - setIsInitiallyLoading(false); + // The main process could not be reached or threw; a restart may well clear that + setLoadError({ message: errorText(error), recoverable: true }); } }, [setClips, setLockedClips, setMaxClips, setIsInitiallyLoading]); @@ -157,4 +180,6 @@ export const useClipsStorage = ( const timeoutId = setTimeout(saveSettingsToStorage, 500); return () => clearTimeout(timeoutId); }, [maxClips, isInitiallyLoading]); + + return { loadError }; }; diff --git a/src/renderer/src/providers/clips/types.ts b/src/renderer/src/providers/clips/types.ts index f85a49b0..ea8c2640 100644 --- a/src/renderer/src/providers/clips/types.ts +++ b/src/renderer/src/providers/clips/types.ts @@ -1,5 +1,5 @@ import React from 'react'; -import type { ClipItem } from '../../../../shared/types'; +import type { ClipItem, StorageLoadError } from '../../../../shared/types'; import type { PinsByGroup } from '../../../../shared/tools'; import type { PinMap } from './pins'; import type { QuickLookPosition, QuickLookState, QuickLookView, VisibleClip } from './quickLook'; @@ -49,8 +49,17 @@ export type ClipsMetaContextType = { setIsSearchVisible: React.Dispatch>; /** Close the bar and clear the filter with it, so an invisible filter cannot persist */ hideSearch: () => void; + /** Why the stored history could not be read, or null; while set, saving stays off */ + loadError: ClipsLoadError | null; }; +/** + * A failed history load as the list reports it. `recoverable` is true when a restart may + * read the history (the keystore was unavailable or the main process could not be reached) + * and false when the file cannot be read under this keystore, so a restart would not help. + */ +export type ClipsLoadError = StorageLoadError; + /** * Pins: memory only, keyed group|value (spec 17.1) */ diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts index 7d20a57c..6b8f5843 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -85,7 +85,10 @@ const createMockApi = () => ({ startClipboardMonitoring: vi.fn().mockResolvedValue(true), stopClipboardMonitoring: vi.fn().mockResolvedValue(true), getCurrentClipboardData: vi.fn().mockResolvedValue(null), - storageGetClips: vi.fn().mockResolvedValue([]), + storageGetClipsSnapshot: vi.fn().mockResolvedValue({ + loadState: { complete: true, error: null }, + clips: [], + }), storageSaveClips: vi.fn().mockResolvedValue(true), setClipboardText: vi.fn().mockResolvedValue(undefined), setClipboardHTML: vi.fn().mockResolvedValue(undefined), diff --git a/src/renderer/src/components/settings/shell/errorText.test.ts b/src/renderer/src/utils/errorText.test.ts similarity index 100% rename from src/renderer/src/components/settings/shell/errorText.test.ts rename to src/renderer/src/utils/errorText.test.ts diff --git a/src/renderer/src/components/settings/shell/errorText.ts b/src/renderer/src/utils/errorText.ts similarity index 100% rename from src/renderer/src/components/settings/shell/errorText.ts rename to src/renderer/src/utils/errorText.ts diff --git a/src/shared/types.ts b/src/shared/types.ts index 0df56c42..fa4f1952 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -117,6 +117,36 @@ export interface StorageMeta { storageVersion: number; } +/** + * Why the stored history could not be read. `recoverable` is true when the next launch may + * read it (the keystore was locked or unavailable, or something else failed on the way) and + * false when the file cannot be read under this keystore at all, so a restart would only + * repeat the failure. + */ +export interface StorageLoadError { + message: string; + recoverable: boolean; +} + +/** + * Progress of the background load that runs after storage initialises. Clips read while + * `complete` is false are the empty defaults, not the stored history; a non-null `error` + * means the history could not be read and must not be overwritten. + */ +export interface StorageLoadState { + complete: boolean; + error: StorageLoadError | null; +} + +/** + * The stored clips together with the load state they were read under, taken in one step so + * a reader cannot mistake the placeholder served during the load for an empty history. + */ +export interface StoredClipsSnapshot { + loadState: StorageLoadState; + clips: StoredClip[]; +} + /** * Storage statistics */