Skip to content

Commit 280a4bc

Browse files
committed
perf(electron): optionally unload renderer on close
1 parent 63a6618 commit 280a4bc

6 files changed

Lines changed: 278 additions & 54 deletions

File tree

electron/lib/remoteServerPreferences.js

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ const path = require("path");
55

66
/**
77
* remoteServerPreferences.js — pure read/write helpers for the small JSON
8-
* preferences file that persists the operator-configured remote server URL
9-
* across app restarts (see resolveRemoteServerUrl.js for how it's consumed).
8+
* preferences file that persists desktop-shell choices needed before the
9+
* server-owned settings database is available.
1010
*
1111
* Deliberately a plain flat JSON file rather than the app's SQLite database:
1212
* this preference must be readable before deciding whether to spawn (or even
@@ -18,19 +18,20 @@ const path = require("path");
1818
* @param {string} prefsPath - absolute path to electron-preferences.json
1919
* @param {(p: string) => boolean} [existsSync]
2020
* @param {(p: string, enc: string) => string} [readFileSync]
21-
* @returns {{remoteServerUrl: string|null}}
21+
* @returns {{remoteServerUrl: string|null, closeBehavior: "keep-loaded"|"unload"}}
2222
*/
2323
function readPreferences(prefsPath, existsSync = fs.existsSync, readFileSync = fs.readFileSync) {
24-
if (!existsSync(prefsPath)) return { remoteServerUrl: null };
24+
if (!existsSync(prefsPath)) return { remoteServerUrl: null, closeBehavior: "keep-loaded" };
2525
try {
2626
const parsed = JSON.parse(readFileSync(prefsPath, "utf8"));
2727
const remoteServerUrl =
2828
typeof parsed.remoteServerUrl === "string" && parsed.remoteServerUrl.trim()
2929
? parsed.remoteServerUrl.trim()
3030
: null;
31-
return { remoteServerUrl };
31+
const closeBehavior = parsed.closeBehavior === "unload" ? "unload" : "keep-loaded";
32+
return { remoteServerUrl, closeBehavior };
3233
} catch {
33-
return { remoteServerUrl: null };
34+
return { remoteServerUrl: null, closeBehavior: "keep-loaded" };
3435
}
3536
}
3637

@@ -72,4 +73,35 @@ function writeRemoteServerUrl(
7273
}
7374
}
7475

75-
module.exports = { readPreferences, writeRemoteServerUrl };
76+
/** Persist whether closing the dashboard hides it or unloads its renderer. */
77+
function writeCloseBehavior(
78+
prefsPath,
79+
closeBehavior,
80+
{
81+
existsSync = fs.existsSync,
82+
readFileSync = fs.readFileSync,
83+
writeFileSync = fs.writeFileSync,
84+
mkdirSync = fs.mkdirSync,
85+
} = {}
86+
) {
87+
try {
88+
const dir = path.dirname(prefsPath);
89+
if (!existsSync(dir)) {
90+
mkdirSync(dir, { recursive: true });
91+
}
92+
93+
const current = readPreferences(prefsPath, existsSync, readFileSync);
94+
const next = {
95+
...current,
96+
closeBehavior: closeBehavior === "unload" ? "unload" : "keep-loaded",
97+
};
98+
writeFileSync(prefsPath, JSON.stringify(next, null, 2) + "\n", "utf8");
99+
} catch (err) {
100+
console.error(
101+
`[remoteServerPreferences] Failed to write preferences to ${prefsPath}:`,
102+
err instanceof Error ? err.message : String(err)
103+
);
104+
}
105+
}
106+
107+
module.exports = { readPreferences, writeRemoteServerUrl, writeCloseBehavior };

