From b428ad1ec751298a512df84e94d386c7e2949ca1 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:25:53 +0200 Subject: [PATCH] perf(icons): cache main icon IPC lookups --- scripts/test-main-icon-ipc-cache.mjs | 317 +++++++++++++++++++++++++++ src/main/icon-ipc-cache.ts | 201 +++++++++++++++++ src/main/main.ts | 22 +- 3 files changed, 529 insertions(+), 11 deletions(-) create mode 100644 scripts/test-main-icon-ipc-cache.mjs create mode 100644 src/main/icon-ipc-cache.ts diff --git a/scripts/test-main-icon-ipc-cache.mjs b/scripts/test-main-icon-ipc-cache.mjs new file mode 100644 index 00000000..a91f8bc0 --- /dev/null +++ b/scripts/test-main-icon-ipc-cache.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { performance } from 'node:perf_hooks'; +import { build } from 'esbuild'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const targetFile = path.join(root, 'src/main/icon-ipc-cache.ts'); +let importNonce = 0; + +async function importMainIconCacheHarness() { + const result = await build({ + absWorkingDir: root, + entryPoints: [targetFile], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + target: 'node20', + logLevel: 'silent', + }); + const dataUrl = [ + 'data:text/javascript;base64,', + Buffer.from(result.outputFiles[0].text).toString('base64'), + `#main-icon-cache-${importNonce++}`, + ].join(''); + return import(dataUrl); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeIcon(dataUrl, resizes = []) { + return { + isEmpty: () => false, + resize: (options) => { + resizes.push(options); + return { + toDataURL: () => dataUrl, + }; + }, + }; +} + +async function resolveFileIconUncached(getFileIcon, filePath, size = 20) { + try { + const bucket = size <= 16 ? 'small' : size >= 64 ? 'large' : 'normal'; + const icon = await getFileIcon(filePath, { size: bucket }); + if (icon && !icon.isEmpty()) { + return icon.resize({ width: size, height: size }).toDataURL(); + } + return null; + } catch { + return null; + } +} + +async function measureFileIconCalls(api, { useCache, iterations, mode, delayMs = 1 }) { + const filePath = '/tmp/../tmp/supercmd-main-icon.txt'; + const dataUrl = 'data:image/png;base64,file'; + let nativeCalls = 0; + + const getFileIcon = async () => { + nativeCalls += 1; + await delay(delayMs); + return makeIcon(dataUrl); + }; + const cache = api.createFileIconDataUrlCache({ getFileIcon, maxEntries: 16 }); + const resolve = useCache + ? (requestedPath) => cache.resolve(requestedPath, 20) + : (requestedPath) => resolveFileIconUncached(getFileIcon, requestedPath, 20); + + const startedAt = performance.now(); + if (mode === 'concurrent') { + await Promise.all(Array.from({ length: iterations }, () => resolve(filePath))); + } else { + for (let index = 0; index < iterations; index += 1) { + await resolve(filePath); + } + } + + return { + nativeCalls, + elapsedMs: performance.now() - startedAt, + }; +} + +async function resolveAppIconUncached(resolveAppIconDataUrl, appPath, size = 32) { + try { + return resolveAppIconDataUrl(appPath, size); + } catch { + return null; + } +} + +async function measureAppIconCalls(api, { useCache, iterations, mode }) { + const appPath = '/Applications/../Applications/SuperCmd.app'; + const dataUrl = 'data:image/png;base64,app'; + let nativeCalls = 0; + + const resolveAppIconDataUrl = () => { + nativeCalls += 1; + return dataUrl; + }; + const cache = api.createAppIconDataUrlCache({ resolveAppIconDataUrl, maxEntries: 16 }); + const resolve = useCache + ? (requestedPath) => cache.resolve(requestedPath, 32) + : (requestedPath) => resolveAppIconUncached(resolveAppIconDataUrl, requestedPath, 32); + + const startedAt = performance.now(); + if (mode === 'concurrent') { + await Promise.all(Array.from({ length: iterations }, () => resolve(appPath))); + } else { + for (let index = 0; index < iterations; index += 1) { + await resolve(appPath); + } + } + + return { + nativeCalls, + elapsedMs: performance.now() - startedAt, + }; +} + +test('main icon IPC cache', async (t) => { + const api = await importMainIconCacheHarness(); + + await t.test('coalesces and caches file icon data URL requests', async () => { + const iterations = 100; + const beforeConcurrent = await measureFileIconCalls(api, { + useCache: false, + iterations, + mode: 'concurrent', + }); + const afterConcurrent = await measureFileIconCalls(api, { + useCache: true, + iterations, + mode: 'concurrent', + }); + const beforeRepeated = await measureFileIconCalls(api, { + useCache: false, + iterations, + mode: 'serial', + }); + const afterRepeated = await measureFileIconCalls(api, { + useCache: true, + iterations, + mode: 'serial', + }); + + t.diagnostic( + `file icon native calls for ${iterations} concurrent requests: before=${beforeConcurrent.nativeCalls}, after=${afterConcurrent.nativeCalls}; elapsedMs before=${beforeConcurrent.elapsedMs.toFixed(1)}, after=${afterConcurrent.elapsedMs.toFixed(1)}`, + ); + t.diagnostic( + `file icon native calls for ${iterations} repeated requests: before=${beforeRepeated.nativeCalls}, after=${afterRepeated.nativeCalls}; elapsedMs before=${beforeRepeated.elapsedMs.toFixed(1)}, after=${afterRepeated.elapsedMs.toFixed(1)}`, + ); + + assert.equal(beforeConcurrent.nativeCalls, iterations); + assert.equal(afterConcurrent.nativeCalls, 1); + assert.equal(beforeRepeated.nativeCalls, iterations); + assert.equal(afterRepeated.nativeCalls, 1); + }); + + await t.test('keeps file icon cache keys normalized by path, size, and bucket', async () => { + const dataUrl = 'data:image/png;base64,file-normalized'; + const resizes = []; + const calls = []; + const cache = api.createFileIconDataUrlCache({ + maxEntries: 4, + getFileIcon: async (filePath, options) => { + calls.push({ filePath, options }); + return makeIcon(dataUrl, resizes); + }, + }); + + assert.equal(await cache.resolve('/tmp/../tmp/supercmd-normalized.txt', 20), dataUrl); + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 20), dataUrl); + assert.equal(calls.length, 1, 'normalized path alias should reuse cached data URL'); + + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 32), dataUrl); + assert.equal(await cache.resolve('/tmp/supercmd-normalized.txt', 16), dataUrl); + assert.equal(calls.length, 3, 'logical size and icon bucket are part of the key'); + assert.deepEqual(calls.map((call) => call.options.size), ['normal', 'normal', 'small']); + assert.deepEqual(resizes, [ + { width: 20, height: 20 }, + { width: 32, height: 32 }, + { width: 16, height: 16 }, + ]); + }); + + await t.test('does not permanently cache file icon nulls or errors', async () => { + const recoveredDataUrl = 'data:image/png;base64,file-recovered'; + let calls = 0; + const cache = api.createFileIconDataUrlCache({ + getFileIcon: async () => { + calls += 1; + if (calls === 1) throw new Error('temporary file icon failure'); + return makeIcon(recoveredDataUrl); + }, + }); + + assert.deepEqual( + await Promise.all([ + cache.resolve('/tmp/supercmd-late-file.txt', 20), + cache.resolve('/tmp/supercmd-late-file.txt', 20), + ]), + [null, null], + ); + assert.equal(calls, 1); + assert.equal(cache.stats().cacheSize, 0); + + assert.equal(await cache.resolve('/tmp/supercmd-late-file.txt', 20), recoveredDataUrl); + assert.equal(calls, 2); + assert.equal(cache.stats().cacheSize, 1); + assert.equal(await cache.resolve('/tmp/supercmd-late-file.txt', 20), recoveredDataUrl); + assert.equal(calls, 2); + }); + + await t.test('coalesces and caches app icon data URL requests', async () => { + const iterations = 100; + const beforeConcurrent = await measureAppIconCalls(api, { + useCache: false, + iterations, + mode: 'concurrent', + }); + const afterConcurrent = await measureAppIconCalls(api, { + useCache: true, + iterations, + mode: 'concurrent', + }); + const beforeRepeated = await measureAppIconCalls(api, { + useCache: false, + iterations, + mode: 'serial', + }); + const afterRepeated = await measureAppIconCalls(api, { + useCache: true, + iterations, + mode: 'serial', + }); + + t.diagnostic( + `app icon native calls for ${iterations} concurrent requests: before=${beforeConcurrent.nativeCalls}, after=${afterConcurrent.nativeCalls}; elapsedMs before=${beforeConcurrent.elapsedMs.toFixed(1)}, after=${afterConcurrent.elapsedMs.toFixed(1)}`, + ); + t.diagnostic( + `app icon native calls for ${iterations} repeated requests: before=${beforeRepeated.nativeCalls}, after=${afterRepeated.nativeCalls}; elapsedMs before=${beforeRepeated.elapsedMs.toFixed(1)}, after=${afterRepeated.elapsedMs.toFixed(1)}`, + ); + + assert.equal(beforeConcurrent.nativeCalls, iterations); + assert.equal(afterConcurrent.nativeCalls, 1); + assert.equal(beforeRepeated.nativeCalls, iterations); + assert.equal(afterRepeated.nativeCalls, 1); + }); + + await t.test('shares app icon cache with sync callers and does not permanently cache nulls', async () => { + const recoveredDataUrl = 'data:image/png;base64,app-recovered'; + let calls = 0; + const cache = api.createAppIconDataUrlCache({ + maxEntries: 2, + resolveAppIconDataUrl: (appPath, size) => { + calls += 1; + if (calls === 1) return null; + return `${recoveredDataUrl}:${path.basename(appPath)}:${size}`; + }, + }); + + assert.deepEqual( + await Promise.all([ + cache.resolve('/Applications/SuperCmd.app', 32), + cache.resolve('/Applications/../Applications/SuperCmd.app', 32), + ]), + [null, null], + ); + assert.equal(calls, 1); + assert.equal(cache.stats().cacheSize, 0); + + const recovered = await cache.resolve('/Applications/SuperCmd.app', 32); + assert.equal(recovered, `${recoveredDataUrl}:SuperCmd.app:32`); + assert.equal(calls, 2); + assert.equal(cache.resolveSync('/Applications/../Applications/SuperCmd.app', 32), recovered); + assert.equal(calls, 2); + }); + + await t.test('bounds file and app icon caches with LRU eviction', async () => { + const fileCache = api.createFileIconDataUrlCache({ + maxEntries: 2, + getFileIcon: async (filePath) => makeIcon(`data:image/png;base64,${Buffer.from(filePath).toString('base64')}`), + }); + await fileCache.resolve('/tmp/a.txt', 20); + await fileCache.resolve('/tmp/b.txt', 20); + await fileCache.resolve('/tmp/a.txt', 20); + await fileCache.resolve('/tmp/c.txt', 20); + const fileKeys = fileCache.stats().keys.join('\n'); + assert.equal(fileCache.stats().cacheSize, 2); + assert.match(fileKeys, /\/tmp\/a\.txt/); + assert.match(fileKeys, /\/tmp\/c\.txt/); + assert.doesNotMatch(fileKeys, /\/tmp\/b\.txt/); + + const appCache = api.createAppIconDataUrlCache({ + maxEntries: 2, + resolveAppIconDataUrl: (appPath) => `data:image/png;base64,${Buffer.from(appPath).toString('base64')}`, + }); + assert.ok(appCache.resolveSync('/Applications/A.app', 32)); + assert.ok(appCache.resolveSync('/Applications/B.app', 32)); + assert.ok(appCache.resolveSync('/Applications/A.app', 32)); + assert.ok(appCache.resolveSync('/Applications/C.app', 32)); + const appKeys = appCache.stats().keys.join('\n'); + assert.equal(appCache.stats().cacheSize, 2); + assert.match(appKeys, /\/Applications\/A\.app/); + assert.match(appKeys, /\/Applications\/C\.app/); + assert.doesNotMatch(appKeys, /\/Applications\/B\.app/); + }); +}); diff --git a/src/main/icon-ipc-cache.ts b/src/main/icon-ipc-cache.ts new file mode 100644 index 00000000..20dfcfd0 --- /dev/null +++ b/src/main/icon-ipc-cache.ts @@ -0,0 +1,201 @@ +import * as path from 'path'; + +export type FileIconSizeBucket = 'small' | 'normal' | 'large'; + +export type FileIconImageLike = { + isEmpty(): boolean; + resize(options: { width: number; height: number }): { toDataURL(): string }; +}; + +export type FileIconProvider = ( + filePath: string, + options: { size: FileIconSizeBucket }, +) => Promise; + +export type AppIconResolver = (appPath: string, size: number) => string | null; + +export const FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES = 512; +export const APP_ICON_DATA_URL_CACHE_MAX_ENTRIES = 256; + +export class BoundedIconLruCache { + private readonly entries = new Map(); + + constructor(private readonly maxEntries: number) {} + + get size(): number { + return this.entries.size; + } + + get(key: string): Value | undefined { + if (!this.entries.has(key)) return undefined; + const value = this.entries.get(key) as Value; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: Value): void { + if (this.maxEntries <= 0) return; + if (this.entries.has(key)) this.entries.delete(key); + this.entries.set(key, value); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + clear(): void { + this.entries.clear(); + } + + keys(): string[] { + return Array.from(this.entries.keys()); + } +} + +export function normalizeIconLogicalSize(size: unknown, fallback: number): number { + const numeric = typeof size === 'number' ? size : Number(size); + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.max(1, Math.round(numeric)); +} + +export function getFileIconSizeBucket(size: number): FileIconSizeBucket { + if (size <= 16) return 'small'; + if (size >= 64) return 'large'; + return 'normal'; +} + +export function normalizeIconCachePath(filePath: string): string { + const raw = String(filePath || ''); + if (!raw) return ''; + return path.resolve(raw); +} + +export function buildFileIconCacheKey(filePath: string, size: unknown): string { + const logicalSize = normalizeIconLogicalSize(size, 20); + return [ + normalizeIconCachePath(filePath), + String(logicalSize), + getFileIconSizeBucket(logicalSize), + ].join('\0'); +} + +export function buildAppIconCacheKey(appPath: string, size: unknown): string { + const logicalSize = normalizeIconLogicalSize(size, 32); + return [normalizeIconCachePath(appPath), String(logicalSize)].join('\0'); +} + +export function createFileIconDataUrlCache(options: { + getFileIcon: FileIconProvider; + maxEntries?: number; +}) { + const cache = new BoundedIconLruCache( + options.maxEntries ?? FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES, + ); + const inFlight = new Map>(); + + async function resolve(filePath: string, size: unknown = 20): Promise { + const logicalSize = normalizeIconLogicalSize(size, 20); + const bucket = getFileIconSizeBucket(logicalSize); + const key = buildFileIconCacheKey(filePath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const pending = inFlight.get(key); + if (pending) return pending; + + const request = Promise.resolve() + .then(async () => { + const icon = await options.getFileIcon(filePath, { size: bucket }); + if (!icon || icon.isEmpty()) return null; + const dataUrl = icon.resize({ width: logicalSize, height: logicalSize }).toDataURL(); + if (typeof dataUrl === 'string') cache.set(key, dataUrl); + return typeof dataUrl === 'string' ? dataUrl : null; + }) + .catch(() => null) + .finally(() => { + inFlight.delete(key); + }); + + inFlight.set(key, request); + return request; + } + + return { + resolve, + clear: () => { + cache.clear(); + inFlight.clear(); + }, + stats: () => ({ + cacheSize: cache.size, + inFlightSize: inFlight.size, + keys: cache.keys(), + maxEntries: options.maxEntries ?? FILE_ICON_DATA_URL_CACHE_MAX_ENTRIES, + }), + }; +} + +export function createAppIconDataUrlCache(options: { + resolveAppIconDataUrl: AppIconResolver; + maxEntries?: number; +}) { + const cache = new BoundedIconLruCache( + options.maxEntries ?? APP_ICON_DATA_URL_CACHE_MAX_ENTRIES, + ); + const inFlight = new Map>(); + + function resolveFromSource(appPath: string, logicalSize: number, key: string): string | null { + try { + const dataUrl = options.resolveAppIconDataUrl(appPath, logicalSize); + if (typeof dataUrl === 'string') cache.set(key, dataUrl); + return typeof dataUrl === 'string' ? dataUrl : null; + } catch { + return null; + } + } + + function resolveSync(appPath: string, size: unknown = 32): string | null { + const logicalSize = normalizeIconLogicalSize(size, 32); + const key = buildAppIconCacheKey(appPath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + return resolveFromSource(appPath, logicalSize, key); + } + + async function resolve(appPath: string, size: unknown = 32): Promise { + const logicalSize = normalizeIconLogicalSize(size, 32); + const key = buildAppIconCacheKey(appPath, logicalSize); + const cached = cache.get(key); + if (cached !== undefined) return cached; + + const pending = inFlight.get(key); + if (pending) return pending; + + const request = Promise.resolve() + .then(() => resolveFromSource(appPath, logicalSize, key)) + .catch(() => null) + .finally(() => { + inFlight.delete(key); + }); + + inFlight.set(key, request); + return request; + } + + return { + resolve, + resolveSync, + clear: () => { + cache.clear(); + inFlight.clear(); + }, + stats: () => ({ + cacheSize: cache.size, + inFlightSize: inFlight.size, + keys: cache.keys(), + maxEntries: options.maxEntries ?? APP_ICON_DATA_URL_CACHE_MAX_ENTRIES, + }), + }; +} diff --git a/src/main/main.ts b/src/main/main.ts index cf510437..d6076ae2 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -86,6 +86,10 @@ import { evaluateRendererCrash, RENDERER_RECOVERY_DELAY_MS, } from './renderer-recovery'; +import { + createAppIconDataUrlCache, + createFileIconDataUrlCache, +} from './icon-ipc-cache'; import { startClipboardMonitor, stopClipboardMonitor, @@ -3072,6 +3076,10 @@ function resolveAppIconDataUrl(appPath: string, size = 32): string | null { return null; } } +const appIconDataUrlCache = createAppIconDataUrlCache({ resolveAppIconDataUrl }); +const fileIconDataUrlCache = createFileIconDataUrlCache({ + getFileIcon: (filePath, options) => app.getFileIcon(filePath, options), +}); let launcherEntryFrontmostApp: FrontmostAppContext | null = null; const registeredHotkeys = new Map(); // shortcut → commandId const activeAIRequests = new Map(); // requestId → controller @@ -15988,7 +15996,7 @@ return appURL's |path|() as text`, // actually targeted (bundlePath), so it does not depend on // lastFrontmostApp.path being populated. const iconPath = String(result?.appPath || targetAppPath || '').trim(); - const appIconDataUrl = iconPath ? resolveAppIconDataUrl(iconPath, 32) : null; + const appIconDataUrl = iconPath ? appIconDataUrlCache.resolveSync(iconPath, 32) : null; resolve({ ...result, appIconDataUrl }); } catch { resolve({ ok: false, error: stderr || 'Failed to parse menu item search output' }); @@ -16331,20 +16339,12 @@ return appURL's |path|() as text`, }); ipcMain.handle('get-file-icon-data-url', async (_event: any, filePath: string, size = 20) => { - try { - const icon = await app.getFileIcon(filePath, { size: size <= 16 ? 'small' : size >= 64 ? 'large' : 'normal' }); - if (icon && !icon.isEmpty()) { - return icon.resize({ width: size, height: size }).toDataURL(); - } - return null; - } catch { - return null; - } + return fileIconDataUrlCache.resolve(filePath, size); }); // Get .app bundle icon by reading its .icns file directly (avoids template-image transparency issues) ipcMain.handle('get-app-icon-data-url', async (_event: any, appPath: string, size = 32) => { - return resolveAppIconDataUrl(appPath, size); + return appIconDataUrlCache.resolve(appPath, size); }); ipcMain.handle('file-search-query', async (_event: any, query: string, options?: { limit?: number }) => {