fix(electron): re-open a window when a second launch finds none

On macOS `window-all-closed` deliberately keeps the process alive while
the 'closed' handler clears App.mainWindow, so the single-instance guard
could hand a launch to a process with no window: focusExistingWindow(null)
did nothing and the new process quit, leaving the user with nothing on
screen.

The second-instance handler now re-creates the window when none is live,
via the same path the macOS dock 'activate' event uses. onActivate's body
moved to a public App.ensureMainWindow() so both entry points share it.

Reported by Codex review on #1272.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 20:08:13 +02:00
1 parent 917691b80c
commit f3425f8957
5 files changed
+116 -10

No files matched your search

@@ -168,6 +168,42 @@ test.describe('Electron Settings', () => {
await closeElectronApp(relaunch);
});
test('@settings @electron re-opens a window when a second launch arrives with none open', async ({
dataDir,
}) => {
// Only macOS keeps the process alive after its last window closes;
// elsewhere `window-all-closed` quits and the next launch is a plain
// cold start.
test.skip(
process.platform !== 'darwin',
'macOS-only windowless-process behaviour'
);
const runningApp = await launchElectronApp(dataDir);
try {
await runningApp.electronApp.evaluate(({ BrowserWindow }) => {
for (const window of BrowserWindow.getAllWindows()) {
window.close();
}
});
await expect
.poll(() => runningApp.electronApp.windows().length)
.toBe(0);
const recreatedWindow =
runningApp.electronApp.waitForEvent('window');
const competing = await launchCompetingElectronInstance(dataDir);
expect(competing.timedOut).toBe(false);
expect(competing.exitCode).toBe(0);
// Without this the guard would quit the launch into nothing and
// leave the user staring at no window at all.
await expect((await recreatedWindow).locator('body')).toBeVisible();
} finally {
await closeElectronApp(runningApp);
}
});
test('@settings @persistence @electron persists changed desktop settings across app restart', async ({
dataDir,
}) => {
+16 -3
View File
@@ -268,9 +268,16 @@ export default class App {
}
}
private static onActivate() {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
/**
* Brings the app back to a windowed state, re-creating the main window if
* it is gone.
*
* On macOS closing the last window deliberately keeps the process alive
* (`onWindowAllClosed`), so this is the recovery path for both the dock
* `activate` event and a second launch that the single-instance guard
* hands over to this process.
*/
static ensureMainWindow() {
if (App.mainWindow === null) {
App.onReady();
}
@@ -279,6 +286,12 @@ export default class App {
}
}
private static onActivate() {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
App.ensureMainWindow();
}
private static handleRendererNavigation(
event: Electron.Event,
url: string
@@ -59,7 +59,9 @@ describe('acquireSingleInstanceLock', () => {
it('continues startup and registers the focus handler when the lock is free', () => {
const app = createApp(true);
expect(acquireSingleInstanceLock(app, () => null, {})).toBe(true);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), {})
).toBe(true);
expect(app.quit).not.toHaveBeenCalled();
expect(app.on).toHaveBeenCalledWith(
'second-instance',
@@ -70,7 +72,9 @@ describe('acquireSingleInstanceLock', () => {
it('quits and stops startup when another instance owns the profile', () => {
const app = createApp(false);
expect(acquireSingleInstanceLock(app, () => null, {})).toBe(false);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), {})
).toBe(false);
expect(app.quit).toHaveBeenCalledTimes(1);
expect(app.on).not.toHaveBeenCalled();
});
@@ -82,20 +86,47 @@ describe('acquireSingleInstanceLock', () => {
isVisible: jest.fn().mockReturnValue(false),
});
acquireSingleInstanceLock(app, () => window, {});
const createMainWindow = jest.fn();
acquireSingleInstanceLock(app, () => window, createMainWindow, {});
const [, handler] = app.on.mock.calls[0];
(handler as () => void)();
expect(window.restore).toHaveBeenCalledTimes(1);
expect(window.show).toHaveBeenCalledTimes(1);
expect(window.focus).toHaveBeenCalledTimes(1);
expect(createMainWindow).not.toHaveBeenCalled();
});
// macOS keeps the process alive after the last window closes, so a second
// launch must rebuild a window instead of quitting into nothing.
it.each([
['no window exists', null],
['the window was destroyed', 'destroyed'],
])('re-creates the main window when %s', (_label, windowState) => {
const app = createApp(true);
const window =
windowState === 'destroyed'
? createWindow({
isDestroyed: jest.fn().mockReturnValue(true),
})
: null;
const createMainWindow = jest.fn();
acquireSingleInstanceLock(app, () => window, createMainWindow, {});
const [, handler] = app.on.mock.calls[0];
(handler as () => void)();
expect(createMainWindow).toHaveBeenCalledTimes(1);
if (window) {
expect(window.focus).not.toHaveBeenCalled();
}
});
it('skips the lock entirely when multiple instances are allowed', () => {
const app = createApp(false);
expect(
acquireSingleInstanceLock(app, () => null, {
acquireSingleInstanceLock(app, () => null, jest.fn(), {
[ALLOW_MULTIPLE_INSTANCES_ENV]: '1',
})
).toBe(true);
@@ -67,15 +67,27 @@ export function focusExistingWindow(
window.focus();
}
/** True when there is no live window left to bring forward. */
function needsNewWindow(
window: SingleInstanceWindow | null | undefined
): boolean {
return !window || window.isDestroyed();
}
/**
* Acquires the single instance lock.
*
* @param createMainWindow re-creates the main window when the lock owner has
* none left. On macOS closing the last window keeps the process alive, so
* without this a second launch would quit silently and leave the user with no
* window at all.
* @returns `true` when this process owns the profile and startup may continue,
* `false` when another instance already owns it and this one is quitting.
*/
export function acquireSingleInstanceLock(
app: SingleInstanceApp,
getMainWindow: () => SingleInstanceWindow | null | undefined,
createMainWindow: () => void,
env: NodeJS.ProcessEnv = process.env
): boolean {
if (allowsMultipleInstances(env)) {
@@ -88,7 +100,14 @@ export function acquireSingleInstanceLock(
}
app.on('second-instance', () => {
focusExistingWindow(getMainWindow());
const mainWindow = getMainWindow();
if (needsNewWindow(mainWindow)) {
createMainWindow();
return;
}
focusExistingWindow(mainWindow);
});
return true;
+9 -2
View File
@@ -197,8 +197,15 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => {
// A second instance sharing this userData directory cannot take the
// Chromium storage lock, so its renderer silently loses every settings
// write. Focus the window that already owns the profile instead.
if (!acquireSingleInstanceLock(app, () => App.mainWindow)) {
// write. Focus the window that already owns the profile instead — or
// re-create it, since on macOS the process outlives its last window.
if (
!acquireSingleInstanceLock(
app,
() => App.mainWindow,
() => App.ensureMainWindow()
)
) {
return;
}