diff --git a/electron/main.ts b/electron/main.ts index cbeeba52..9947bae1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,5 +1,6 @@ import { app, autoUpdater, BrowserWindow, Menu, ipcMain, session, shell } from 'electron'; import { buildMenuTemplate } from './menu-template.js'; +import { restoreWindow } from './window-restore.js'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; @@ -80,7 +81,24 @@ function fixEnv(): void { } } -fixEnv(); +// One running copy per profile, and the lock is taken here rather than beside the +// window wiring because Electron's guidance is to take it as early as possible and +// this file gives that guidance teeth: fixEnv() above spawns an interactive login +// shell, which on a normal rc file (nvm, conda, compinit) costs on the order of half +// a second. A second launch is going to quit — spending that first would put the +// delay squarely on the icon-relaunch path the lock exists to make instant. +// +// Dev runs skip the lock deliberately, so `npm run dev` still starts while an +// installed build is running. +const isPrimaryInstance = !app.isPackaged || app.requestSingleInstanceLock(); + +if (!isPrimaryInstance) { + app.quit(); +} else { + // Only the primary instance ever spawns a PTY, so it is the only one that needs + // the resolved login-shell environment. + fixEnv(); +} // Blink evicts the oldest WebGL context past 16 per renderer process, and every // mounted terminal pane holds one — hidden task/tab terminals included. Past 16 @@ -226,41 +244,54 @@ function createWindow() { }); } -app.whenReady().then(async () => { - // Grant microphone and clipboard access (deny camera/video) - session.defaultSession.setPermissionRequestHandler( - (_webContents, permission, callback, details) => { - if (permission === 'clipboard-read' || permission === 'clipboard-sanitized-write') { - return callback(true); - } - if (permission === 'media') { - const types = (details as { mediaTypes?: string[] }).mediaTypes ?? []; - return callback(types.every((t) => t === 'audio')); - } - callback(false); - }, - ); +// Why the lock matters here: "Keep them alive in the background" hides the window +// instead of closing it, so a user who launches the app again is asking for the +// window they already have. Without the lock a second process starts, restores +// every persisted session from the same state file, and spawns a duplicate agent +// for each one — on top of the PTYs the hidden instance is still holding. The +// hidden window has no way back either, because nothing is listening for the +// launch. With the lock, a second launch becomes "show the window". +if (isPrimaryInstance) { + // A second launch (icon, CLI, file manager) reaches the instance that owns + // the lock as this event instead of starting a process of its own. + app.on('second-instance', () => restoreWindow(mainWindow)); - // electron-updater stages the install, then quits through `app.quit()`. - // Vetoing that quit below would leave the update staged with the app still - // running, so let it through — the window's own close prompt still asks about - // running terminals, and `autoInstallOnAppQuit` re-applies the update on the - // next quit if the user backs out. Both platform paths announce the relaunch - // on Electron's own updater immediately before quitting (the AppImage updater - // emits it by hand, Squirrel natively), so this is set only while a quit is - // genuinely in flight — unlike a flag set when the install is *requested*, - // which sticks for the whole session on the many paths where - // `quitAndInstall()` returns without quitting. - autoUpdater.on('before-quit-for-update', () => { - quittingForUpdate = true; - }); + app.whenReady().then(async () => { + // Grant microphone and clipboard access (deny camera/video) + session.defaultSession.setPermissionRequestHandler( + (_webContents, permission, callback, details) => { + if (permission === 'clipboard-read' || permission === 'clipboard-sanitized-write') { + return callback(true); + } + if (permission === 'media') { + const types = (details as { mediaTypes?: string[] }).mediaTypes ?? []; + return callback(types.every((t) => t === 'audio')); + } + callback(false); + }, + ); - // Listening before the window exists: a renderer cannot spawn a Claude - // agent that misses its hooks. Failure falls back to PTY heuristics. - await startAgentHookRuntime(() => mainWindow); - setupApplicationMenu(); - createWindow(); -}); + // electron-updater stages the install, then quits through `app.quit()`. + // Vetoing that quit below would leave the update staged with the app still + // running, so let it through — the window's own close prompt still asks about + // running terminals, and `autoInstallOnAppQuit` re-applies the update on the + // next quit if the user backs out. Both platform paths announce the relaunch + // on Electron's own updater immediately before quitting (the AppImage updater + // emits it by hand, Squirrel natively), so this is set only while a quit is + // genuinely in flight — unlike a flag set when the install is *requested*, + // which sticks for the whole session on the many paths where + // `quitAndInstall()` returns without quitting. + autoUpdater.on('before-quit-for-update', () => { + quittingForUpdate = true; + }); + + // Listening before the window exists: a renderer cannot spawn a Claude + // agent that misses its hooks. Failure falls back to PTY heuristics. + await startAgentHookRuntime(() => mainWindow); + setupApplicationMenu(); + createWindow(); + }); +} // A quit reaches `before-quit` *before* any window `close` event, so tearing // down agents here destroyed the very terminals the close dialog was about to @@ -274,9 +305,9 @@ app.whenReady().then(async () => { app.on('before-quit', (event) => { if (!mainWindow || mainWindow.isDestroyed() || quittingForUpdate) return; event.preventDefault(); - // The confirmation is a sheet on this window, and show() also focuses — a - // quit from the menu while the app sits hidden must not prompt invisibly. - mainWindow.show(); + // The confirmation is a sheet on this window — a quit from the menu while the + // app sits hidden or minimized must not prompt somewhere the user cannot see. + restoreWindow(mainWindow); mainWindow.close(); }); @@ -292,10 +323,9 @@ app.on('will-quit', () => { }); // "Keep them alive in the background" hides the window; without this the dock -// icon is a dead end and the only way back is attempting to quit. -app.on('activate', () => { - mainWindow?.show(); -}); +// icon is a dead end and the only way back is attempting to quit. `show()` alone +// left a minimized or buried window where it was — see restoreWindow. +app.on('activate', () => restoreWindow(mainWindow)); app.on('window-all-closed', () => { app.quit(); diff --git a/electron/window-restore.test.ts b/electron/window-restore.test.ts new file mode 100644 index 00000000..1f3f5775 --- /dev/null +++ b/electron/window-restore.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { restoreWindow, type RestorableWindow } from './window-restore.js'; + +interface FakeWindow extends RestorableWindow { + calls: string[]; +} + +function fakeWindow( + state: { destroyed?: boolean; visible?: boolean; minimized?: boolean } = {}, +): FakeWindow { + const calls: string[] = []; + return { + calls, + isDestroyed: () => state.destroyed ?? false, + isVisible: () => state.visible ?? true, + isMinimized: () => state.minimized ?? false, + show: () => void calls.push('show'), + restore: () => void calls.push('restore'), + focus: () => void calls.push('focus'), + }; +} + +describe('restoreWindow', () => { + // The case the whole function exists for: "Keep them alive in the background" + // hides the window, and without `show()` there is no way back to it at all. + it('shows a hidden window and focuses it', () => { + const win = fakeWindow({ visible: false }); + restoreWindow(win); + expect(win.calls).toEqual(['show', 'focus']); + }); + + // A handler that only called `show()` would leave a minimized window where it + // was: `restore()` is the call that un-minimizes. + it('restores a minimized window and focuses it', () => { + const win = fakeWindow({ minimized: true }); + restoreWindow(win); + expect(win.calls).toEqual(['restore', 'focus']); + }); + + // The two states are not exclusive, and the function must not treat them as + // such — a window can be hidden and minimized at the same time. + it('handles a window that is both hidden and minimized', () => { + const win = fakeWindow({ visible: false, minimized: true }); + restoreWindow(win); + expect(win.calls).toEqual(['show', 'restore', 'focus']); + }); + + // Visible but buried behind another app: nothing to show or restore, but the + // user asked for this window, so it still has to come forward. + it('focuses a window that is already visible', () => { + const win = fakeWindow(); + restoreWindow(win); + expect(win.calls).toEqual(['focus']); + }); + + // The window is nulled on `closed`, but these events can arrive in the gap + // before that fires, and calling into a destroyed window throws. + it('is a no-op for a destroyed window', () => { + const win = fakeWindow({ destroyed: true, visible: false, minimized: true }); + expect(() => restoreWindow(win)).not.toThrow(); + expect(win.calls).toEqual([]); + }); + + it('is a no-op for a missing window', () => { + expect(() => restoreWindow(null)).not.toThrow(); + expect(() => restoreWindow(undefined)).not.toThrow(); + }); +}); diff --git a/electron/window-restore.ts b/electron/window-restore.ts new file mode 100644 index 00000000..b0277f98 --- /dev/null +++ b/electron/window-restore.ts @@ -0,0 +1,43 @@ +// Bringing the main window back from wherever the user left it. +// +// "Keep them alive in the background" hides the window rather than closing it, +// which is the whole point — the agents keep running. But a hidden window is +// only useful if there is a way back to it, and the entry points that ask for +// it (a dock click, a second launch) can arrive with the window hidden, +// minimized, or merely behind another app. Each of those needs a different call, +// so they all route through one function that makes all three rather than each +// caller guessing which one applies. +// +// Wayland caveat: a client generally cannot raise itself there, and Electron's +// own docs say `focus()` on Wayland "may show a notification or flash the app +// icon" instead. So the visible-but-buried case can end at an icon flash rather +// than a raise, depending on the compositor. Hidden and minimized are unaffected. +// +// Typed structurally instead of against `BrowserWindow` so the behaviour can be +// tested without an Electron runtime. `BrowserWindow` satisfies this shape. +export interface RestorableWindow { + isDestroyed(): boolean; + isVisible(): boolean; + isMinimized(): boolean; + show(): void; + restore(): void; + focus(): void; +} + +/** + * Bring `win` back into view, whatever state it is in: hidden, minimized, + * behind another app, or any combination. + * + * A no-op for a missing or destroyed window — the window is set to null on + * `closed`, but the events that call this can arrive in the gap before that + * fires, and calling into a destroyed window throws. + */ +export function restoreWindow(win: RestorableWindow | null | undefined): void { + if (!win || win.isDestroyed()) return; + // Both questions get asked, and each answer gets acted on independently: the + // two states are not exclusive, and how a minimized window reports its + // visibility is not something to depend on. + if (!win.isVisible()) win.show(); + if (win.isMinimized()) win.restore(); + win.focus(); +}