From b0ca12192ebf00633de3cf41f45724e1ca0cd272 Mon Sep 17 00:00:00 2001 From: Andry Lloyd Paez <84067786+zeapsu@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:33:38 -0700 Subject: [PATCH] fix(desktop): restore closed main window on second launch (#64800) * fix(desktop): restore closed main window on second launch * fix(desktop): reset deep-link readiness when main window closes --- .../electron/main-window-lifecycle.test.ts | 69 +++++++++++++++++++ .../desktop/electron/main-window-lifecycle.ts | 28 ++++++++ apps/desktop/electron/main.ts | 30 +++++--- 3 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/electron/main-window-lifecycle.test.ts create mode 100644 apps/desktop/electron/main-window-lifecycle.ts diff --git a/apps/desktop/electron/main-window-lifecycle.test.ts b/apps/desktop/electron/main-window-lifecycle.test.ts new file mode 100644 index 000000000000..cef5243fc6d8 --- /dev/null +++ b/apps/desktop/electron/main-window-lifecycle.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { test } from 'vitest' + +import { ensureMainWindow } from './main-window-lifecycle' + +test('recreates a destroyed primary window without focusing it', () => { + const destroyedWindow = { + isDestroyed: () => true + } + let createCalls = 0 + let focusCalls = 0 + + ensureMainWindow(destroyedWindow, { + isReady: true, + createWindow: () => { + createCalls += 1 + }, + focusWindow: () => { + focusCalls += 1 + } + }) + + assert.equal(createCalls, 1) + assert.equal(focusCalls, 0) +}) + +test('waits for app readiness before recreating a primary window', () => { + let createCalls = 0 + + ensureMainWindow(null, { + isReady: false, + createWindow: () => { + createCalls += 1 + }, + focusWindow: () => assert.fail('missing window must not be focused') + }) + + assert.equal(createCalls, 0) +}) + +test('focuses a live primary window for a normal second launch', () => { + const liveWindow = { + isDestroyed: () => false + } + let focusedWindow = null + + ensureMainWindow(liveWindow, { + isReady: true, + createWindow: () => assert.fail('live window must not be replaced'), + focusWindow: window => { + focusedWindow = window + } + }) + + assert.equal(focusedWindow, liveWindow) +}) + +test('leaves live-window focus to deep-link delivery', () => { + const liveWindow = { + isDestroyed: () => false + } + + ensureMainWindow(liveWindow, { + isReady: true, + createWindow: () => assert.fail('live window must not be replaced'), + focusWindow: () => assert.fail('deep-link delivery owns focus'), + focusExisting: false + }) +}) diff --git a/apps/desktop/electron/main-window-lifecycle.ts b/apps/desktop/electron/main-window-lifecycle.ts new file mode 100644 index 000000000000..d3edc3b05e23 --- /dev/null +++ b/apps/desktop/electron/main-window-lifecycle.ts @@ -0,0 +1,28 @@ +type MainWindowLike = { + isDestroyed: () => boolean +} + +type EnsureMainWindowOptions = { + isReady: boolean + createWindow: () => unknown + focusWindow: (window: T) => unknown + focusExisting?: boolean +} + +export function ensureMainWindow( + window: T | null | undefined, + { isReady, createWindow, focusWindow, focusExisting = true }: EnsureMainWindowOptions +) { + if (!window || window.isDestroyed()) { + // a closed electron window stays truthy, so replace it before invoking native methods. + if (isReady) { + createWindow() + } + + return + } + + if (focusExisting) { + focusWindow(window) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d9c14e066019..29e38d6420f2 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -101,6 +101,7 @@ import { TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' +import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { @@ -7309,10 +7310,17 @@ function createWindow() { mainWindow.on('unmaximize', schedulePersistWindowState) mainWindow.on('close', () => schedulePersistWindowState.flush()) - // The overlay rides the main window — closing the app's primary window must - // tear it down too (otherwise it strands as an orphan that blocks - // window-all-closed from quitting on Windows/Linux). - mainWindow.on('closed', () => closePetOverlay()) + // the closed wrapper remains truthy, so clear only the window this callback owns. + const createdMainWindow = mainWindow + mainWindow.on('closed', () => { + closePetOverlay() + + if (mainWindow === createdMainWindow) { + mainWindow = null + // the replacement renderer must register before queued links can be delivered. + _rendererReadyForDeepLink = false + } + }) wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat')) @@ -9099,13 +9107,15 @@ if (!_gotSingleInstanceLock) { if (url) { handleDeepLink(url) - } else if (mainWindow) { - if (mainWindow.isMinimized()) { - mainWindow.restore() - } - - mainWindow.focus() } + + ensureMainWindow(mainWindow, { + isReady: app.isReady(), + createWindow, + focusWindow, + // deep-link delivery focuses a live window after its renderer is ready. + focusExisting: !url + }) }) }