electron/lib/windowClosePolicy.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"use strict";
2+
3+
const CLOSE_BEHAVIOR_KEEP_LOADED = "keep-loaded";
4+
const CLOSE_BEHAVIOR_UNLOAD = "unload";
5+
6+
function normalizeCloseBehavior(value) {
7+
if (value === CLOSE_BEHAVIOR_KEEP_LOADED || value === CLOSE_BEHAVIOR_UNLOAD) return value;
8+
return null;
9+
}
10+
11+
function resolveRendererUrl(currentUrl, serverUrl) {
12+
try {
13+
const current = new URL(currentUrl);
14+
const server = new URL(serverUrl);
15+
return current.origin === server.origin ? current.href : server.href;
16+
} catch {
17+
return serverUrl;
18+
}
19+
}
20+
21+
module.exports = {
22+
CLOSE_BEHAVIOR_KEEP_LOADED,
23+
CLOSE_BEHAVIOR_UNLOAD,
24+
normalizeCloseBehavior,
25+
resolveRendererUrl,
26+
};

electron/main.js

Lines changed: 95 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,18 @@ const { killProcessTree } = require("./processTree");
3838
const { resolveServerEntry } = require("./lib/resolveServerEntry");
3939
const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper");
4040
const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl");
41-
const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences");
41+
const {
42+
readPreferences,
43+
writeRemoteServerUrl,
44+
writeCloseBehavior,
45+
} = require("./lib/remoteServerPreferences");
4246
const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness");
47+
const {
48+
CLOSE_BEHAVIOR_KEEP_LOADED,
49+
CLOSE_BEHAVIOR_UNLOAD,
50+
normalizeCloseBehavior,
51+
resolveRendererUrl,
52+
} = require("./lib/windowClosePolicy");
4353

4454
// ── Single Instance Lock ───────────────────────────────────
4555
const gotTheLock = app.requestSingleInstanceLock();
@@ -49,11 +59,12 @@ if (!gotTheLock) {
4959
}
5060

5161
app.on("second-instance", () => {
52-
if (mainWindow) {
53-
if (mainWindow.isMinimized()) mainWindow.restore();
54-
mainWindow.show();
55-
mainWindow.focus();
56-
}
62+
const isHeadless =
63+
process.argv.includes("--headless") ||
64+
process.argv.includes("--cli") ||
65+
process.env.OMNIROUTE_HEADLESS === "true";
66+
if (isHeadless) return;
67+
showMainWindow();
5768
});
5869

5970
// ── Environment Detection ──────────────────────────────────
@@ -71,6 +82,7 @@ let nextServer = null;
7182
let serverPort = 20128;
7283
let isServerStopped = false;
7384
let remoteServerPromptWindow = null;
85+
let lastRendererUrl = null;
7486

