From 5e508147c4688c3459962884eb540ec8ae97181e Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 20 Aug 2026 23:32:08 +0900 Subject: [PATCH] perf(electron): defer hidden-start renderer creation (#10327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — locally validated (6/6 focused lazy-window tests, plus the wider electron suite, typecheck:core clean, gates green). Thanks! --- electron/lib/windowLifecycle.js | 28 +++++++ electron/main.js | 68 +++++++-------- electron/package.json | 1 + tests/unit/electron-lazy-window.test.ts | 105 ++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 electron/lib/windowLifecycle.js create mode 100644 tests/unit/electron-lazy-window.test.ts diff --git a/electron/lib/windowLifecycle.js b/electron/lib/windowLifecycle.js new file mode 100644 index 0000000000..8a75a4a18d --- /dev/null +++ b/electron/lib/windowLifecycle.js @@ -0,0 +1,28 @@ +/** Pure helpers for deciding and driving the Electron dashboard window lifecycle. */ + +function shouldStartHidden({ argv = [], loginItemSettings = {} } = {}) { + return ( + argv.includes("--hidden") || + argv.includes("--minimized") || + loginItemSettings.wasOpenedAsHidden === true + ); +} + +function showOrCreateWindow({ appReady, getWindow, createWindow }) { + if (!appReady) return null; + + const currentWindow = getWindow(); + if (!currentWindow || currentWindow.isDestroyed()) { + return createWindow(); + } + + if (currentWindow.isMinimized()) currentWindow.restore(); + currentWindow.show(); + currentWindow.focus(); + return currentWindow; +} + +module.exports = { + shouldStartHidden, + showOrCreateWindow, +}; diff --git a/electron/main.js b/electron/main.js index 61f9e784d9..f2e3a4e3d8 100644 --- a/electron/main.js +++ b/electron/main.js @@ -40,6 +40,7 @@ const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper"); const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl"); const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences"); const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness"); +const { shouldStartHidden, showOrCreateWindow } = require("./lib/windowLifecycle"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -49,11 +50,7 @@ if (!gotTheLock) { } app.on("second-instance", () => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - } + showMainWindow(); }); // ── Environment Detection ────────────────────────────────── @@ -71,6 +68,7 @@ let nextServer = null; let serverPort = 20128; let isServerStopped = false; let remoteServerPromptWindow = null; +let keepAliveWithoutWindows = false; // ── Remote Server Mode ────────────────────────────────────── // Lets the desktop shell attach to an already-running OmniRoute server (e.g. a @@ -366,6 +364,8 @@ function setupContentSecurityPolicy() { // ── Create Window ────────────────────────────────────────── function createWindow() { + if (mainWindow && !mainWindow.isDestroyed()) return mainWindow; + // Platform-conditional options (#9) const platformWindowOptions = process.platform === "darwin" @@ -397,16 +397,10 @@ function createWindow() { mainWindow.webContents.openDevTools({ mode: "detach" }); } - // Show window when ready (unless starting minimized/hidden in tray) + // Hidden startup skips createWindow() entirely; any created dashboard is explicit. mainWindow.once("ready-to-show", () => { - const startHidden = - process.argv.includes("--hidden") || - process.argv.includes("--minimized") || - app.getLoginItemSettings().wasOpenedAsHidden; - if (!startHidden) { + if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); - } else { - console.log("[Electron] Launched hidden in background tray"); } }); @@ -437,6 +431,16 @@ function createWindow() { mainWindow.on("closed", () => { mainWindow = null; }); + + return mainWindow; +} + +function showMainWindow() { + return showOrCreateWindow({ + appReady: app.isReady(), + getWindow: () => mainWindow, + createWindow, + }); } // ── System Tray ──────────────────────────────────────────── @@ -465,12 +469,7 @@ function createTray() { const contextMenu = Menu.buildFromTemplate([ { label: "Open OmniRoute", - click: () => { - if (mainWindow) { - mainWindow.show(); - mainWindow.focus(); - } - }, + click: () => showMainWindow(), }, { label: "Open Dashboard", @@ -523,10 +522,7 @@ function createTray() { tray.setContextMenu(contextMenu); tray.on("double-click", () => { - if (mainWindow) { - mainWindow.show(); - mainWindow.focus(); - } + showMainWindow(); }); } @@ -1094,9 +1090,20 @@ app.whenReady().then(async () => { process.argv.includes("--headless") || process.argv.includes("--cli") || process.env.OMNIROUTE_HEADLESS === "true"; + const startHidden = + !isHeadless && + shouldStartHidden({ + argv: process.argv, + loginItemSettings: app.getLoginItemSettings(), + }); + keepAliveWithoutWindows = startHidden; // Fix #1: Start server and WAIT for readiness before showing window startNextServer(); + if (!isHeadless) { + createTray(); + } + let serverReady = true; if (!isDev) { // Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state. @@ -1105,9 +1112,10 @@ app.whenReady().then(async () => { if (isHeadless) { console.log("[Electron] Headless mode active — UI window and tray icon skipped"); + } else if (startHidden) { + console.log("[Electron] Launched hidden in background tray without a renderer"); } else { - createWindow(); - createTray(); + showMainWindow(); } setupIpcHandlers(); @@ -1115,7 +1123,7 @@ app.whenReady().then(async () => { // If readiness timed out (e.g. very long first-launch migrations), don't leave the // window stuck on a hanging connection — keep polling and reload once it responds (#2460). - if (!isDev && !serverReady && !isHeadless) { + if (!isDev && !serverReady && !isHeadless && !startHidden) { void waitForServer(getServerReadinessUrl(), 300000).then((ready) => { if (ready && mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL(getServerUrl()); @@ -1133,11 +1141,7 @@ app.whenReady().then(async () => { // macOS: recreate window when dock icon clicked app.on("activate", () => { if (isHeadless) return; - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } else if (mainWindow) { - mainWindow.show(); - } + showMainWindow(); }); }); @@ -1147,7 +1151,7 @@ app.on("window-all-closed", () => { process.argv.includes("--headless") || process.argv.includes("--cli") || process.env.OMNIROUTE_HEADLESS === "true"; - if (process.platform !== "darwin" && !isHeadless) { + if (process.platform !== "darwin" && !isHeadless && !keepAliveWithoutWindows) { app.quit(); } }); diff --git a/electron/package.json b/electron/package.json index a3793de8fa..8a895b3566 100644 --- a/electron/package.json +++ b/electron/package.json @@ -64,6 +64,7 @@ "remoteServerPromptRenderer.js", "lib/resolveServerEntry.js", "lib/resolveNodeHelper.js", + "lib/windowLifecycle.js", "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", "lib/serverReadiness.js", diff --git a/tests/unit/electron-lazy-window.test.ts b/tests/unit/electron-lazy-window.test.ts new file mode 100644 index 0000000000..d3a4440c8b --- /dev/null +++ b/tests/unit/electron-lazy-window.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; + +const require = createRequire(import.meta.url); +const { shouldStartHidden, showOrCreateWindow } = require("../../electron/lib/windowLifecycle"); + +describe("Electron hidden-start window lifecycle", () => { + it("detects explicit hidden flags and OS login-item hidden launches", () => { + assert.equal(shouldStartHidden({ argv: ["electron", "--hidden"] }), true); + assert.equal(shouldStartHidden({ argv: ["electron", "--minimized"] }), true); + assert.equal( + shouldStartHidden({ argv: ["electron"], loginItemSettings: { wasOpenedAsHidden: true } }), + true + ); + assert.equal(shouldStartHidden({ argv: ["electron"], loginItemSettings: {} }), false); + }); + + it("creates the dashboard only when an explicit open action has no live window", () => { + const createdWindow = { id: "created" }; + let createCalls = 0; + const result = showOrCreateWindow({ + appReady: true, + getWindow: () => null, + createWindow: () => { + createCalls += 1; + return createdWindow; + }, + }); + + assert.equal(result, createdWindow); + assert.equal(createCalls, 1); + }); + + it("restores, shows, and focuses an existing dashboard without recreating it", () => { + const calls: string[] = []; + const existingWindow = { + isDestroyed: () => false, + isMinimized: () => true, + restore: () => calls.push("restore"), + show: () => calls.push("show"), + focus: () => calls.push("focus"), + }; + + const result = showOrCreateWindow({ + appReady: true, + getWindow: () => existingWindow, + createWindow: () => { + throw new Error("must not recreate a live dashboard"); + }, + }); + + assert.equal(result, existingWindow); + assert.deepEqual(calls, ["restore", "show", "focus"]); + }); + + it("does not create a BrowserWindow before Electron is ready", () => { + let createCalls = 0; + const result = showOrCreateWindow({ + appReady: false, + getWindow: () => null, + createWindow: () => { + createCalls += 1; + }, + }); + + assert.equal(result, null); + assert.equal(createCalls, 0); + }); + + it("routes tray, second-instance, and macOS activation opens through the lazy helper", () => { + const mainSource = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + + assert.match(mainSource, /app\.on\("second-instance", \(\) => \{\s*showMainWindow\(\);/); + assert.match(mainSource, /label: "Open OmniRoute",\s*click: \(\) => showMainWindow\(\)/); + assert.match(mainSource, /tray\.on\("double-click", \(\) => \{\s*showMainWindow\(\);/); + assert.match(mainSource, /app\.on\("activate", \(\) => \{[\s\S]*?showMainWindow\(\);/); + }); + + it("keeps hidden startup renderer-free until an explicit open action", () => { + const mainSource = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + const readyBlock = mainSource.slice(mainSource.indexOf("app.whenReady().then")); + + assert.match( + readyBlock, + /startNextServer\(\);\s*if \(!isHeadless\) \{\s*createTray\(\);\s*\}/, + "the server and tray must start before the hidden/visible renderer decision" + ); + assert.match( + readyBlock, + /if \(isHeadless\)[\s\S]*?else if \(startHidden\)[\s\S]*?else \{\s*showMainWindow\(\);/ + ); + assert.doesNotMatch( + readyBlock.match(/else if \(startHidden\)[\s\S]*?\} else \{/s)?.[0] ?? "", + /createWindow\(|showMainWindow\(/ + ); + assert.match( + readyBlock, + /\}\s*setupIpcHandlers\(\);\s*setupAutoUpdater\(\);/, + "IPC and updater setup must remain active when no renderer was created" + ); + }); +});