diff --git a/scripts/test-clipboard-history-persistence.mjs b/scripts/test-clipboard-history-persistence.mjs new file mode 100644 index 00000000..c6076c41 --- /dev/null +++ b/scripts/test-clipboard-history-persistence.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; + +const clipboardManagerPath = 'src/main/clipboard-manager.ts'; +const mainPath = 'src/main/main.ts'; + +function makeTextItem(index) { + return { + id: `item-${index}`, + type: 'text', + content: `Clipboard text ${index}`, + preview: `Clipboard text ${index}`, + timestamp: Date.now() + index, + pinned: false, + }; +} + +function assertIncludes(source, needle) { + assert.ok(source.includes(needle), `Source should include: ${needle}`); +} + +function assertNotIncludes(source, needle) { + assert.ok(!source.includes(needle), `Source should not include: ${needle}`); +} + +function extractFunction(source, signature) { + const start = source.indexOf(signature); + assert.notEqual(start, -1, `Missing function signature: ${signature}`); + + const bodyStart = source.indexOf('{', start); + assert.notEqual(bodyStart, -1, `Missing function body: ${signature}`); + + let depth = 0; + for (let index = bodyStart; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') depth += 1; + if (char === '}') depth -= 1; + if (depth === 0) { + return source.slice(start, index + 1); + } + } + + assert.fail(`Unterminated function body: ${signature}`); +} + +function measureCoalescedRapidAdditions(additions) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'supercmd-clipboard-after-')); + const historyPath = path.join(tempDir, 'history.json'); + const tempPath = `${historyPath}.tmp`; + const history = []; + let serializeMs = 0; + let writeMs = 0; + + try { + for (let index = 0; index < additions; index += 1) { + history.unshift(makeTextItem(index)); + } + + const serializeStart = performance.now(); + const json = JSON.stringify(history, null, 2); + serializeMs += performance.now() - serializeStart; + + const writeStart = performance.now(); + fs.writeFileSync(tempPath, json); + fs.renameSync(tempPath, historyPath); + writeMs += performance.now() - writeStart; + + return { + additions, + writeCount: 1, + serializeMs, + writeMs, + finalBytes: fs.statSync(historyPath).size, + }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test('clipboard history persistence coalesces rapid additions into async atomic writes', () => { + const source = fs.readFileSync(clipboardManagerPath, 'utf8'); + const syncHistoryWriteSites = (source.match(/fs\.writeFileSync\(historyPath/g) || []).length; + const metrics = measureCoalescedRapidAdditions(100); + + console.log( + `[clipboard-after] rapidAdditions=${metrics.additions} ` + + `coalescedWrites=${metrics.writeCount} ` + + `serializeMs=${metrics.serializeMs.toFixed(2)} ` + + `writeMs=${metrics.writeMs.toFixed(2)} ` + + `blockingMs=${(metrics.serializeMs + metrics.writeMs).toFixed(2)} ` + + `finalBytes=${metrics.finalBytes}` + ); + + assert.equal(syncHistoryWriteSites, 0); + assertIncludes(source, 'const HISTORY_SAVE_DEBOUNCE_MS = 250;'); + assertIncludes(source, 'let historySaveDirty = false;'); + assertIncludes(source, 'async function writeHistoryFileAtomic(serializedHistory: string): Promise'); + assertIncludes(source, "await fsp.writeFile(tempPath, serializedHistory, 'utf-8');"); + assertIncludes(source, 'await fsp.rename(tempPath, historyPath);'); + assertIncludes(source, 'while (historySaveDirty)'); + assertIncludes(source, 'historySaveTimer = setTimeout'); + assertIncludes(source, 'void flushClipboardHistoryWrites();'); + assert.equal(metrics.writeCount, 1); +}); + +test('clipboard history persistence flushes on stop, clear, delete, and app quit', () => { + const clipboardSource = fs.readFileSync(clipboardManagerPath, 'utf8'); + const mainSource = fs.readFileSync(mainPath, 'utf8'); + + const stopBlock = extractFunction(clipboardSource, 'export async function stopClipboardMonitor(): Promise'); + const clearBlock = extractFunction(clipboardSource, 'export async function clearClipboardHistory(): Promise'); + const deleteBlock = extractFunction(clipboardSource, 'export async function deleteClipboardItem(id: string): Promise'); + const beforeQuitBlock = extractFunction(mainSource, "app.on('before-quit', (event: any) =>"); + + assertIncludes(stopBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(clearBlock, 'saveHistory({ flush: true });'); + assertIncludes(clearBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(deleteBlock, 'saveHistory({ flush: true });'); + assertIncludes(deleteBlock, 'await flushClipboardHistoryWrites();'); + assertIncludes(beforeQuitBlock, 'hasPendingClipboardHistoryWrites()'); + assertIncludes(beforeQuitBlock, 'event.preventDefault();'); + assertIncludes(beforeQuitBlock, 'flushClipboardHistoryWrites()'); + assertIncludes(mainSource, 'await flushClipboardHistoryWrites();'); + assertIncludes(mainSource, "ipcMain.handle('clipboard-clear-history', async () =>"); + assertIncludes(mainSource, "ipcMain.handle('clipboard-delete-item', async (_event: any, id: string) =>"); + assertNotIncludes(mainSource, "ipcMain.handle('clipboard-delete-item', (_event: any, id: string) =>"); +}); diff --git a/src/main/clipboard-manager.ts b/src/main/clipboard-manager.ts index fa1453ff..97391be8 100644 --- a/src/main/clipboard-manager.ts +++ b/src/main/clipboard-manager.ts @@ -11,6 +11,7 @@ import { app, clipboard, nativeImage } from 'electron'; import { execFileSync } from 'child_process'; import * as fs from 'fs'; +import * as fsp from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; @@ -87,6 +88,7 @@ export interface ClipboardItem { const MAX_ITEMS = 1000; const POLL_INTERVAL = 1000; // 1 second +const HISTORY_SAVE_DEBOUNCE_MS = 250; const MAX_TEXT_LENGTH = 100_000; // Don't store huge text items const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB max per image const INTERNAL_CLIPBOARD_PROBE_REGEX = /^__supercmd_[a-z0-9_]+_probe__\d+_[a-z0-9]+$/i; @@ -174,6 +176,11 @@ function ensurePinnedOrder(): void { // ─── Persistence ──────────────────────────────────────────────────── +let historySaveTimer: NodeJS.Timeout | null = null; +let historySaveDirty = false; +let historySaveFlushPromise: Promise | null = null; +let historyWriteInFlight: Promise | null = null; + function loadHistory(): void { try { const historyPath = getHistoryFilePath(); @@ -215,13 +222,96 @@ function loadHistory(): void { } } -function saveHistory(): void { +function getHistoryTempFilePath(historyPath: string): string { + return `${historyPath}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}.tmp`; +} + +async function writeHistoryFileAtomic(serializedHistory: string): Promise { + const historyPath = getHistoryFilePath(); + const tempPath = getHistoryTempFilePath(historyPath); + try { - const historyPath = getHistoryFilePath(); - fs.writeFileSync(historyPath, JSON.stringify(clipboardHistory, null, 2)); + await fsp.mkdir(path.dirname(historyPath), { recursive: true }); + await fsp.writeFile(tempPath, serializedHistory, 'utf-8'); + await fsp.rename(tempPath, historyPath); } catch (e) { - console.error('Failed to save clipboard history:', e); + try { + await fsp.unlink(tempPath); + } catch {} + throw e; + } +} + +function clearHistorySaveTimer(): void { + if (historySaveTimer) { + clearTimeout(historySaveTimer); + historySaveTimer = null; + } +} + +async function drainHistorySaveQueue(): Promise { + clearHistorySaveTimer(); + + if (historyWriteInFlight) { + await historyWriteInFlight; + } + + while (historySaveDirty) { + clearHistorySaveTimer(); + historySaveDirty = false; + const serializedHistory = JSON.stringify(clipboardHistory, null, 2); + const writePromise = writeHistoryFileAtomic(serializedHistory); + historyWriteInFlight = writePromise; + try { + await writePromise; + } catch (e) { + historySaveDirty = true; + throw e; + } finally { + if (historyWriteInFlight === writePromise) { + historyWriteInFlight = null; + } + } + } +} + +export function hasPendingClipboardHistoryWrites(): boolean { + return Boolean(historySaveTimer || historySaveDirty || historyWriteInFlight || historySaveFlushPromise); +} + +export function flushClipboardHistoryWrites(): Promise { + clearHistorySaveTimer(); + + if (!hasPendingClipboardHistoryWrites()) { + return Promise.resolve(); } + + if (!historySaveFlushPromise) { + historySaveFlushPromise = drainHistorySaveQueue() + .catch((e) => { + console.error('Failed to save clipboard history:', e); + }) + .finally(() => { + historySaveFlushPromise = null; + }); + } + + return historySaveFlushPromise; +} + +function saveHistory(options: { flush?: boolean } = {}): void { + historySaveDirty = true; + + if (options.flush) { + void flushClipboardHistoryWrites(); + return; + } + + clearHistorySaveTimer(); + historySaveTimer = setTimeout(() => { + historySaveTimer = null; + void flushClipboardHistoryWrites(); + }, HISTORY_SAVE_DEBOUNCE_MS); } // ─── Clipboard Monitoring ─────────────────────────────────────────── @@ -888,11 +978,12 @@ export function startClipboardMonitor(): void { console.log('Clipboard monitor started'); } -export function stopClipboardMonitor(): void { +export async function stopClipboardMonitor(): Promise { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + await flushClipboardHistoryWrites(); console.log('Clipboard monitor stopped'); } @@ -929,13 +1020,13 @@ export function pruneClipboardHistoryOlderThan(retentionDays: number | null | un const removed = before - kept.length; if (removed > 0) { clipboardHistory = kept; - saveHistory(); + saveHistory({ flush: true }); console.log(`Pruned ${removed} clipboard item${removed === 1 ? '' : 's'} older than ${days} day${days === 1 ? '' : 's'}`); } return removed; } -export function clearClipboardHistory(): void { +export async function clearClipboardHistory(): Promise { // Delete all image files for (const item of clipboardHistory) { if (item.type === 'image' && fs.existsSync(item.content)) { @@ -946,11 +1037,12 @@ export function clearClipboardHistory(): void { } clipboardHistory = []; - saveHistory(); + saveHistory({ flush: true }); + await flushClipboardHistoryWrites(); console.log('Clipboard history cleared'); } -export function deleteClipboardItem(id: string): boolean { +export async function deleteClipboardItem(id: string): Promise { const index = clipboardHistory.findIndex((item) => item.id === id); if (index === -1) return false; @@ -964,7 +1056,8 @@ export function deleteClipboardItem(id: string): boolean { } clipboardHistory.splice(index, 1); - saveHistory(); + saveHistory({ flush: true }); + await flushClipboardHistoryWrites(); return true; } @@ -1132,7 +1225,7 @@ export function setClipboardMonitorEnabled(enabled: boolean): void { if (enabled && !pollInterval) { startClipboardMonitor(); } else if (!enabled && pollInterval) { - stopClipboardMonitor(); + void stopClipboardMonitor(); } } diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..15159f32 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -100,6 +100,8 @@ import { togglePinClipboardItem, moveClipboardPinnedItem, pruneClipboardHistoryOlderThan, + flushClipboardHistoryWrites, + hasPendingClipboardHistoryWrites, } from './clipboard-manager'; import { initSnippetStore, @@ -13315,19 +13317,22 @@ async function restartAndInstallAppUpdate(): Promise { }); triggerTimer = setTimeout(() => { - try { - if (process.platform === 'darwin') { - try { - appUpdater.autoInstallOnAppQuit = true; - appUpdater.autoRunAppAfterInstall = true; - } catch {} - appUpdater.quitAndInstall(); - } else { - appUpdater.quitAndInstall(false, true); + void (async () => { + try { + await flushClipboardHistoryWrites(); + if (process.platform === 'darwin') { + try { + appUpdater.autoInstallOnAppQuit = true; + appUpdater.autoRunAppAfterInstall = true; + } catch {} + appUpdater.quitAndInstall(); + } else { + appUpdater.quitAndInstall(false, true); + } + } catch (error: any) { + finish(false, error); } - } catch (error: any) { - finish(false, error); - } + })(); }, 40); timeoutTimer = setTimeout(() => { @@ -16531,12 +16536,12 @@ return appURL's |path|() as text`, return searchClipboardHistory(query); }); - ipcMain.handle('clipboard-clear-history', () => { - clearClipboardHistory(); + ipcMain.handle('clipboard-clear-history', async () => { + await clearClipboardHistory(); }); - ipcMain.handle('clipboard-delete-item', (_event: any, id: string) => { - return deleteClipboardItem(id); + ipcMain.handle('clipboard-delete-item', async (_event: any, id: string) => { + return await deleteClipboardItem(id); }); ipcMain.handle('clipboard-copy-item', (_event: any, id: string) => { @@ -19424,8 +19429,31 @@ app.on('window-all-closed', () => { } }); -app.on('before-quit', () => { +let clipboardHistoryQuitFlushComplete = false; +let clipboardHistoryQuitFlushInProgress = false; + +app.on('before-quit', (event: any) => { prepareWindowsForAppQuit(); + + const updateRestartInProgress = Boolean(appUpdaterRestartPromise) || appUpdaterStatusSnapshot.state === 'restarting'; + if ( + !updateRestartInProgress && + !clipboardHistoryQuitFlushComplete && + hasPendingClipboardHistoryWrites() + ) { + event.preventDefault(); + if (clipboardHistoryQuitFlushInProgress) return; + clipboardHistoryQuitFlushInProgress = true; + flushClipboardHistoryWrites() + .catch((error) => { + console.error('Failed to flush clipboard history before quit:', error); + }) + .finally(() => { + clipboardHistoryQuitFlushComplete = true; + clipboardHistoryQuitFlushInProgress = false; + app.quit(); + }); + } }); app.on('will-quit', () => { @@ -19451,7 +19479,7 @@ app.on('will-quit', () => { killParakeetServer(); killQwen3Server(); killAudioCapturer(); - stopClipboardMonitor(); + void stopClipboardMonitor(); stopSnippetExpander(); stopEmojiTriggerMonitor(); stopFileSearchIndexing();