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
112 changes: 71 additions & 41 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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();
});

Expand All @@ -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();
Expand Down
68 changes: 68 additions & 0 deletions electron/window-restore.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
43 changes: 43 additions & 0 deletions electron/window-restore.ts
Original file line number Diff line number Diff line change
@@ -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();
}