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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions scripts/test-clipboard-history-persistence.mjs
Original file line number Diff line number Diff line change
@@ -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<void>');
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<void>');
const clearBlock = extractFunction(clipboardSource, 'export async function clearClipboardHistory(): Promise<void>');
const deleteBlock = extractFunction(clipboardSource, 'export async function deleteClipboardItem(id: string): Promise<boolean>');
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) =>");
});
115 changes: 104 additions & 11 deletions src/main/clipboard-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -174,6 +176,11 @@ function ensurePinnedOrder(): void {

// ─── Persistence ────────────────────────────────────────────────────

let historySaveTimer: NodeJS.Timeout | null = null;
let historySaveDirty = false;
let historySaveFlushPromise: Promise<void> | null = null;
let historyWriteInFlight: Promise<void> | null = null;

function loadHistory(): void {
try {
const historyPath = getHistoryFilePath();
Expand Down Expand Up @@ -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<void> {
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<void> {
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<void> {
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 ───────────────────────────────────────────
Expand Down Expand Up @@ -888,11 +978,12 @@ export function startClipboardMonitor(): void {
console.log('Clipboard monitor started');
}

export function stopClipboardMonitor(): void {
export async function stopClipboardMonitor(): Promise<void> {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
await flushClipboardHistoryWrites();
console.log('Clipboard monitor stopped');
}

Expand Down Expand Up @@ -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<void> {
// Delete all image files
for (const item of clipboardHistory) {
if (item.type === 'image' && fs.existsSync(item.content)) {
Expand All @@ -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<boolean> {
const index = clipboardHistory.findIndex((item) => item.id === id);
if (index === -1) return false;

Expand All @@ -964,7 +1056,8 @@ export function deleteClipboardItem(id: string): boolean {
}

clipboardHistory.splice(index, 1);
saveHistory();
saveHistory({ flush: true });
await flushClipboardHistoryWrites();

return true;
}
Expand Down Expand Up @@ -1132,7 +1225,7 @@ export function setClipboardMonitorEnabled(enabled: boolean): void {
if (enabled && !pollInterval) {
startClipboardMonitor();
} else if (!enabled && pollInterval) {
stopClipboardMonitor();
void stopClipboardMonitor();
}
}

Expand Down
Loading
Loading