7587
// ── Remote Server Mode ──────────────────────────────────────
7688
// Lets the desktop shell attach to an already-running OmniRoute server (e.g. a
@@ -81,6 +93,8 @@ const REMOTE_SERVER_PREFS_PATH = path.join(
8193
resolveDataDir(null, process.env),
8294
"electron-preferences.json"
8395
);
96+
const electronPreferences = readPreferences(REMOTE_SERVER_PREFS_PATH);
97+
let closeBehavior = electronPreferences.closeBehavior;
8498
let remoteServerUrl = resolveRemoteServerUrl({
8599
env: process.env,
86100
prefsPath: REMOTE_SERVER_PREFS_PATH,
@@ -365,14 +379,18 @@ function setupContentSecurityPolicy() {
365379
}
366380

367381
// ── Create Window ──────────────────────────────────────────
368-
function createWindow() {
382+
function createWindow({ showWhenReady = true } = {}) {
383+
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
384+
385+
const rendererStartedAt = Date.now();
386+
369387
// Platform-conditional options (#9)
370388
const platformWindowOptions =
371389
process.platform === "darwin"
372390
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
373391
: { titleBarStyle: "default" };
374392

375-
mainWindow = new BrowserWindow({
393+
const window = new BrowserWindow({
376394
width: 1400,
377395
height: 900,
378396
minWidth: 1024,
@@ -390,28 +408,25 @@ function createWindow() {
390408
backgroundColor: "#0a0a0a",
391409
...platformWindowOptions,
392410
});
411+
mainWindow = window;
393412

394413
// Load the Next.js app
395-
mainWindow.loadURL(getServerUrl());
414+
window.loadURL(resolveRendererUrl(lastRendererUrl, getServerUrl()));
396415
if (isDev) {
397-
mainWindow.webContents.openDevTools({ mode: "detach" });
416+
window.webContents.openDevTools({ mode: "detach" });
398417
}
399418

400-
// Show window when ready (unless starting minimized/hidden in tray)
401-
mainWindow.once("ready-to-show", () => {
402-
const startHidden =
403-
process.argv.includes("--hidden") ||
404-
process.argv.includes("--minimized") ||
405-
app.getLoginItemSettings().wasOpenedAsHidden;
406-
if (!startHidden) {
407-
mainWindow.show();
419+
window.once("ready-to-show", () => {
420+
console.log(`[Electron] Renderer ready in ${Date.now() - rendererStartedAt}ms`);
421+
if (showWhenReady) {
422+
window.show();
408423
} else {
409424
console.log("[Electron] Launched hidden in background tray");
410425
}
411426
});
412427

413428
// Handle external links — validate URL protocol to prevent RCE
414-
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
429+
window.webContents.setWindowOpenHandler(({ url }) => {
415430
try {
416431
const parsedUrl = new URL(url);
417432
if (["http:", "https:"].includes(parsedUrl.protocol)) {
@@ -425,18 +440,46 @@ function createWindow() {
425440
return { action: "deny" };
426441
});
427442

428-
// Handle window close — minimize to tray
429-
mainWindow.on("close", (event) => {
443+
// Keep the server alive while either hiding the renderer for a fast reopen or
444+
// unloading it to reclaim memory, according to the persisted tray preference.
445+
window.on("close", (event) => {
430446
if (!app.isQuitting) {
431447
event.preventDefault();
432-
mainWindow.hide();
448+
lastRendererUrl = resolveRendererUrl(window.webContents.getURL(), getServerUrl());
449+
if (closeBehavior === CLOSE_BEHAVIOR_UNLOAD) {
450+
console.log("[Electron] Dashboard renderer unloaded; server remains running");
451+
window.destroy();
452+
} else {
453+
console.log("[Electron] Dashboard hidden; renderer kept loaded");
454+
window.hide();
455+
}
433456
}
434457
return false;
435458
});
436459

437-
mainWindow.on("closed", () => {
438-
mainWindow = null;
460+
window.on("closed", () => {
461+
if (mainWindow === window) mainWindow = null;
439462
});
463+
464+
return window;
465+
}
466+
467+
function showMainWindow() {
468+
if (!mainWindow || mainWindow.isDestroyed()) {
469+
createWindow();
470+
return;
471+
}
472+
if (mainWindow.isMinimized()) mainWindow.restore();
473+
mainWindow.show();
474+
mainWindow.focus();
475+
}
476+
477+
function setCloseBehavior(nextBehavior) {
478+
const normalized = normalizeCloseBehavior(nextBehavior);
479+
if (!normalized || normalized === closeBehavior) return;
480+
closeBehavior = normalized;
481+
writeCloseBehavior(REMOTE_SERVER_PREFS_PATH, closeBehavior);
482+
createTray();
440483
}
441484

442485
// ── System Tray ────────────────────────────────────────────
@@ -465,12 +508,7 @@ function createTray() {
465508
const contextMenu = Menu.buildFromTemplate([
466509
{
467510
label: "Open OmniRoute",
468-
click: () => {
469-
if (mainWindow) {
470-
mainWindow.show();
471-
mainWindow.focus();
472-
}
473-
},
511+
click: () => showMainWindow(),
474512
},
475513
{
476514
label: "Open Dashboard",
@@ -504,6 +542,23 @@ function createTray() {
504542
},
505543
],
506544
},
545+
{
546+
label: "When Dashboard Closes",
547+
submenu: [
548+
{
549+
label: "Keep Loaded (Faster Reopen)",
550+
type: "radio",
551+
checked: closeBehavior === CLOSE_BEHAVIOR_KEEP_LOADED,
552+
click: () => setCloseBehavior(CLOSE_BEHAVIOR_KEEP_LOADED),
553+
},
554+
{
555+
label: "Unload Renderer (Lower Memory)",
556+
type: "radio",
557+
checked: closeBehavior === CLOSE_BEHAVIOR_UNLOAD,
558+
click: () => setCloseBehavior(CLOSE_BEHAVIOR_UNLOAD),
559+
},
560+
],
561+
},
507562
{ type: "separator" },
508563
{
509564
label: "Check for Updates",
@@ -522,12 +577,7 @@ function createTray() {
522577
tray.setToolTip("OmniRoute");
523578
tray.setContextMenu(contextMenu);
524579

525-
tray.on("double-click", () => {
526-
if (mainWindow) {
527-
mainWindow.show();
528-
mainWindow.focus();
529-
}
530-
});
580+
tray.on("double-click", () => showMainWindow());
531581
}
532582

533583
// ── Change Port (#3: now restarts server) ──────────────────
@@ -549,6 +599,7 @@ async function changePort(newPort) {
549599
await waitForServer(getServerReadinessUrl());
550600

551601
// Reload window and update tray
602+
lastRendererUrl = getServerUrl();
552603
if (mainWindow && !mainWindow.isDestroyed()) {
553604
mainWindow.loadURL(getServerUrl());
554605
}
@@ -613,6 +664,7 @@ async function setRemoteServerUrl(nextUrl) {
613664

614665
remoteServerUrl = normalized;
615666
writeRemoteServerUrl(REMOTE_SERVER_PREFS_PATH, remoteServerUrl);
667+
lastRendererUrl = getServerUrl();
616668

617669
startNextServer();
618670
try {
@@ -1098,7 +1150,11 @@ app.whenReady().then(async () => {
10981150
if (isHeadless) {
10991151
console.log("[Electron] Headless mode active — UI window and tray icon skipped");
11001152
} else {
1101-
createWindow();
1153+
const startHidden =
1154+
process.argv.includes("--hidden") ||
1155+
process.argv.includes("--minimized") ||
1156+
app.getLoginItemSettings().wasOpenedAsHidden;
1157+
createWindow({ showWhenReady: !startHidden });
11021158
createTray();
11031159
}
11041160

@@ -1125,11 +1181,7 @@ app.whenReady().then(async () => {
11251181
// macOS: recreate window when dock icon clicked
11261182
app.on("activate", () => {
11271183
if (isHeadless) return;
1128-
if (BrowserWindow.getAllWindows().length === 0) {
1129-
createWindow();
1130-
} else if (mainWindow) {
1131-
mainWindow.show();
1132-
}
1184+
showMainWindow();
11331185
});
11341186
});
11351187

@@ -1139,7 +1191,7 @@ app.on("window-all-closed", () => {
11391191
process.argv.includes("--headless") ||
11401192
process.argv.includes("--cli") ||
11411193
process.env.OMNIROUTE_HEADLESS === "true";
1142-
if (process.platform !== "darwin" && !isHeadless) {
1194+
if (process.platform !== "darwin" && !isHeadless && closeBehavior !== CLOSE_BEHAVIOR_UNLOAD) {
11431195
app.quit();
11441196
}
11451197
});

electron/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"lib/resolveRemoteServerUrl.js",
6868
"lib/remoteServerPreferences.js",
6969
"lib/serverReadiness.js",
70+
"lib/windowClosePolicy.js",
7071
"assets/remoteServerPrompt.html",
7172
"package.json",
7273
"node_modules/**/*"

0 commit comments

Comments
 (0)