Skip to content
Open
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
"node_modules/@esbuild/**",
"node_modules/node-edge-tts/**",
"node_modules/node-window-manager/**",
"node_modules/extract-file-icon/**",
"node_modules/node-gyp-build/**",
"node_modules/electron-liquid-glass/**"
],
"files": [
Expand Down
3 changes: 1 addition & 2 deletions scripts/measure-extension-bundle-cache.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,7 @@ async function main() {

const menuBar = await measure('repeated-menu-bar-background-prep', fixture.extDir, async () => {
for (let i = 0; i < iterations; i++) {
const commands = runner
.discoverInstalledExtensionCommands()
const commands = (await runner.discoverInstalledExtensionCommands())
.filter((command) => command.mode === 'menu-bar');
assert.equal(commands.length, 1);
const bundle = await runner.getExtensionBundle(commands[0].extName, commands[0].cmdName);
Expand Down
35 changes: 23 additions & 12 deletions src/main/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,21 @@ function getCommandsDiskCachePath(): string {
return commandsDiskCachePath;
}

function loadCommandsDiskCache(): CommandInfo[] | null {
async function loadCommandsDiskCache(): Promise<CommandInfo[] | null> {
try {
const raw = fs.readFileSync(getCommandsDiskCachePath(), 'utf-8');
const raw = await fs.promises.readFile(getCommandsDiskCachePath(), 'utf-8');
const parsed = JSON.parse(raw) as { version: number; commands: CommandInfo[] };
if (parsed?.version !== COMMANDS_DISK_CACHE_VERSION) return null;
const cmds = parsed.commands;
// Re-attach icons from the per-app icon disk cache (fast file reads).
for (const cmd of cmds) {
if (cmd.path) {
const icon = getCachedIcon(cmd.path);
if (icon) cmd.iconDataUrl = icon;
}
}
// Re-attach icons from the per-app icon disk cache. Reading them
// concurrently (fs.promises, backed by libuv's threadpool) instead of one
// fs.readFileSync per command avoids serializing every icon read on the
// main thread right before the first window is created.
await Promise.all(cmds.map(async (cmd) => {
if (!cmd.path) return;
const icon = await getCachedIconAsync(cmd.path);
if (icon) cmd.iconDataUrl = icon;
}));
return cmds;
} catch {
return null;
Expand All @@ -163,8 +165,8 @@ function saveCommandsDiskCache(commands: CommandInfo[]): void {
}

/** Call once after app.whenReady() to pre-populate the in-memory cache from disk. */
export function initCommandsCache(): void {
const cmds = loadCommandsDiskCache();
export async function initCommandsCache(): Promise<void> {
const cmds = await loadCommandsDiskCache();
if (cmds) {
cachedCommands = cmds;
staleCommandsFallback = cmds;
Expand Down Expand Up @@ -207,6 +209,15 @@ function getCachedIcon(bundlePath: string): string | undefined {
return undefined;
}

async function getCachedIconAsync(bundlePath: string): Promise<string | undefined> {
try {
const cacheFile = path.join(getIconCacheDir(), `${iconCacheKey(bundlePath)}.b64`);
return await fs.promises.readFile(cacheFile, 'utf-8');
} catch {
return undefined;
}
}

function setCachedIcon(bundlePath: string, dataUrl: string): void {
try {
const cacheFile = path.join(getIconCacheDir(), `${iconCacheKey(bundlePath)}.b64`);
Expand Down Expand Up @@ -1849,7 +1860,7 @@ async function discoverAndBuildCommands(): Promise<CommandInfo[]> {
// Installed community extensions
let extensionCommands: CommandInfo[] = [];
try {
extensionCommands = discoverInstalledExtensionCommands().map((ext) => ({
extensionCommands = (await discoverInstalledExtensionCommands()).map((ext) => ({
id: ext.id,
title: ext.title,
subtitle: ext.extensionTitle,
Expand Down
67 changes: 62 additions & 5 deletions src/main/extension-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,53 @@ interface CachedTextFile {
value: string;
}

/**
* Map-backed LRU cache with a hard size cap. Reads move the key to the
* most-recently-used position; inserts past the cap evict the oldest entry.
* Keeps long sessions that open many distinct extensions from accumulating
* unbounded bundle/manifest text in memory.
*/
class LruCache<K, V> {
private readonly map = new Map<K, V>();

constructor(private readonly maxEntries: number) {}

get(key: K): V | undefined {
const value = this.map.get(key);
if (value === undefined) return undefined;
this.map.delete(key);
this.map.set(key, value);
return value;
}

set(key: K, value: V): void {
this.map.delete(key);
this.map.set(key, value);
if (this.map.size > this.maxEntries) {
const oldestKey = this.map.keys().next().value as K;
this.map.delete(oldestKey);
}
}

delete(key: K): void {
this.map.delete(key);
}

clear(): void {
this.map.clear();
}

keys(): IterableIterator<K> {
return this.map.keys();
}
}

const MAX_EXTENSION_MANIFEST_CACHE_ENTRIES = 200;
const MAX_EXTENSION_BUNDLE_CACHE_ENTRIES = 40;

let _installedExtensionsSnapshot: InstalledExtensionsSnapshot | null = null;
const _extensionManifestCache = new Map<string, CachedManifest>();
const _extensionBundleCodeCache = new Map<string, CachedTextFile>();
const _extensionManifestCache = new LruCache<string, CachedManifest>(MAX_EXTENSION_MANIFEST_CACHE_ENTRIES);
const _extensionBundleCodeCache = new LruCache<string, CachedTextFile>(MAX_EXTENSION_BUNDLE_CACHE_ENTRIES);

function getManagedExtensionsDir(): string {
const dir = path.join(app.getPath('userData'), 'extensions');
Expand Down Expand Up @@ -497,13 +541,24 @@ function normalizePreferenceSchema(pref: any, scope: 'extension' | 'command'): E

// ─── Discovery ──────────────────────────────────────────────────────

const DISCOVERY_YIELD_EVERY_N_EXTENSIONS = 5;

/**
* Scan installed extensions directory and return a flat list of
* commands that should appear in the launcher.
*
* Yields back to the event loop every few extensions so a large install
* (many manifests + icon reads, all sync fs calls) doesn't monopolize the
* single main-process thread for the whole scan in one uninterrupted burst.
*/
export function discoverInstalledExtensionCommands(): ExtensionCommandInfo[] {
export async function discoverInstalledExtensionCommands(): Promise<ExtensionCommandInfo[]> {
const results: ExtensionCommandInfo[] = [];
for (const source of collectInstalledExtensions()) {
const sources = collectInstalledExtensions();
for (let i = 0; i < sources.length; i++) {
if (i > 0 && i % DISCOVERY_YIELD_EVERY_N_EXTENSIONS === 0) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
const source = sources[i];
const extPath = source.extPath;
const extName = source.extName;

Expand Down Expand Up @@ -1256,7 +1311,9 @@ export async function buildSingleCommand(extName: string, cmdName: string): Prom
extPath,
`${extName}/${cmdName}`
);
return fs.existsSync(outFile);
const built = fs.existsSync(outFile);
if (built) lastBuildError.delete(`${extName}/${cmdName}`);
return built;
} catch (e: any) {
console.error(` On-demand esbuild failed for ${extName}/${cmdName}:`, e);
lastBuildError.set(`${extName}/${cmdName}`, e?.message || String(e));
Expand Down
Loading