From 805e8c068d97f83780cdf4e0cd36ceddcc1cb90e Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:03:53 +0000 Subject: [PATCH 01/11] chore: start storage-load-guard From cd68036aab9c0077a15c1793658659b148684932 Mon Sep 17 00:00:00 2001 From: cb-jeeves Date: Wed, 2 Sep 2026 08:07:58 +0000 Subject: [PATCH 02/11] fix(storage): block clip saves until the stored history has loaded successfully Closes the startup race from #156 where the renderer could write its blank seed list over the encrypted clip history and delete the images it referenced. Main: SecureStorage records a load error when the clips file cannot be decrypted, when encryption is unavailable, or when the background load throws; getLoadState() exposes {complete, failed, error}; saveClips() refuses to run until the load has completed without error. Exposed via the storage-get-load-state IPC channel and preload storageGetLoadState(). Renderer: useClipsStorage asks for the load state before reading clips (so a load finishing in between cannot be mistaken for an empty history), keeps isInitiallyLoading set until a successful load has been applied, and on a failed load shows a toast once and leaves saves disabled. The storage-ready event still triggers the reload. Tests: src/renderer/src/providers/clips/storage.test.tsx covers the empty-before-complete race, the failed-load and thrown-read paths, and the state-before-clips ordering; src/main/storage/index.test.ts covers the load state transitions and the saveClips guard. Notes: the OKF brain MCP server named in CLAUDE.md was not reachable from this environment, so no concepts were read or updated. --- src/main/clipboard/ipc.ts | 2 + src/main/clipboard/storage-integration.ts | 5 +- src/main/storage/index.test.ts | 168 +++++++++++++++++ src/main/storage/index.ts | 43 ++++- src/preload/index.d.ts | 2 + src/preload/index.ts | 3 + src/renderer/src/providers/clips/README.md | 1 + .../src/providers/clips/storage.test.tsx | 173 ++++++++++++++++++ src/renderer/src/providers/clips/storage.ts | 50 ++++- src/renderer/src/test-setup.ts | 1 + src/shared/types.ts | 11 ++ 11 files changed, 453 insertions(+), 6 deletions(-) create mode 100644 src/main/storage/index.test.ts create mode 100644 src/renderer/src/providers/clips/storage.test.tsx diff --git a/src/main/clipboard/ipc.ts b/src/main/clipboard/ipc.ts index 1a36aaab..4119ec9b 100644 --- a/src/main/clipboard/ipc.ts +++ b/src/main/clipboard/ipc.ts @@ -14,6 +14,7 @@ import { } from './monitoring'; import { getClips, + getStorageLoadState, saveClips, getSettings, saveSettings, @@ -105,6 +106,7 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void { // Storage integration handlers ipcMain.handle('storage-get-clips', async () => getClips()); + ipcMain.handle('storage-get-load-state', () => getStorageLoadState()); ipcMain.handle( 'storage-save-clips', async (_event, clips: ClipItem[], lockedIndices: Record) => diff --git a/src/main/clipboard/storage-integration.ts b/src/main/clipboard/storage-integration.ts index 1a949949..bd48424f 100644 --- a/src/main/clipboard/storage-integration.ts +++ b/src/main/clipboard/storage-integration.ts @@ -1,6 +1,6 @@ import { storage } from '../storage'; import { DEFAULT_SETTINGS } from '../storage/defaults'; -import type { ClipItem, StoredClip, UserSettings } from '../../shared/types'; +import type { ClipItem, StoredClip, StorageLoadState, UserSettings } from '../../shared/types'; // Storage integration functions export const getClips = async (): Promise => { @@ -12,6 +12,9 @@ export const getClips = async (): Promise => { } }; +// Whether the clips returned by getClips are the stored history yet, or still the defaults +export const getStorageLoadState = (): StorageLoadState => storage.getLoadState(); + export const saveClips = async ( clips: ClipItem[], lockedIndices: Record diff --git a/src/main/storage/index.test.ts b/src/main/storage/index.test.ts new file mode 100644 index 00000000..9a19a2e4 --- /dev/null +++ b/src/main/storage/index.test.ts @@ -0,0 +1,168 @@ +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 } from '../../shared/types'; +import { storage } from './index'; +import { loadEncryptedJson, saveEncryptedJson, isEncryptionAvailable } from './file-operations'; +import { deleteImage } from './image-store'; + +const mockedLoad = vi.mocked(loadEncryptedJson); +const mockedSave = vi.mocked(saveEncryptedJson); + +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; put it back to a fresh, un-initialised state per test +const reset = () => { + const s = storage as unknown as Record; + s.isInitialized = false; + s.isBackgroundLoadComplete = false; + s.loadError = null; + s.clips = []; + s.onBackgroundLoadComplete = undefined; +}; + +// 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) => { + mockedLoad.mockImplementation((filePath: string) => + filePath.endsWith('clips.enc') ? (clips() as Promise) : (notFound() as Promise) + ); +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isEncryptionAvailable).mockReturnValue(true); + reset(); +}); + +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, failed: false }); + expect(await storage.getClips()).toEqual([]); + + finishClips(history); + await done; + + expect(storage.getLoadState()).toEqual({ complete: true, failed: false }); + expect((await storage.getClips()).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, failed: false }); + }); + + 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.failed).toBe(true); + expect(state.error).toMatch(/decrypting/); + }); + + it('reports a failed load when encryption is unavailable', async () => { + vi.mocked(isEncryptionAvailable).mockReturnValue(false); + await initialiseAndWaitForLoad(); + + expect(storage.getLoadState()).toMatchObject({ complete: true, failed: 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(mockedSave).not.toHaveBeenCalled(); + expect(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(mockedSave).not.toHaveBeenCalled(); + expect(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' }], {}); + + 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(deleteImage).toHaveBeenCalledWith('img-1', expect.any(String)); + }); +}); diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 9cfe1021..583fc890 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -15,6 +15,7 @@ import type { GroupColours, TemplatesData, StorageMeta, + StorageLoadState, } from '../../shared/types'; // Import utility modules @@ -72,6 +73,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: string | null = null; // Domain-specific data stores private settings: UserSettings = DEFAULT_SETTINGS; @@ -123,6 +126,7 @@ class SecureStorage { // Check if safeStorage is available if (!isEncryptionAvailable()) { console.warn('Encryption not available, keeping default data'); + this.loadError = 'Encryption is not available on this system'; this.isBackgroundLoadComplete = true; this.onBackgroundLoadComplete?.(); return; @@ -139,7 +143,8 @@ 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 + this.loadError = errorMessage(error); this.isBackgroundLoadComplete = true; this.onBackgroundLoadComplete?.(); } @@ -171,7 +176,10 @@ class SecureStorage { } } 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. console.error('Failed to load clips:', error); + this.loadError = errorMessage(error); } } @@ -299,6 +307,25 @@ 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, + failed: this.loadError !== null, + ...(this.loadError !== null && { 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 */ @@ -332,6 +359,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}` + ); + } + // 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!) @@ -896,4 +933,8 @@ class SecureStorage { } // Export singleton instance +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export const storage = new SecureStorage(); diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index fa1a254b..f9c8501f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -10,6 +10,7 @@ import type { Template, UpdateState, SettingsApplyResult, + StorageLoadState, StorageStats, UserSettings, } from '../shared/types'; @@ -49,6 +50,7 @@ declare global { // Storage APIs onStorageReady: (callback: () => void) => () => void; storageGetClips: () => Promise; + storageGetLoadState: () => 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..f2c48159 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,6 +6,7 @@ import type { UserSettings, HotkeySettings, StoredClip, + StorageLoadState, Template, SearchTerm, QuickTool, @@ -86,6 +87,8 @@ const api = { // Storage APIs onStorageReady: (callback: () => void) => subscribe('storage-ready', () => callback()), storageGetClips: () => electronAPI.ipcRenderer.invoke('storage-get-clips'), + storageGetLoadState: (): Promise => + electronAPI.ipcRenderer.invoke('storage-get-load-state'), 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/providers/clips/README.md b/src/renderer/src/providers/clips/README.md index 3130ce94..c7e68cf5 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 with a toast - Manages saving clips and settings with debouncing - Listens for settings updates from other windows 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..851cf4a3 --- /dev/null +++ b/src/renderer/src/providers/clips/storage.test.tsx @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, act, cleanup } from '@testing-library/react'; +import { useState } from 'react'; +import type { StoredClip, StorageLoadState } from '../../../../shared/types'; +import { ToastContext } from '../../components/Toast'; +import { DEFAULT_MAX_CLIPS } from '../constants'; +import { ClipItem } 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 NOT_LOADED: StorageLoadState = { complete: false, failed: false }; +const LOADED: StorageLoadState = { complete: true, failed: false }; +const FAILED: StorageLoadState = { + complete: true, + failed: true, + error: 'Error while decrypting the ciphertext provided to safeStorage.decryptString.', +}; + +let storageReady: (() => void) | null = null; +let observed: { clips: ClipItem[]; isInitiallyLoading: boolean } = { + clips: [], + isInitiallyLoading: true, +}; + +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); + useClipsStorage( + clips, + lockedClips, + maxClips, + isInitiallyLoading, + setClips, + setLockedClips, + setMaxClips, + setIsInitiallyLoading + ); + observed = { clips, isInitiallyLoading }; + return null; +} + +const toast = vi.fn(); + +function mount() { + return 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(); + toast.mockReset(); + storageReady = null; + api().storageGetClips.mockReset().mockResolvedValue([]); + api().storageSaveClips.mockReset().mockResolvedValue(true); + api().storageGetLoadState.mockReset().mockResolvedValue(LOADED); + 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().storageGetLoadState.mockResolvedValueOnce(NOT_LOADED); + mount(); + await settle(); + + expect(api().storageSaveClips).not.toHaveBeenCalled(); + expect(observed.isInitiallyLoading).toBe(true); + + // The background decrypt finishes and the real history becomes available + api().storageGetClips.mockResolvedValue([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(toast).not.toHaveBeenCalled(); + }); + + it('enables saving once the load is reported complete', async () => { + api().storageGetClips.mockResolvedValue([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 tells the user when the background load failed', async () => { + api().storageGetLoadState.mockResolvedValue(FAILED); + mount(); + await settle(); + await act(async () => { + storageReady?.(); + }); + await settle(); + + expect(api().storageSaveClips).not.toHaveBeenCalled(); + expect(observed.isInitiallyLoading).toBe(true); + expect(toast).toHaveBeenCalledTimes(1); + expect(toast.mock.calls[0][0]).toMatch(/clip history/i); + }); + + it('keeps saves disabled and tells the user when reading storage throws', async () => { + api().storageGetClips.mockRejectedValue(new Error('ipc down')); + mount(); + await settle(); + + expect(api().storageSaveClips).not.toHaveBeenCalled(); + expect(observed.isInitiallyLoading).toBe(true); + expect(toast).toHaveBeenCalledTimes(1); + }); + + it('reads the load state before the clips so a load finishing in between cannot be mistaken for empty', async () => { + const order: string[] = []; + api().storageGetLoadState.mockImplementation(async () => { + order.push('state'); + return LOADED; + }); + api().storageGetClips.mockImplementation(async () => { + order.push('clips'); + return []; + }); + mount(); + await settle(); + + expect(order.slice(0, 2)).toEqual(['state', 'clips']); + }); +}); diff --git a/src/renderer/src/providers/clips/storage.ts b/src/renderer/src/providers/clips/storage.ts index f0213404..33dc873c 100644 --- a/src/renderer/src/providers/clips/storage.ts +++ b/src/renderer/src/providers/clips/storage.ts @@ -3,6 +3,15 @@ import { ClipItem } from './types'; import { DEFAULT_MAX_CLIPS } from '../constants'; import { shrinkClips, updateClipsLength } from './utils'; import { UserSettings, StoredClip } from '../../../../shared/types'; +import { useToast } from '../../components/Toast'; + +// Shown when the stored history could not be read; the message stays up long enough to read +export const LOAD_FAILED_TITLE = "Couldn't load your clip history"; +export const LOAD_FAILED_DETAIL = [ + 'Saving is paused so the stored history is not overwritten.', + 'Restart Clipless to try again.', +]; +const LOAD_FAILED_TOAST_DURATION = 12000; /** * Hook for managing storage operations for clips and settings @@ -17,7 +26,20 @@ export const useClipsStorage = ( setMaxClips: React.Dispatch>, setIsInitiallyLoading: React.Dispatch> ) => { - // Shared function to load all stored data (clips + settings) + const toast = useToast(); + // The mount load and the storage-ready reload can both see a failed load; tell the user once + const reportedLoadFailure = useRef(false); + + const reportLoadFailure = useCallback(() => { + if (reportedLoadFailure.current) return; + reportedLoadFailure.current = true; + toast(LOAD_FAILED_TITLE, LOAD_FAILED_DETAIL, { duration: LOAD_FAILED_TOAST_DURATION }); + }, [toast]); + + // 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); @@ -25,6 +47,16 @@ export const useClipsStorage = ( } try { + // Ask about the load before reading anything: whatever is read after a completed + // load is the stored data, whereas data read before it is the placeholder. + const loadState = await window.api.storageGetLoadState(); + + 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; + } + // Load settings first const settings = await window.api.storageGetSettings(); if (settings && typeof settings.maxClips === 'number') { @@ -32,6 +64,14 @@ export const useClipsStorage = ( } // Note: codeDetectionEnabled is now handled by LanguageDetectionProvider + if (loadState.failed) { + // The history is unreadable, so what getClips returns is 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); + reportLoadFailure(); + return; + } + // Load clips from storage const storedClips = await window.api.storageGetClips(); @@ -69,12 +109,14 @@ export const useClipsStorage = ( } else { console.log('No stored clips found'); } + + // Only a load that got this far may enable saving + setIsInitiallyLoading(false); } catch (error) { console.error('Failed to load data from storage:', error); - } finally { - setIsInitiallyLoading(false); + reportLoadFailure(); } - }, [setClips, setLockedClips, setMaxClips, setIsInitiallyLoading]); + }, [setClips, setLockedClips, setMaxClips, setIsInitiallyLoading, reportLoadFailure]); // Load data from storage on mount useEffect(() => { diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts index 7d20a57c..21d7c3c9 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -86,6 +86,7 @@ const createMockApi = () => ({ stopClipboardMonitoring: vi.fn().mockResolvedValue(true), getCurrentClipboardData: vi.fn().mockResolvedValue(null), storageGetClips: vi.fn().mockResolvedValue([]), + storageGetLoadState: vi.fn().mockResolvedValue({ complete: true, failed: false }), storageSaveClips: vi.fn().mockResolvedValue(true), setClipboardText: vi.fn().mockResolvedValue(undefined), setClipboardHTML: vi.fn().mockResolvedValue(undefined), diff --git a/src/shared/types.ts b/src/shared/types.ts index 0df56c42..95f868d3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -120,6 +120,17 @@ export interface StorageMeta { /** * Storage statistics */ +/** + * Progress of the background load that runs after storage initialises. Clips read while + * `complete` is false are the empty defaults, not the stored history; `failed` means the + * history could not be read and must not be overwritten. + */ +export interface StorageLoadState { + complete: boolean; + failed: boolean; + error?: string; +} + export interface StorageStats { clipCount: number; lockedCount: number; From 43b9e9f845d4dab5606ea4d58d17772496f6db59 Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:11:14 +0000 Subject: [PATCH 03/11] refactor(storage): tidy load-state helpers and document the save guard in the README --- README.md | 2 +- src/main/storage/index.ts | 15 +++++++++------ src/shared/types.ts | 6 +++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 84bf51bb..850d3862 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 you'll see a message instead of an empty list. - **🖥️ 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/src/main/storage/index.ts b/src/main/storage/index.ts index 583fc890..91595080 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -59,6 +59,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: [], @@ -311,11 +315,14 @@ class SecureStorage { * Whether the background load has finished, and whether the stored history was readable. */ getLoadState(): StorageLoadState { - return { + const state: StorageLoadState = { complete: this.isBackgroundLoadComplete, failed: this.loadError !== null, - ...(this.loadError !== null && { error: this.loadError }), }; + if (this.loadError !== null) { + state.error = this.loadError; + } + return state; } /** @@ -933,8 +940,4 @@ class SecureStorage { } // Export singleton instance -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - export const storage = new SecureStorage(); diff --git a/src/shared/types.ts b/src/shared/types.ts index 95f868d3..41242a73 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -117,9 +117,6 @@ export interface StorageMeta { storageVersion: number; } -/** - * Storage statistics - */ /** * Progress of the background load that runs after storage initialises. Clips read while * `complete` is false are the empty defaults, not the stored history; `failed` means the @@ -131,6 +128,9 @@ export interface StorageLoadState { error?: string; } +/** + * Storage statistics + */ export interface StorageStats { clipCount: number; lockedCount: number; From 8313bbbd8caadd9c3356c1f69e369aaf29c941ba Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:15:25 +0000 Subject: [PATCH 04/11] chore(release): v2.2.1 (patch) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2e02a222..bc8c6f33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "2.2.0", + "version": "2.2.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9290f459..ef3bda91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipless", - "version": "2.2.0", + "version": "2.2.1", "description": "A Clipboard manager for busy people", "main": "./out/main/index.js", "author": "Daniel Essig", From 56fffa7ca4dc4413d51a1808bee1d62788cbbfa7 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:38:46 +0000 Subject: [PATCH 05/11] fix(storage): show a persistent banner for a failed history load and hand clips out with their load state The failed-load toast expired after 12 seconds, so a user whose keystore changed saw only an empty list while saving stayed off for the session. The list now shows a banner above the rows for as long as the load error stands, carrying the same two lines plus the error itself. storage-get-clips now returns the clips together with the load state they were read under, taken in one step in the main process, so a reader cannot mistake the placeholder served during the load for an empty history. This removes the separate storage-get-load-state channel and the ordering rule that callers had to follow. StorageLoadState carries a single nullable error instead of a failed flag that had to agree with it. The storage tests import a fresh instance per test instead of resetting private fields by name. --- README.md | 2 +- src/main/clipboard/ipc.ts | 2 - src/main/clipboard/storage-integration.ts | 14 +-- src/main/storage/index.test.ts | 84 +++++++------ src/main/storage/index.ts | 21 ++-- src/preload/index.d.ts | 5 +- src/preload/index.ts | 7 +- src/renderer/src/App.module.css | 3 + .../src/components/clips/Clips.module.css | 28 ++++- .../src/components/clips/Clips.test.tsx | 19 +++ src/renderer/src/components/clips/Clips.tsx | 111 +++++++++++------- .../src/components/settings/tools/harness.tsx | 17 +-- .../components/settings/tools/useToolsData.ts | 4 +- src/renderer/src/providers/clips/README.md | 2 +- src/renderer/src/providers/clips/index.tsx | 5 +- .../src/providers/clips/storage.test.tsx | 90 +++++++------- src/renderer/src/providers/clips/storage.ts | 64 ++++------ src/renderer/src/providers/clips/types.ts | 2 + src/renderer/src/test-setup.ts | 6 +- src/shared/types.ts | 16 ++- 20 files changed, 299 insertions(+), 203 deletions(-) diff --git a/README.md b/README.md index 850d3862..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. Nothing is written back until that load succeeds, and if the history can't be read you'll see a message instead of an empty list. +- **🚀 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/src/main/clipboard/ipc.ts b/src/main/clipboard/ipc.ts index 4119ec9b..1a36aaab 100644 --- a/src/main/clipboard/ipc.ts +++ b/src/main/clipboard/ipc.ts @@ -14,7 +14,6 @@ import { } from './monitoring'; import { getClips, - getStorageLoadState, saveClips, getSettings, saveSettings, @@ -106,7 +105,6 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void { // Storage integration handlers ipcMain.handle('storage-get-clips', async () => getClips()); - ipcMain.handle('storage-get-load-state', () => getStorageLoadState()); ipcMain.handle( 'storage-save-clips', async (_event, clips: ClipItem[], lockedIndices: Record) => diff --git a/src/main/clipboard/storage-integration.ts b/src/main/clipboard/storage-integration.ts index bd48424f..bc44eaaf 100644 --- a/src/main/clipboard/storage-integration.ts +++ b/src/main/clipboard/storage-integration.ts @@ -1,20 +1,20 @@ import { storage } from '../storage'; import { DEFAULT_SETTINGS } from '../storage/defaults'; -import type { ClipItem, StoredClip, StorageLoadState, 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 getClips = 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: [] }; } }; -// Whether the clips returned by getClips are the stored history yet, or still the defaults -export const getStorageLoadState = (): StorageLoadState => storage.getLoadState(); - export const saveClips = async ( clips: ClipItem[], lockedIndices: Record diff --git a/src/main/storage/index.test.ts b/src/main/storage/index.test.ts index 9a19a2e4..5e84c6a5 100644 --- a/src/main/storage/index.test.ts +++ b/src/main/storage/index.test.ts @@ -39,12 +39,6 @@ vi.mock('./image-store', () => ({ })); import type { StoredClip } from '../../shared/types'; -import { storage } from './index'; -import { loadEncryptedJson, saveEncryptedJson, isEncryptionAvailable } from './file-operations'; -import { deleteImage } from './image-store'; - -const mockedLoad = vi.mocked(loadEncryptedJson); -const mockedSave = vi.mocked(saveEncryptedJson); const history: StoredClip[] = [ { clip: { id: 'a', type: 'text', content: 'kept' }, isLocked: false, timestamp: 1 }, @@ -59,15 +53,21 @@ 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; put it back to a fresh, un-initialised state per test -const reset = () => { - const s = storage as unknown as Record; - s.isInitialized = false; - s.isBackgroundLoadComplete = false; - s.loadError = null; - s.clips = []; - s.onBackgroundLoadComplete = undefined; -}; +// 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 () => { @@ -78,17 +78,11 @@ const initialiseAndWaitForLoad = async () => { // Every domain file is missing except clips, which is served by `clips` const serveFiles = (clips: () => Promise) => { - mockedLoad.mockImplementation((filePath: string) => + vi.mocked(fileOperations.loadEncryptedJson).mockImplementation((filePath: string) => filePath.endsWith('clips.enc') ? (clips() as Promise) : (notFound() as Promise) ); }; -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(isEncryptionAvailable).mockReturnValue(true); - reset(); -}); - describe('SecureStorage load state', () => { it('reports the load as incomplete until the background load finishes', async () => { let finishClips!: (value: StoredClip[]) => void; @@ -98,21 +92,42 @@ describe('SecureStorage load state', () => { const done = new Promise((resolve) => storage.setOnBackgroundLoadComplete(resolve)); await storage.initialize(); - expect(storage.getLoadState()).toEqual({ complete: false, failed: false }); + expect(storage.getLoadState()).toEqual({ complete: false, error: null }); expect(await storage.getClips()).toEqual([]); finishClips(history); await done; - expect(storage.getLoadState()).toEqual({ complete: true, failed: false }); + 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, failed: false }); + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); }); it('reports a failed load when the clips file cannot be decrypted', async () => { @@ -121,15 +136,17 @@ describe('SecureStorage load state', () => { const state = storage.getLoadState(); expect(state.complete).toBe(true); - expect(state.failed).toBe(true); expect(state.error).toMatch(/decrypting/); }); it('reports a failed load when encryption is unavailable', async () => { - vi.mocked(isEncryptionAvailable).mockReturnValue(false); + vi.mocked(fileOperations.isEncryptionAvailable).mockReturnValue(false); await initialiseAndWaitForLoad(); - expect(storage.getLoadState()).toMatchObject({ complete: true, failed: true }); + expect(storage.getLoadState()).toEqual({ + complete: true, + error: expect.stringMatching(/Encryption is not available/), + }); }); }); @@ -139,8 +156,8 @@ describe('SecureStorage.saveClips guard', () => { await storage.initialize(); await expect(storage.saveClips([], {})).rejects.toThrow(/not finished loading/); - expect(mockedSave).not.toHaveBeenCalled(); - expect(deleteImage).not.toHaveBeenCalled(); + expect(fileOperations.saveEncryptedJson).not.toHaveBeenCalled(); + expect(imageStore.deleteImage).not.toHaveBeenCalled(); }); it('refuses to save over a history that could not be read', async () => { @@ -148,8 +165,8 @@ describe('SecureStorage.saveClips guard', () => { await initialiseAndWaitForLoad(); await expect(storage.saveClips([], {})).rejects.toThrow(/could not be loaded/); - expect(mockedSave).not.toHaveBeenCalled(); - expect(deleteImage).not.toHaveBeenCalled(); + expect(fileOperations.saveEncryptedJson).not.toHaveBeenCalled(); + expect(imageStore.deleteImage).not.toHaveBeenCalled(); }); it('saves normally once the history has loaded', async () => { @@ -158,11 +175,12 @@ describe('SecureStorage.saveClips guard', () => { 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(deleteImage).toHaveBeenCalledWith('img-1', expect.any(String)); + expect(imageStore.deleteImage).toHaveBeenCalledWith('img-1', expect.any(String)); }); }); diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 91595080..5bfd0e64 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -16,6 +16,7 @@ import type { TemplatesData, StorageMeta, StorageLoadState, + StoredClipsSnapshot, } from '../../shared/types'; // Import utility modules @@ -315,14 +316,7 @@ class SecureStorage { * Whether the background load has finished, and whether the stored history was readable. */ getLoadState(): StorageLoadState { - const state: StorageLoadState = { - complete: this.isBackgroundLoadComplete, - failed: this.loadError !== null, - }; - if (this.loadError !== null) { - state.error = this.loadError; - } - return state; + return { complete: this.isBackgroundLoadComplete, error: this.loadError }; } /** @@ -357,6 +351,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. diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index f9c8501f..798f2607 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -10,7 +10,7 @@ import type { Template, UpdateState, SettingsApplyResult, - StorageLoadState, + StoredClipsSnapshot, StorageStats, UserSettings, } from '../shared/types'; @@ -49,8 +49,7 @@ declare global { hotkeysGetDefaults: () => Promise; // Storage APIs onStorageReady: (callback: () => void) => () => void; - storageGetClips: () => Promise; - storageGetLoadState: () => Promise; + storageGetClips: () => 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 f2c48159..1c44eb0d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,7 +6,7 @@ import type { UserSettings, HotkeySettings, StoredClip, - StorageLoadState, + StoredClipsSnapshot, Template, SearchTerm, QuickTool, @@ -86,9 +86,8 @@ const api = { // Storage APIs onStorageReady: (callback: () => void) => subscribe('storage-ready', () => callback()), - storageGetClips: () => electronAPI.ipcRenderer.invoke('storage-get-clips'), - storageGetLoadState: (): Promise => - electronAPI.ipcRenderer.invoke('storage-get-load-state'), + storageGetClips: (): Promise => + electronAPI.ipcRenderer.invoke('storage-get-clips'), 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/App.module.css b/src/renderer/src/App.module.css index f7fe6957..cbe1d81e 100644 --- a/src/renderer/src/App.module.css +++ b/src/renderer/src/App.module.css @@ -13,4 +13,7 @@ min-height: 0; overflow: hidden; position: relative; + /* A column so the storage banner can sit above the list without pushing it out of view */ + display: flex; + flex-direction: column; } diff --git a/src/renderer/src/components/clips/Clips.module.css b/src/renderer/src/components/clips/Clips.module.css index 359ff06c..fcf5181a 100644 --- a/src/renderer/src/components/clips/Clips.module.css +++ b/src/renderer/src/components/clips/Clips.module.css @@ -1,6 +1,32 @@ +.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; + 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..62da02cb 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 string | 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,19 @@ 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 = 'Error while decrypting the ciphertext provided to safeStorage.'; + 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(); + }); +}); diff --git a/src/renderer/src/components/clips/Clips.tsx b/src/renderer/src/components/clips/Clips.tsx index 32cc221d..152f9d08 100644 --- a/src/renderer/src/components/clips/Clips.tsx +++ b/src/renderer/src/components/clips/Clips.tsx @@ -16,7 +16,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 +82,74 @@ 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_DETAIL = [ + 'Saving is paused so the stored history is not overwritten.', + 'Restart Clipless to try again.', +]; + +/** + * 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. + */ +function LoadFailedBanner({ error }: { error: string }): React.JSX.Element { + return ( +
+
{LOAD_FAILED_TITLE}
+
    + {LOAD_FAILED_DETAIL.map((line) => ( +
  • {line}
  • + ))} +
  • {error}
  • +
); } diff --git a/src/renderer/src/components/settings/tools/harness.tsx b/src/renderer/src/components/settings/tools/harness.tsx index 6039d8a2..abf9d54c 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.storageGetClips.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.ts b/src/renderer/src/components/settings/tools/useToolsData.ts index 6a8a6bae..591598eb 100644 --- a/src/renderer/src/components/settings/tools/useToolsData.ts +++ b/src/renderer/src/components/settings/tools/useToolsData.ts @@ -59,8 +59,8 @@ export function useToolsDataValue(): ToolsData { let live = true; window.api .storageGetClips() - .then((clips) => { - if (live) setClip(newestClipText(clips ?? [])); + .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 c7e68cf5..fdc53c95 100644 --- a/src/renderer/src/providers/clips/README.md +++ b/src/renderer/src/providers/clips/README.md @@ -19,7 +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 with a toast +- 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.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 index 851cf4a3..d3ddfd61 100644 --- a/src/renderer/src/providers/clips/storage.test.tsx +++ b/src/renderer/src/providers/clips/storage.test.tsx @@ -1,8 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, act, cleanup } from '@testing-library/react'; import { useState } from 'react'; -import type { StoredClip, StorageLoadState } from '../../../../shared/types'; -import { ToastContext } from '../../components/Toast'; +import type { StoredClip, StoredClipsSnapshot } from '../../../../shared/types'; import { DEFAULT_MAX_CLIPS } from '../constants'; import { ClipItem } from './types'; import { updateClipsLength } from './utils'; @@ -14,18 +13,27 @@ const stored = (id: string, content: string, isLocked = false): StoredClip => ({ timestamp: 1, }); -const NOT_LOADED: StorageLoadState = { complete: false, failed: false }; -const LOADED: StorageLoadState = { complete: true, failed: false }; -const FAILED: StorageLoadState = { - complete: true, - failed: true, - error: 'Error while decrypting the ciphertext provided to safeStorage.decryptString.', -}; +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: DECRYPT_ERROR }, + clips: [], +}); let storageReady: (() => void) | null = null; -let observed: { clips: ClipItem[]; isInitiallyLoading: boolean } = { +let observed: { clips: ClipItem[]; isInitiallyLoading: boolean; loadError: string | null } = { clips: [], isInitiallyLoading: true, + loadError: null, }; function Probe() { @@ -33,7 +41,7 @@ function Probe() { const [lockedClips, setLockedClips] = useState>({}); const [maxClips, setMaxClips] = useState(DEFAULT_MAX_CLIPS); const [isInitiallyLoading, setIsInitiallyLoading] = useState(true); - useClipsStorage( + const { loadError } = useClipsStorage( clips, lockedClips, maxClips, @@ -43,19 +51,11 @@ function Probe() { setMaxClips, setIsInitiallyLoading ); - observed = { clips, isInitiallyLoading }; + observed = { clips, isInitiallyLoading, loadError }; return null; } -const toast = vi.fn(); - -function mount() { - return render( - - - - ); -} +const mount = () => render(); const flush = async () => { await act(async () => { @@ -75,11 +75,9 @@ const api = () => window.api as unknown as Record { vi.useFakeTimers(); - toast.mockReset(); storageReady = null; - api().storageGetClips.mockReset().mockResolvedValue([]); + api().storageGetClips.mockReset().mockResolvedValue(loaded()); api().storageSaveClips.mockReset().mockResolvedValue(true); - api().storageGetLoadState.mockReset().mockResolvedValue(LOADED); api() .onStorageReady.mockReset() .mockImplementation((cb: () => void) => { @@ -97,7 +95,7 @@ afterEach(() => { describe('useClipsStorage load guard', () => { it('never saves the blank list when clips read as empty before the background load completes', async () => { - api().storageGetLoadState.mockResolvedValueOnce(NOT_LOADED); + api().storageGetClips.mockResolvedValueOnce(notLoaded()); mount(); await settle(); @@ -105,7 +103,9 @@ describe('useClipsStorage load guard', () => { expect(observed.isInitiallyLoading).toBe(true); // The background decrypt finishes and the real history becomes available - api().storageGetClips.mockResolvedValue([stored('a', 'first'), stored('b', 'second', true)]); + api().storageGetClips.mockResolvedValue( + loaded([stored('a', 'first'), stored('b', 'second', true)]) + ); await act(async () => { storageReady?.(); }); @@ -117,11 +117,11 @@ describe('useClipsStorage load guard', () => { for (const [saved] of api().storageSaveClips.mock.calls) { expect(saved.slice(0, 2).map((c: ClipItem) => c.content)).toEqual(['first', 'second']); } - expect(toast).not.toHaveBeenCalled(); + expect(observed.loadError).toBeNull(); }); it('enables saving once the load is reported complete', async () => { - api().storageGetClips.mockResolvedValue([stored('a', 'kept')]); + api().storageGetClips.mockResolvedValue(loaded([stored('a', 'kept')])); mount(); await settle(); @@ -130,8 +130,8 @@ describe('useClipsStorage load guard', () => { expect(api().storageSaveClips.mock.calls[0][0][0].content).toBe('kept'); }); - it('keeps saves disabled and tells the user when the background load failed', async () => { - api().storageGetLoadState.mockResolvedValue(FAILED); + it('keeps saves disabled and reports the error when the background load failed', async () => { + api().storageGetClips.mockResolvedValue(failed()); mount(); await settle(); await act(async () => { @@ -141,33 +141,33 @@ describe('useClipsStorage load guard', () => { expect(api().storageSaveClips).not.toHaveBeenCalled(); expect(observed.isInitiallyLoading).toBe(true); - expect(toast).toHaveBeenCalledTimes(1); - expect(toast.mock.calls[0][0]).toMatch(/clip history/i); + expect(observed.loadError).toBe(DECRYPT_ERROR); }); - it('keeps saves disabled and tells the user when reading storage throws', async () => { + it('keeps saves disabled and reports the error when reading storage throws', async () => { api().storageGetClips.mockRejectedValue(new Error('ipc down')); mount(); await settle(); expect(api().storageSaveClips).not.toHaveBeenCalled(); expect(observed.isInitiallyLoading).toBe(true); - expect(toast).toHaveBeenCalledTimes(1); + expect(observed.loadError).toBe('ipc down'); }); - it('reads the load state before the clips so a load finishing in between cannot be mistaken for empty', async () => { - const order: string[] = []; - api().storageGetLoadState.mockImplementation(async () => { - order.push('state'); - return LOADED; - }); - api().storageGetClips.mockImplementation(async () => { - order.push('clips'); - return []; - }); + it('clears the error once a later load succeeds', async () => { + api().storageGetClips.mockResolvedValueOnce(failed()); mount(); await settle(); + expect(observed.loadError).toBe(DECRYPT_ERROR); - expect(order.slice(0, 2)).toEqual(['state', 'clips']); + api().storageGetClips.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'); }); }); diff --git a/src/renderer/src/providers/clips/storage.ts b/src/renderer/src/providers/clips/storage.ts index 33dc873c..3d916f88 100644 --- a/src/renderer/src/providers/clips/storage.ts +++ b/src/renderer/src/providers/clips/storage.ts @@ -1,20 +1,14 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { ClipItem } from './types'; import { DEFAULT_MAX_CLIPS } from '../constants'; import { shrinkClips, updateClipsLength } from './utils'; import { UserSettings, StoredClip } from '../../../../shared/types'; -import { useToast } from '../../components/Toast'; - -// Shown when the stored history could not be read; the message stays up long enough to read -export const LOAD_FAILED_TITLE = "Couldn't load your clip history"; -export const LOAD_FAILED_DETAIL = [ - 'Saving is paused so the stored history is not overwritten.', - 'Restart Clipless to try again.', -]; -const LOAD_FAILED_TOAST_DURATION = 12000; /** - * 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[], @@ -25,16 +19,8 @@ export const useClipsStorage = ( setLockedClips: React.Dispatch>>, setMaxClips: React.Dispatch>, setIsInitiallyLoading: React.Dispatch> -) => { - const toast = useToast(); - // The mount load and the storage-ready reload can both see a failed load; tell the user once - const reportedLoadFailure = useRef(false); - - const reportLoadFailure = useCallback(() => { - if (reportedLoadFailure.current) return; - reportedLoadFailure.current = true; - toast(LOAD_FAILED_TITLE, LOAD_FAILED_DETAIL, { duration: LOAD_FAILED_TOAST_DURATION }); - }, [toast]); +): { loadError: string | 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 @@ -47,16 +33,6 @@ export const useClipsStorage = ( } try { - // Ask about the load before reading anything: whatever is read after a completed - // load is the stored data, whereas data read before it is the placeholder. - const loadState = await window.api.storageGetLoadState(); - - 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; - } - // Load settings first const settings = await window.api.storageGetSettings(); if (settings && typeof settings.maxClips === 'number') { @@ -64,17 +40,24 @@ export const useClipsStorage = ( } // Note: codeDetectionEnabled is now handled by LanguageDetectionProvider - if (loadState.failed) { - // The history is unreadable, so what getClips returns is not it: leave the window + // 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.storageGetClips(); + + 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); - reportLoadFailure(); + setLoadError(loadState.error); return; } - // Load clips from storage - const storedClips = await window.api.storageGetClips(); - if (storedClips && storedClips.length > 0) { const loadedClips: ClipItem[] = []; const loadedLocks: Record = {}; @@ -111,12 +94,13 @@ export const useClipsStorage = ( } // 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); - reportLoadFailure(); + setLoadError(error instanceof Error ? error.message : String(error)); } - }, [setClips, setLockedClips, setMaxClips, setIsInitiallyLoading, reportLoadFailure]); + }, [setClips, setLockedClips, setMaxClips, setIsInitiallyLoading]); // Load data from storage on mount useEffect(() => { @@ -199,4 +183,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..3508bd86 100644 --- a/src/renderer/src/providers/clips/types.ts +++ b/src/renderer/src/providers/clips/types.ts @@ -49,6 +49,8 @@ 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: string | null; }; /** diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts index 21d7c3c9..90b00917 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -85,8 +85,10 @@ const createMockApi = () => ({ startClipboardMonitoring: vi.fn().mockResolvedValue(true), stopClipboardMonitoring: vi.fn().mockResolvedValue(true), getCurrentClipboardData: vi.fn().mockResolvedValue(null), - storageGetClips: vi.fn().mockResolvedValue([]), - storageGetLoadState: vi.fn().mockResolvedValue({ complete: true, failed: false }), + storageGetClips: 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/shared/types.ts b/src/shared/types.ts index 41242a73..9b968b83 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -119,13 +119,21 @@ export interface StorageMeta { /** * Progress of the background load that runs after storage initialises. Clips read while - * `complete` is false are the empty defaults, not the stored history; `failed` means the - * history could not be read and must not be overwritten. + * `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; - failed: boolean; - error?: string; + error: string | 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[]; } /** From 227b3e0807a5ec8aa7ceeeffac0e8ca77decb535 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:50:48 +0000 Subject: [PATCH 06/11] refactor(storage): say whether a failed load may clear on restart, and tidy the banner and snapshot API The load state now carries `recoverable`, true only when encryption was unavailable, so the banner tells a keystore-mismatch user that the history cannot be read under this keystore instead of sending them into a restart loop. The guidance lines use the banner's normal text and only the error line is mono and muted. Clips has its own flex-column root, so the App stylesheet change is reverted. The snapshot API is named getClipsSnapshot at every layer so getClips keeps meaning just the clips. errorText moved to a shared renderer util and the storage hook uses it. --- src/main/clipboard/ipc.ts | 4 +- src/main/clipboard/storage-integration.ts | 2 +- src/main/storage/index.test.ts | 12 +++--- src/main/storage/index.ts | 9 +++- src/preload/index.d.ts | 2 +- src/preload/index.ts | 4 +- src/renderer/src/App.module.css | 3 -- .../src/components/clips/Clips.module.css | 10 +++++ .../src/components/clips/Clips.test.tsx | 21 +++++++++- src/renderer/src/components/clips/Clips.tsx | 24 ++++++----- .../components/settings/general/ClearAll.tsx | 2 +- .../components/settings/general/General.tsx | 2 +- .../settings/general/ImportPreview.tsx | 2 +- .../settings/general/SettingsProvider.tsx | 2 +- .../settings/tools/ExportImport.tsx | 2 +- .../src/components/settings/tools/Tools.tsx | 2 +- .../src/components/settings/tools/harness.tsx | 4 +- .../settings/tools/useToolsData.test.tsx | 6 +-- .../components/settings/tools/useToolsData.ts | 2 +- .../src/providers/clips/storage.test.tsx | 41 ++++++++++--------- src/renderer/src/providers/clips/storage.ts | 14 ++++--- src/renderer/src/providers/clips/types.ts | 9 +++- src/renderer/src/test-setup.ts | 4 +- .../shell => utils}/errorText.test.ts | 0 .../settings/shell => utils}/errorText.ts | 0 src/shared/types.ts | 6 ++- 26 files changed, 119 insertions(+), 70 deletions(-) rename src/renderer/src/{components/settings/shell => utils}/errorText.test.ts (100%) rename src/renderer/src/{components/settings/shell => utils}/errorText.ts (100%) 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.ts b/src/main/clipboard/storage-integration.ts index bc44eaaf..4af8bbe6 100644 --- a/src/main/clipboard/storage-integration.ts +++ b/src/main/clipboard/storage-integration.ts @@ -6,7 +6,7 @@ import type { ClipItem, StoredClipsSnapshot, UserSettings } from '../../shared/t // 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 getClips = async (): Promise => { +export const getClipsSnapshot = async (): Promise => { try { return await storage.getClipsSnapshot(); } catch (error) { diff --git a/src/main/storage/index.test.ts b/src/main/storage/index.test.ts index 5e84c6a5..eeccb8a4 100644 --- a/src/main/storage/index.test.ts +++ b/src/main/storage/index.test.ts @@ -92,13 +92,13 @@ describe('SecureStorage load state', () => { const done = new Promise((resolve) => storage.setOnBackgroundLoadComplete(resolve)); await storage.initialize(); - expect(storage.getLoadState()).toEqual({ complete: false, error: null }); + expect(storage.getLoadState()).toEqual({ complete: false, error: null, recoverable: false }); expect(await storage.getClips()).toEqual([]); finishClips(history); await done; - expect(storage.getLoadState()).toEqual({ complete: true, error: null }); + expect(storage.getLoadState()).toEqual({ complete: true, error: null, recoverable: false }); expect((await storage.getClips()).map((c) => c.clip.id)).toEqual(['a', 'b']); }); @@ -111,7 +111,7 @@ describe('SecureStorage load state', () => { await storage.initialize(); expect(await storage.getClipsSnapshot()).toEqual({ - loadState: { complete: false, error: null }, + loadState: { complete: false, error: null, recoverable: false }, clips: [], }); @@ -119,7 +119,7 @@ describe('SecureStorage load state', () => { await done; const loaded = await storage.getClipsSnapshot(); - expect(loaded.loadState).toEqual({ complete: true, error: null }); + expect(loaded.loadState).toEqual({ complete: true, error: null, recoverable: false }); expect(loaded.clips.map((c) => c.clip.id)).toEqual(['a', 'b']); }); @@ -127,7 +127,7 @@ describe('SecureStorage load state', () => { serveFiles(notFound); await initialiseAndWaitForLoad(); - expect(storage.getLoadState()).toEqual({ complete: true, error: null }); + expect(storage.getLoadState()).toEqual({ complete: true, error: null, recoverable: false }); }); it('reports a failed load when the clips file cannot be decrypted', async () => { @@ -137,6 +137,7 @@ describe('SecureStorage load state', () => { const state = storage.getLoadState(); expect(state.complete).toBe(true); expect(state.error).toMatch(/decrypting/); + expect(state.recoverable).toBe(false); }); it('reports a failed load when encryption is unavailable', async () => { @@ -146,6 +147,7 @@ describe('SecureStorage load state', () => { expect(storage.getLoadState()).toEqual({ complete: true, error: expect.stringMatching(/Encryption is not available/), + recoverable: true, }); }); }); diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 5bfd0e64..e53c9c93 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -80,6 +80,8 @@ class SecureStorage { private isBackgroundLoadComplete = false; // Set when the stored history could not be read; saves stay refused so it is not overwritten private loadError: string | null = null; + // True when the failure is one a later launch may clear (the keystore was unavailable) + private loadRecoverable = false; // Domain-specific data stores private settings: UserSettings = DEFAULT_SETTINGS; @@ -132,6 +134,7 @@ class SecureStorage { if (!isEncryptionAvailable()) { console.warn('Encryption not available, keeping default data'); this.loadError = 'Encryption is not available on this system'; + this.loadRecoverable = true; this.isBackgroundLoadComplete = true; this.onBackgroundLoadComplete?.(); return; @@ -316,7 +319,11 @@ class SecureStorage { * Whether the background load has finished, and whether the stored history was readable. */ getLoadState(): StorageLoadState { - return { complete: this.isBackgroundLoadComplete, error: this.loadError }; + return { + complete: this.isBackgroundLoadComplete, + error: this.loadError, + recoverable: this.loadRecoverable, + }; } /** diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 798f2607..216165f1 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -49,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 1c44eb0d..4e6f258d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -86,8 +86,8 @@ const api = { // Storage APIs onStorageReady: (callback: () => void) => subscribe('storage-ready', () => callback()), - storageGetClips: (): Promise => - 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/App.module.css b/src/renderer/src/App.module.css index cbe1d81e..f7fe6957 100644 --- a/src/renderer/src/App.module.css +++ b/src/renderer/src/App.module.css @@ -13,7 +13,4 @@ min-height: 0; overflow: hidden; position: relative; - /* A column so the storage banner can sit above the list without pushing it out of view */ - display: flex; - flex-direction: column; } diff --git a/src/renderer/src/components/clips/Clips.module.css b/src/renderer/src/components/clips/Clips.module.css index fcf5181a..162bf742 100644 --- a/src/renderer/src/components/clips/Clips.module.css +++ b/src/renderer/src/components/clips/Clips.module.css @@ -1,3 +1,10 @@ +.clips { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} + .loadFailed { flex: none; margin: 8px 10px 0; @@ -18,6 +25,9 @@ list-style: none; padding: 0; margin: 3px 0 0; +} + +.loadFailedError { font-family: var(--mono); font-size: 11.5px; color: var(--muted); diff --git a/src/renderer/src/components/clips/Clips.test.tsx b/src/renderer/src/components/clips/Clips.test.tsx index 62da02cb..8984f3ce 100644 --- a/src/renderer/src/components/clips/Clips.test.tsx +++ b/src/renderer/src/components/clips/Clips.test.tsx @@ -18,7 +18,7 @@ const { virtual, state } = vi.hoisted(() => ({ isSearchVisible: false, setIsSearchVisible: vi.fn(), focusRequest: null as { index: number; seq: number } | null, - loadError: null as string | null, + loadError: null as { message: string; recoverable: boolean } | null, }, })); @@ -206,7 +206,10 @@ describe('Clips empty states', () => { describe('Clips load failure', () => { it('shows a banner with the reason for as long as the history is unreadable', () => { - state.loadError = 'Error while decrypting the ciphertext provided to safeStorage.'; + 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); @@ -218,4 +221,18 @@ describe('Clips load failure', () => { 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/); + + 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/); + }); }); diff --git a/src/renderer/src/components/clips/Clips.tsx b/src/renderer/src/components/clips/Clips.tsx index 152f9d08..f008aed6 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'; @@ -82,7 +83,7 @@ export function Clips(): React.JSX.Element { : `No clips match "${searchTerm.trim()}"`; return ( - <> +
{loadError !== null && }
)}
- +
); } const LOAD_FAILED_TITLE = "Couldn't load your clip history"; -const LOAD_FAILED_DETAIL = [ - 'Saving is paused so the stored history is not overwritten.', - 'Restart Clipless to try again.', -]; +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."; /** * 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. */ -function LoadFailedBanner({ error }: { error: string }): React.JSX.Element { +function LoadFailedBanner({ error }: { error: ClipsLoadError }): React.JSX.Element { return (
{LOAD_FAILED_TITLE}
    - {LOAD_FAILED_DETAIL.map((line) => ( -
  • {line}
  • - ))} -
  • {error}
  • +
  • {LOAD_FAILED_PAUSED}
  • +
  • {error.recoverable ? LOAD_FAILED_RETRY : LOAD_FAILED_UNREADABLE}
  • +
  • {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 abf9d54c..73a66294 100644 --- a/src/renderer/src/components/settings/tools/harness.tsx +++ b/src/renderer/src/components/settings/tools/harness.tsx @@ -96,8 +96,8 @@ export function installConfig( ...settings, }); a.settingsChanged.mockResolvedValue({ ok: true, failed: [] }); - a.storageGetClips.mockResolvedValue({ - loadState: { complete: true, error: null }, + a.storageGetClipsSnapshot.mockResolvedValue({ + loadState: { complete: true, error: null, recoverable: false }, clips: [ { clip: { id: 'c1', type: 'text', content: 'newest clip text with 10.0.0.1' }, 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 591598eb..9c1d2df5 100644 --- a/src/renderer/src/components/settings/tools/useToolsData.ts +++ b/src/renderer/src/components/settings/tools/useToolsData.ts @@ -58,7 +58,7 @@ export function useToolsDataValue(): ToolsData { useEffect(() => { let live = true; window.api - .storageGetClips() + .storageGetClipsSnapshot() .then((snapshot) => { if (live) setClip(newestClipText(snapshot?.clips ?? [])); }) diff --git a/src/renderer/src/providers/clips/storage.test.tsx b/src/renderer/src/providers/clips/storage.test.tsx index d3ddfd61..3c8275e4 100644 --- a/src/renderer/src/providers/clips/storage.test.tsx +++ b/src/renderer/src/providers/clips/storage.test.tsx @@ -3,7 +3,7 @@ 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 } from './types'; +import { ClipItem, ClipsLoadError } from './types'; import { updateClipsLength } from './utils'; import { useClipsStorage } from './storage'; @@ -17,24 +17,25 @@ const DECRYPT_ERROR = 'Error while decrypting the ciphertext provided to safeStorage.decryptString.'; const notLoaded = (): StoredClipsSnapshot => ({ - loadState: { complete: false, error: null }, + loadState: { complete: false, error: null, recoverable: false }, clips: [], }); const loaded = (clips: StoredClip[] = []): StoredClipsSnapshot => ({ - loadState: { complete: true, error: null }, + loadState: { complete: true, error: null, recoverable: false }, clips, }); const failed = (): StoredClipsSnapshot => ({ - loadState: { complete: true, error: DECRYPT_ERROR }, + loadState: { complete: true, error: DECRYPT_ERROR, recoverable: false }, clips: [], }); let storageReady: (() => void) | null = null; -let observed: { clips: ClipItem[]; isInitiallyLoading: boolean; loadError: string | null } = { - clips: [], - isInitiallyLoading: true, - loadError: null, -}; +let observed: { clips: ClipItem[]; isInitiallyLoading: boolean; loadError: ClipsLoadError | null } = + { + clips: [], + isInitiallyLoading: true, + loadError: null, + }; function Probe() { const [clips, setClips] = useState(updateClipsLength([], DEFAULT_MAX_CLIPS)); @@ -76,7 +77,7 @@ const api = () => window.api as unknown as Record { vi.useFakeTimers(); storageReady = null; - api().storageGetClips.mockReset().mockResolvedValue(loaded()); + api().storageGetClipsSnapshot.mockReset().mockResolvedValue(loaded()); api().storageSaveClips.mockReset().mockResolvedValue(true); api() .onStorageReady.mockReset() @@ -95,7 +96,7 @@ afterEach(() => { describe('useClipsStorage load guard', () => { it('never saves the blank list when clips read as empty before the background load completes', async () => { - api().storageGetClips.mockResolvedValueOnce(notLoaded()); + api().storageGetClipsSnapshot.mockResolvedValueOnce(notLoaded()); mount(); await settle(); @@ -103,7 +104,7 @@ describe('useClipsStorage load guard', () => { expect(observed.isInitiallyLoading).toBe(true); // The background decrypt finishes and the real history becomes available - api().storageGetClips.mockResolvedValue( + api().storageGetClipsSnapshot.mockResolvedValue( loaded([stored('a', 'first'), stored('b', 'second', true)]) ); await act(async () => { @@ -121,7 +122,7 @@ describe('useClipsStorage load guard', () => { }); it('enables saving once the load is reported complete', async () => { - api().storageGetClips.mockResolvedValue(loaded([stored('a', 'kept')])); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'kept')])); mount(); await settle(); @@ -131,7 +132,7 @@ describe('useClipsStorage load guard', () => { }); it('keeps saves disabled and reports the error when the background load failed', async () => { - api().storageGetClips.mockResolvedValue(failed()); + api().storageGetClipsSnapshot.mockResolvedValue(failed()); mount(); await settle(); await act(async () => { @@ -141,26 +142,26 @@ describe('useClipsStorage load guard', () => { expect(api().storageSaveClips).not.toHaveBeenCalled(); expect(observed.isInitiallyLoading).toBe(true); - expect(observed.loadError).toBe(DECRYPT_ERROR); + expect(observed.loadError).toEqual({ message: DECRYPT_ERROR, recoverable: false }); }); it('keeps saves disabled and reports the error when reading storage throws', async () => { - api().storageGetClips.mockRejectedValue(new Error('ipc down')); + api().storageGetClipsSnapshot.mockRejectedValue(new Error('ipc down')); mount(); await settle(); expect(api().storageSaveClips).not.toHaveBeenCalled(); expect(observed.isInitiallyLoading).toBe(true); - expect(observed.loadError).toBe('ipc down'); + expect(observed.loadError).toEqual({ message: 'ipc down', recoverable: true }); }); it('clears the error once a later load succeeds', async () => { - api().storageGetClips.mockResolvedValueOnce(failed()); + api().storageGetClipsSnapshot.mockResolvedValueOnce(failed()); mount(); await settle(); - expect(observed.loadError).toBe(DECRYPT_ERROR); + expect(observed.loadError).toEqual({ message: DECRYPT_ERROR, recoverable: false }); - api().storageGetClips.mockResolvedValue(loaded([stored('a', 'back')])); + api().storageGetClipsSnapshot.mockResolvedValue(loaded([stored('a', 'back')])); await act(async () => { storageReady?.(); }); diff --git a/src/renderer/src/providers/clips/storage.ts b/src/renderer/src/providers/clips/storage.ts index 3d916f88..19349f9a 100644 --- a/src/renderer/src/providers/clips/storage.ts +++ b/src/renderer/src/providers/clips/storage.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { ClipItem } from './types'; +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'; /** @@ -19,8 +20,8 @@ export const useClipsStorage = ( setLockedClips: React.Dispatch>>, setMaxClips: React.Dispatch>, setIsInitiallyLoading: React.Dispatch> -): { loadError: string | null } => { - const [loadError, setLoadError] = useState(null); +): { 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 @@ -42,7 +43,7 @@ export const useClipsStorage = ( // 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.storageGetClips(); + const { loadState, clips: storedClips } = await window.api.storageGetClipsSnapshot(); if (!loadState.complete) { // The storage-ready event triggers another load once the history is available @@ -54,7 +55,7 @@ export const useClipsStorage = ( // 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); - setLoadError(loadState.error); + setLoadError({ message: loadState.error, recoverable: loadState.recoverable }); return; } @@ -98,7 +99,8 @@ export const useClipsStorage = ( setIsInitiallyLoading(false); } catch (error) { console.error('Failed to load data from storage:', error); - setLoadError(error instanceof Error ? error.message : String(error)); + // 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]); diff --git a/src/renderer/src/providers/clips/types.ts b/src/renderer/src/providers/clips/types.ts index 3508bd86..1b7eec59 100644 --- a/src/renderer/src/providers/clips/types.ts +++ b/src/renderer/src/providers/clips/types.ts @@ -50,9 +50,16 @@ export type ClipsMetaContextType = { /** 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: string | null; + 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 = { message: string; recoverable: boolean }; + /** * 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 90b00917..0fd095d7 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -85,8 +85,8 @@ const createMockApi = () => ({ startClipboardMonitoring: vi.fn().mockResolvedValue(true), stopClipboardMonitoring: vi.fn().mockResolvedValue(true), getCurrentClipboardData: vi.fn().mockResolvedValue(null), - storageGetClips: vi.fn().mockResolvedValue({ - loadState: { complete: true, error: null }, + storageGetClipsSnapshot: vi.fn().mockResolvedValue({ + loadState: { complete: true, error: null, recoverable: false }, clips: [], }), storageSaveClips: vi.fn().mockResolvedValue(true), 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 9b968b83..983eef6a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -120,11 +120,15 @@ export interface StorageMeta { /** * 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. + * means the history could not be read and must not be overwritten. `recoverable` is true + * when the next launch may read it (the keystore was locked or unavailable) and false when + * the file cannot be read under this keystore at all, so a restart would only repeat the + * failure. */ export interface StorageLoadState { complete: boolean; error: string | null; + recoverable: boolean; } /** From f32122689e59b9b0e9bbf77315c15b2a075de53e Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:03:56 +0000 Subject: [PATCH 07/11] refactor(storage): carry the load failure as one value and point unreadable history at the reset Fold the message and the recoverable flag into a StorageLoadError so the two fields cannot disagree across the IPC boundary and the renderer reuses the shared type instead of mapping it. Treat an unclassified load failure as recoverable, since only a clips decrypt failure is known to repeat on every launch. When it does, the banner tells the user to clear all data in Settings and restart, which is the one way out of a permanently paused session. --- src/main/storage/index.test.ts | 19 +++++++------- src/main/storage/index.ts | 25 +++++++++++-------- .../src/components/clips/Clips.test.tsx | 2 ++ src/renderer/src/components/clips/Clips.tsx | 6 ++++- .../src/components/settings/tools/harness.tsx | 2 +- .../src/providers/clips/storage.test.tsx | 6 ++--- src/renderer/src/providers/clips/storage.ts | 4 +-- src/renderer/src/providers/clips/types.ts | 4 +-- src/renderer/src/test-setup.ts | 2 +- src/shared/types.ts | 19 +++++++++----- 10 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/main/storage/index.test.ts b/src/main/storage/index.test.ts index eeccb8a4..648c8d5a 100644 --- a/src/main/storage/index.test.ts +++ b/src/main/storage/index.test.ts @@ -92,13 +92,13 @@ describe('SecureStorage load state', () => { const done = new Promise((resolve) => storage.setOnBackgroundLoadComplete(resolve)); await storage.initialize(); - expect(storage.getLoadState()).toEqual({ complete: false, error: null, recoverable: false }); + 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, recoverable: false }); + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); expect((await storage.getClips()).map((c) => c.clip.id)).toEqual(['a', 'b']); }); @@ -111,7 +111,7 @@ describe('SecureStorage load state', () => { await storage.initialize(); expect(await storage.getClipsSnapshot()).toEqual({ - loadState: { complete: false, error: null, recoverable: false }, + loadState: { complete: false, error: null }, clips: [], }); @@ -119,7 +119,7 @@ describe('SecureStorage load state', () => { await done; const loaded = await storage.getClipsSnapshot(); - expect(loaded.loadState).toEqual({ complete: true, error: null, recoverable: false }); + expect(loaded.loadState).toEqual({ complete: true, error: null }); expect(loaded.clips.map((c) => c.clip.id)).toEqual(['a', 'b']); }); @@ -127,7 +127,7 @@ describe('SecureStorage load state', () => { serveFiles(notFound); await initialiseAndWaitForLoad(); - expect(storage.getLoadState()).toEqual({ complete: true, error: null, recoverable: false }); + expect(storage.getLoadState()).toEqual({ complete: true, error: null }); }); it('reports a failed load when the clips file cannot be decrypted', async () => { @@ -136,8 +136,10 @@ describe('SecureStorage load state', () => { const state = storage.getLoadState(); expect(state.complete).toBe(true); - expect(state.error).toMatch(/decrypting/); - expect(state.recoverable).toBe(false); + expect(state.error).toEqual({ + message: expect.stringMatching(/decrypting/), + recoverable: false, + }); }); it('reports a failed load when encryption is unavailable', async () => { @@ -146,8 +148,7 @@ describe('SecureStorage load state', () => { expect(storage.getLoadState()).toEqual({ complete: true, - error: expect.stringMatching(/Encryption is not available/), - recoverable: true, + error: { message: expect.stringMatching(/Encryption is not available/), recoverable: true }, }); }); }); diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index e53c9c93..20702cc8 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -15,6 +15,7 @@ import type { GroupColours, TemplatesData, StorageMeta, + StorageLoadError, StorageLoadState, StoredClipsSnapshot, } from '../../shared/types'; @@ -79,9 +80,7 @@ class SecureStorage { 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: string | null = null; - // True when the failure is one a later launch may clear (the keystore was unavailable) - private loadRecoverable = false; + private loadError: StorageLoadError | null = null; // Domain-specific data stores private settings: UserSettings = DEFAULT_SETTINGS; @@ -133,8 +132,10 @@ class SecureStorage { // Check if safeStorage is available if (!isEncryptionAvailable()) { console.warn('Encryption not available, keeping default data'); - this.loadError = 'Encryption is not available on this system'; - this.loadRecoverable = true; + this.loadError = { + message: 'Encryption is not available on this system', + recoverable: true, + }; this.isBackgroundLoadComplete = true; this.onBackgroundLoadComplete?.(); return; @@ -151,8 +152,10 @@ class SecureStorage { this.onBackgroundLoadComplete?.(); } catch (error) { console.error('Failed to load data in background:', error); - // Keep using default data, but refuse to write over the unread history - this.loadError = errorMessage(error); + // 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?.(); } @@ -185,9 +188,10 @@ class SecureStorage { } 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. + // 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 = errorMessage(error); + this.loadError = { message: errorMessage(error), recoverable: false }; } } @@ -322,7 +326,6 @@ class SecureStorage { return { complete: this.isBackgroundLoadComplete, error: this.loadError, - recoverable: this.loadRecoverable, }; } @@ -384,7 +387,7 @@ class SecureStorage { throw new Error( this.loadError === null ? 'Storage has not finished loading' - : `Storage could not be loaded: ${this.loadError}` + : `Storage could not be loaded: ${this.loadError.message}` ); } diff --git a/src/renderer/src/components/clips/Clips.test.tsx b/src/renderer/src/components/clips/Clips.test.tsx index 8984f3ce..9e369f69 100644 --- a/src/renderer/src/components/clips/Clips.test.tsx +++ b/src/renderer/src/components/clips/Clips.test.tsx @@ -228,11 +228,13 @@ describe('Clips load failure', () => { 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 f008aed6..a0060b51 100644 --- a/src/renderer/src/components/clips/Clips.tsx +++ b/src/renderer/src/components/clips/Clips.tsx @@ -136,12 +136,15 @@ const LOAD_FAILED_PAUSED = 'Saving is paused so the stored history is not overwr 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. + * 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 ( @@ -150,6 +153,7 @@ function LoadFailedBanner({ error }: { error: ClipsLoadError }): React.JSX.Eleme
  • {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/tools/harness.tsx b/src/renderer/src/components/settings/tools/harness.tsx index 73a66294..430ff73e 100644 --- a/src/renderer/src/components/settings/tools/harness.tsx +++ b/src/renderer/src/components/settings/tools/harness.tsx @@ -97,7 +97,7 @@ export function installConfig( }); a.settingsChanged.mockResolvedValue({ ok: true, failed: [] }); a.storageGetClipsSnapshot.mockResolvedValue({ - loadState: { complete: true, error: null, recoverable: false }, + loadState: { complete: true, error: null }, clips: [ { clip: { id: 'c1', type: 'text', content: 'newest clip text with 10.0.0.1' }, diff --git a/src/renderer/src/providers/clips/storage.test.tsx b/src/renderer/src/providers/clips/storage.test.tsx index 3c8275e4..60fa53e8 100644 --- a/src/renderer/src/providers/clips/storage.test.tsx +++ b/src/renderer/src/providers/clips/storage.test.tsx @@ -17,15 +17,15 @@ const DECRYPT_ERROR = 'Error while decrypting the ciphertext provided to safeStorage.decryptString.'; const notLoaded = (): StoredClipsSnapshot => ({ - loadState: { complete: false, error: null, recoverable: false }, + loadState: { complete: false, error: null }, clips: [], }); const loaded = (clips: StoredClip[] = []): StoredClipsSnapshot => ({ - loadState: { complete: true, error: null, recoverable: false }, + loadState: { complete: true, error: null }, clips, }); const failed = (): StoredClipsSnapshot => ({ - loadState: { complete: true, error: DECRYPT_ERROR, recoverable: false }, + loadState: { complete: true, error: { message: DECRYPT_ERROR, recoverable: false } }, clips: [], }); diff --git a/src/renderer/src/providers/clips/storage.ts b/src/renderer/src/providers/clips/storage.ts index 19349f9a..0273ccbc 100644 --- a/src/renderer/src/providers/clips/storage.ts +++ b/src/renderer/src/providers/clips/storage.ts @@ -54,8 +54,8 @@ export const useClipsStorage = ( 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); - setLoadError({ message: loadState.error, recoverable: loadState.recoverable }); + console.error('Stored clip history could not be loaded:', loadState.error.message); + setLoadError(loadState.error); return; } diff --git a/src/renderer/src/providers/clips/types.ts b/src/renderer/src/providers/clips/types.ts index 1b7eec59..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'; @@ -58,7 +58,7 @@ export type ClipsMetaContextType = { * 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 = { message: string; recoverable: boolean }; +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 0fd095d7..6b8f5843 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -86,7 +86,7 @@ const createMockApi = () => ({ stopClipboardMonitoring: vi.fn().mockResolvedValue(true), getCurrentClipboardData: vi.fn().mockResolvedValue(null), storageGetClipsSnapshot: vi.fn().mockResolvedValue({ - loadState: { complete: true, error: null, recoverable: false }, + loadState: { complete: true, error: null }, clips: [], }), storageSaveClips: vi.fn().mockResolvedValue(true), diff --git a/src/shared/types.ts b/src/shared/types.ts index 983eef6a..fa4f1952 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -117,18 +117,25 @@ 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. `recoverable` is true - * when the next launch may read it (the keystore was locked or unavailable) and false when - * the file cannot be read under this keystore at all, so a restart would only repeat the - * failure. + * means the history could not be read and must not be overwritten. */ export interface StorageLoadState { complete: boolean; - error: string | null; - recoverable: boolean; + error: StorageLoadError | null; } /** From fda2a29d32ea7250816e8edbb90f4d3a70147c7c Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:47:04 +0000 Subject: [PATCH 08/11] chore(release): v2.2.2 (patch, rebased on main) --- package-lock.json | 46 ++-------------------------------------------- package.json | 2 +- 2 files changed, 3 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 338b4eae..a4d90764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "2.2.1", + "version": "2.2.2", "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 ef3bda91..62cf8304 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipless", - "version": "2.2.1", + "version": "2.2.2", "description": "A Clipboard manager for busy people", "main": "./out/main/index.js", "author": "Daniel Essig", From 20f64a71b3d71ea4c37dfc89c0d13a990b2c5c9f Mon Sep 17 00:00:00 2001 From: jeeves <308196396+cb-jeeves@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:54:35 +0000 Subject: [PATCH 09/11] chore(release): v2.2.3 (patch, rebased on main) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d22daffb..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": { 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", From 0e76de4ef19dad66888a8bb1a0655e563448cf05 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:04:04 +0000 Subject: [PATCH 10/11] fix(storage): treat a non-list clips file as a failed load A clips file that decrypts and parses to something other than an array used to leave loadError unset, so the save guard opened and the next save replaced the file with an empty history. Throw into the existing catch instead so it is reported as an unrecoverable load failure. --- src/main/storage/index.test.ts | 13 +++++++++++++ src/main/storage/index.ts | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/src/main/storage/index.test.ts b/src/main/storage/index.test.ts index 648c8d5a..fba06378 100644 --- a/src/main/storage/index.test.ts +++ b/src/main/storage/index.test.ts @@ -142,6 +142,19 @@ describe('SecureStorage load state', () => { }); }); + 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(); diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 20702cc8..53b37ec3 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -184,6 +184,11 @@ 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') { From 80ea561fbf45a60662390dec3e95918d9194f574 Mon Sep 17 00:00:00 2001 From: Dan Essig <2682437+dantheuber@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:55:15 +0000 Subject: [PATCH 11/11] test(storage): bring the load guard modules to full coverage Cover every branch of SecureStorage, the clipboard storage-integration wrappers and the renderer useClipsStorage hook, and add a provider test for the loadError hand-off to the meta context. Two branches were unreachable and are removed rather than ignored: the saveDomain initialisation guard (every entry point initialises first) and the lock cleanup for index 0 in useClipsStorage, which can never be set because locks are only recorded from index 1. --- .../clipboard/storage-integration.test.ts | 181 +++++ src/main/storage/index.test.ts | 712 +++++++++++++++++- src/main/storage/index.ts | 7 +- .../src/providers/clips/index.test.tsx | 78 ++ .../src/providers/clips/storage.test.tsx | 164 +++- src/renderer/src/providers/clips/storage.ts | 5 - 6 files changed, 1129 insertions(+), 18 deletions(-) create mode 100644 src/main/clipboard/storage-integration.test.ts create mode 100644 src/renderer/src/providers/clips/index.test.tsx 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/storage/index.test.ts b/src/main/storage/index.test.ts index fba06378..bcfba163 100644 --- a/src/main/storage/index.test.ts +++ b/src/main/storage/index.test.ts @@ -38,7 +38,14 @@ vi.mock('./image-store', () => ({ deleteAllImages: vi.fn().mockResolvedValue(undefined), })); -import type { StoredClip } from '../../shared/types'; +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 }, @@ -200,3 +207,706 @@ describe('SecureStorage.saveClips guard', () => { 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 53b37ec3..995c2279 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -277,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)); } @@ -723,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/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/storage.test.tsx b/src/renderer/src/providers/clips/storage.test.tsx index 60fa53e8..259e13ec 100644 --- a/src/renderer/src/providers/clips/storage.test.tsx +++ b/src/renderer/src/providers/clips/storage.test.tsx @@ -30,12 +30,20 @@ const failed = (): StoredClipsSnapshot => ({ }); let storageReady: (() => void) | null = null; -let observed: { clips: ClipItem[]; isInitiallyLoading: boolean; loadError: ClipsLoadError | null } = - { - clips: [], - isInitiallyLoading: true, - loadError: 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)); @@ -52,7 +60,7 @@ function Probe() { setMaxClips, setIsInitiallyLoading ); - observed = { clips, isInitiallyLoading, loadError }; + observed = { clips, lockedClips, maxClips, isInitiallyLoading, loadError }; return null; } @@ -79,6 +87,17 @@ beforeEach(() => { 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) => { @@ -172,3 +191,134 @@ describe('useClipsStorage load guard', () => { 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 0273ccbc..e6af0a29 100644 --- a/src/renderer/src/providers/clips/storage.ts +++ b/src/renderer/src/providers/clips/storage.ts @@ -76,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);