Skip to content

Commit be7eae4

Browse files
committed
perf(electron): optionally unload renderer on close
1 parent 0bd2be0 commit be7eae4

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,7 +38,17 @@ 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");
46+
const {
47+
CLOSE_BEHAVIOR_KEEP_LOADED,
48+
CLOSE_BEHAVIOR_UNLOAD,
49+
normalizeCloseBehavior,
50+
resolveRendererUrl,
51+
} = require("./lib/windowClosePolicy");
4252

4353
// ── Single Instance Lock ───────────────────────────────────
4454
const gotTheLock = app.requestSingleInstanceLock();
@@ -48,11 +58,12 @@ if (!gotTheLock) {
4858
}
4959

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

5869
// ── Environment Detection ──────────────────────────────────
@@ -70,6 +81,7 @@ let nextServer = null;
7081
let serverPort = 20128;
7182
let isServerStopped = false;
7283
let remoteServerPromptWindow = null;
84+
let lastRendererUrl = null;
7385

7486
// ── Remote Server Mode ──────────────────────────────────────
7587
// Lets the desktop shell attach to an already-running OmniRoute server (e.g. a
@@ -80,6 +92,8 @@ const REMOTE_SERVER_PREFS_PATH = path.join(
8092
resolveDataDir(null, process.env),
8193
"electron-preferences.json"
8294
);
95+
const electronPreferences = readPreferences(REMOTE_SERVER_PREFS_PATH);
96+
let closeBehavior = electronPreferences.closeBehavior;
8397
let remoteServerUrl = resolveRemoteServerUrl({
8498
env: process.env,
8599
prefsPath: REMOTE_SERVER_PREFS_PATH,
@@ -352,14 +366,18 @@ function setupContentSecurityPolicy() {
352366
}
353367

354368
// ── Create Window ──────────────────────────────────────────
355-
function createWindow() {
369+
function createWindow({ showWhenReady = true } = {}) {
370+
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
371+
372+
const rendererStartedAt = Date.now();
373+
356374
// Platform-conditional options (#9)
357375
const platformWindowOptions =
358376
process.platform === "darwin"
359377
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
360378
: { titleBarStyle: "default" };
361379

362-
mainWindow = new BrowserWindow({
380+
const window = new BrowserWindow({
363381
width: 1400,
364382
height: 900,
365383
minWidth: 1024,
@@ -377,28 +395,25 @@ function createWindow() {
377395
backgroundColor: "#0a0a0a",
378396
...platformWindowOptions,
379397
});
398+
mainWindow = window;
380399

381400
// Load the Next.js app
382-
mainWindow.loadURL(getServerUrl());
401+
window.loadURL(resolveRendererUrl(lastRendererUrl, getServerUrl()));
383402
if (isDev) {
384-
mainWindow.webContents.openDevTools({ mode: "detach" });
403+
window.webContents.openDevTools({ mode: "detach" });
385404
}
386405

387-
// Show window when ready (unless starting minimized/hidden in tray)
388-
mainWindow.once("ready-to-show", () => {
389-
const startHidden =
390-
process.argv.includes("--hidden") ||
391-
process.argv.includes("--minimized") ||
392-
app.getLoginItemSettings().wasOpenedAsHidden;
393-
if (!startHidden) {
394-
mainWindow.show();
406+
window.once("ready-to-show", () => {
407+
console.log(`[Electron] Renderer ready in ${Date.now() - rendererStartedAt}ms`);
408+
if (showWhenReady) {
409+
window.show();
395410
} else {
396411
console.log("[Electron] Launched hidden in background tray");
397412
}
398413
});
399414

400415
// Handle external links — validate URL protocol to prevent RCE
401-
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
416+
window.webContents.setWindowOpenHandler(({ url }) => {
402417
try {
403418
const parsedUrl = new URL(url);
404419
if (["http:", "https:"].includes(parsedUrl.protocol)) {
@@ -412,18 +427,46 @@ function createWindow() {
412427
return { action: "deny" };
413428
});
414429

415-
// Handle window close — minimize to tray
416-
mainWindow.on("close", (event) => {
430+
// Keep the server alive while either hiding the renderer for a fast reopen or
431+
// unloading it to reclaim memory, according to the persisted tray preference.
432+
window.on("close", (event) => {
417433
if (!app.isQuitting) {
418434
event.preventDefault();
419-
mainWindow.hide();
435+
lastRendererUrl = resolveRendererUrl(window.webContents.getURL(), getServerUrl());
436+
if (closeBehavior === CLOSE_BEHAVIOR_UNLOAD) {
437+
console.log("[Electron] Dashboard renderer unloaded; server remains running");
438+
window.destroy();
439+
} else {
440+
console.log("[Electron] Dashboard hidden; renderer kept loaded");
441+
window.hide();
442+
}
420443
}
421444
return false;
422445
});
423446

424-
mainWindow.on("closed", () => {
425-
mainWindow = null;
447+
window.on("closed", () => {
448+
if (mainWindow === window) mainWindow = null;
426449
});
450+
451+
return window;
452+
}
453+
454+
function showMainWindow() {
455+
if (!mainWindow || mainWindow.isDestroyed()) {
456+
createWindow();
457+
return;
458+
}
459+
if (mainWindow.isMinimized()) mainWindow.restore();
460+
mainWindow.show();
461+
mainWindow.focus();
462+
}
463+
464+
function setCloseBehavior(nextBehavior) {
465+
const normalized = normalizeCloseBehavior(nextBehavior);
466+
if (!normalized || normalized === closeBehavior) return;
467+
closeBehavior = normalized;
468+
writeCloseBehavior(REMOTE_SERVER_PREFS_PATH, closeBehavior);
469+
createTray();
427470
}
428471

429472
// ── System Tray ────────────────────────────────────────────
@@ -452,12 +495,7 @@ function createTray() {
452495
const contextMenu = Menu.buildFromTemplate([
453496
{
454497
label: "Open OmniRoute",
455-
click: () => {
456-
if (mainWindow) {
457-
mainWindow.show();
458-
mainWindow.focus();
459-
}
460-
},
498+
click: () => showMainWindow(),
461499
},
462500
{
463501
label: "Open Dashboard",
@@ -491,6 +529,23 @@ function createTray() {
491529
},
492530
],
493531
},
532+
{
533+
label: "When Dashboard Closes",
534+
submenu: [
535+
{
536+
label: "Keep Loaded (Faster Reopen)",
537+
type: "radio",
538+
checked: closeBehavior === CLOSE_BEHAVIOR_KEEP_LOADED,
539+
click: () => setCloseBehavior(CLOSE_BEHAVIOR_KEEP_LOADED),
540+
},
541+
{
542+
label: "Unload Renderer (Lower Memory)",
543+
type: "radio",
544+
checked: closeBehavior === CLOSE_BEHAVIOR_UNLOAD,
545+
click: () => setCloseBehavior(CLOSE_BEHAVIOR_UNLOAD),
546+
},
547+
],
548+
},
494549
{ type: "separator" },
495550
{
496551
label: "Check for Updates",
@@ -509,12 +564,7 @@ function createTray() {
509564
tray.setToolTip("OmniRoute");
510565
tray.setContextMenu(contextMenu);
511566

512-
tray.on("double-click", () => {
513-
if (mainWindow) {
514-
mainWindow.show();
515-
mainWindow.focus();
516-
}
517-
});
567+
tray.on("double-click", () => showMainWindow());
518568
}
519569

520570
// ── Change Port (#3: now restarts server) ──────────────────
@@ -536,6 +586,7 @@ async function changePort(newPort) {
536586
await waitForServer(getServerUrl());
537587

538588
// Reload window and update tray
589+
lastRendererUrl = getServerUrl();
539590
if (mainWindow && !mainWindow.isDestroyed()) {
540591
mainWindow.loadURL(getServerUrl());
541592
}
@@ -600,6 +651,7 @@ async function setRemoteServerUrl(nextUrl) {
600651

601652
remoteServerUrl = normalized;
602653
writeRemoteServerUrl(REMOTE_SERVER_PREFS_PATH, remoteServerUrl);
654+
lastRendererUrl = getServerUrl();
603655

604656
startNextServer();
605657
try {
@@ -1085,7 +1137,11 @@ app.whenReady().then(async () => {
10851137
if (isHeadless) {
10861138
console.log("[Electron] Headless mode active — UI window and tray icon skipped");
10871139
} else {
1088-
createWindow();
1140+
const startHidden =
1141+
process.argv.includes("--hidden") ||
1142+
process.argv.includes("--minimized") ||
1143+
app.getLoginItemSettings().wasOpenedAsHidden;
1144+
createWindow({ showWhenReady: !startHidden });
10891145
createTray();
10901146
}
10911147

@@ -1112,11 +1168,7 @@ app.whenReady().then(async () => {
11121168
// macOS: recreate window when dock icon clicked
11131169
app.on("activate", () => {
11141170
if (isHeadless) return;
1115-
if (BrowserWindow.getAllWindows().length === 0) {
1116-
createWindow();
1117-
} else if (mainWindow) {
1118-
mainWindow.show();
1119-
}
1171+
showMainWindow();
11201172
});
11211173
});
11221174

@@ -1126,7 +1178,7 @@ app.on("window-all-closed", () => {
11261178
process.argv.includes("--headless") ||
11271179
process.argv.includes("--cli") ||
11281180
process.env.OMNIROUTE_HEADLESS === "true";
1129-
if (process.platform !== "darwin" && !isHeadless) {
1181+
if (process.platform !== "darwin" && !isHeadless && closeBehavior !== CLOSE_BEHAVIOR_UNLOAD) {
11301182
app.quit();
11311183
}
11321184
});

electron/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"lib/resolveNodeHelper.js",
6767
"lib/resolveRemoteServerUrl.js",
6868
"lib/remoteServerPreferences.js",
69+
"lib/windowClosePolicy.js",
6970
"assets/remoteServerPrompt.html",
7071
"package.json",
7172
"node_modules/**/*"

0 commit comments

Comments
 (0)