Files
OmniRoute/electron/main.js
diegosouzapw d624ddde03 fix(electron): code review hardening — 16 fixes for security, performance, robustness
## Critical Fixes
- #1: Server readiness — waitForServer() polls before loading window
- #2: Restart timeout — 5s + SIGKILL prevents IPC handler from hanging
- #3: changePort — now stops/restarts server on new port

## Important Fixes
- #4: Tray cleanup — destroy old Tray before recreating
- #5: IPC emission — server-status & port-changed events
- #6: Disposer pattern — replaces removeAllListeners
- #7: useSyncExternalStore — eliminates 5x re-renders

## Minor: #8-#16 (dead code, CSP, platform titlebar, types, errors, version)

Tests: 76 / 15 suites (was 64/9)
2026-02-28 08:15:04 -03:00

442 lines
13 KiB
JavaScript

/**
* OmniRoute Electron Desktop App - Main Process
*
* This is the entry point for the Electron desktop application.
* It manages the main window, system tray, server lifecycle, and IPC communication.
*
* Code Review Fixes Applied:
* #1 Server readiness — wait for health check before loading window
* #2 Restart timeout — 5s timeout + SIGKILL to prevent hanging
* #3 changePort — stop + restart server on new port
* #4 Tray cleanup — destroy old tray before recreating
* #5 Emit server-status/port-changed IPC events
* #8 Removed dead isProduction variable
* #9 Platform-conditional titleBarStyle
* #10 stdio: pipe + stdout/stderr capture for readiness detection
* #14 Removed dead omniroute:// protocol (no handler existed)
* #15 Content Security Policy via session headers
*/
const {
app,
BrowserWindow,
ipcMain,
Tray,
Menu,
nativeImage,
shell,
session,
} = require("electron");
const path = require("path");
const { spawn } = require("child_process");
const fs = require("fs");
// ── Single Instance Lock ───────────────────────────────────
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
process.exit(0);
}
app.on("second-instance", () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
// ── Environment Detection ──────────────────────────────────
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
// ── Paths ──────────────────────────────────────────────────
const APP_PATH = app.getAppPath();
const RESOURCES_PATH = !isDev ? process.resourcesPath : APP_PATH;
const NEXT_SERVER_PATH = path.join(RESOURCES_PATH, "app");
// ── State ──────────────────────────────────────────────────
let mainWindow = null;
let tray = null;
let nextServer = null;
let serverPort = 20128;
const getServerUrl = () => `http://localhost:${serverPort}`;
// ── Helper: Send IPC event to renderer (#5) ────────────────
function sendToRenderer(channel, data) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, data);
}
}
// ── Helper: Wait for server readiness (#1, #10) ────────────
async function waitForServer(url, timeoutMs = 30000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url);
if (res.ok || res.status < 500) return true;
} catch {
/* server not ready yet */
}
await new Promise((r) => setTimeout(r, 500));
}
console.warn("[Electron] Server readiness timeout — showing window anyway");
return false;
}
// ── Helper: Wait for server process exit with timeout (#2) ─
async function waitForServerExit(proc, timeoutMs = 5000) {
if (!proc) return;
await Promise.race([
new Promise((r) => proc.once("exit", r)),
new Promise((r) =>
setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
/* already dead */
}
r();
}, timeoutMs)
),
]);
}
// ── Content Security Policy (#15) ──────────────────────────
function setupContentSecurityPolicy() {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const csp = [
"default-src 'self'",
`connect-src 'self' http://localhost:* ws://localhost:* https://*.omniroute.online https://*.omniroute.dev`,
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data: blob: https:",
"media-src 'self'",
].join("; ");
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [csp],
},
});
});
}
// ── Create Window ──────────────────────────────────────────
function createWindow() {
// Platform-conditional options (#9)
const platformWindowOptions =
process.platform === "darwin"
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
: { titleBarStyle: "default" };
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1024,
minHeight: 700,
title: "OmniRoute",
icon: path.join(RESOURCES_PATH, "assets", "icon.png"),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
},
show: false,
backgroundColor: "#0a0a0a",
...platformWindowOptions,
});
// Load the Next.js app
mainWindow.loadURL(getServerUrl());
if (isDev) {
mainWindow.webContents.openDevTools({ mode: "detach" });
}
// Show window when ready
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});
// Handle external links — validate URL protocol to prevent RCE
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
try {
const parsedUrl = new URL(url);
if (["http:", "https:"].includes(parsedUrl.protocol)) {
shell.openExternal(url);
} else {
console.warn("[Electron] Blocked unsafe protocol:", parsedUrl.protocol);
}
} catch {
console.error("[Electron] Blocked invalid URL:", url);
}
return { action: "deny" };
});
// Handle window close — minimize to tray
mainWindow.on("close", (event) => {
if (!app.isQuitting) {
event.preventDefault();
mainWindow.hide();
}
return false;
});
mainWindow.on("closed", () => {
mainWindow = null;
});
}
// ── System Tray ────────────────────────────────────────────
function createTray() {
// Fix #4: Destroy old tray before recreating
if (tray) {
tray.destroy();
tray = null;
}
const iconPath = path.join(RESOURCES_PATH, "assets", "tray-icon.png");
let icon;
try {
icon = nativeImage.createFromPath(iconPath);
if (icon.isEmpty()) icon = nativeImage.createEmpty();
} catch {
icon = nativeImage.createEmpty();
}
tray = new Tray(icon);
const contextMenu = Menu.buildFromTemplate([
{
label: "Open OmniRoute",
click: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
},
},
{
label: "Open Dashboard",
click: () => shell.openExternal(getServerUrl()),
},
{ type: "separator" },
{
label: "Server Port",
submenu: [
{ label: `Port: ${serverPort}`, enabled: false },
{ type: "separator" },
{ label: "20128", click: () => changePort(20128) },
{ label: "3000", click: () => changePort(3000) },
{ label: "8080", click: () => changePort(8080) },
],
},
{ type: "separator" },
{
label: "Quit",
click: () => {
app.isQuitting = true;
app.quit();
},
},
]);
tray.setToolTip("OmniRoute");
tray.setContextMenu(contextMenu);
tray.on("double-click", () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
});
}
// ── Change Port (#3: now restarts server) ──────────────────
async function changePort(newPort) {
if (newPort === serverPort) return;
const oldPort = serverPort;
serverPort = newPort;
sendToRenderer("server-status", { status: "restarting", port: newPort });
// Stop current server and wait for exit
const serverToStop = nextServer;
stopNextServer();
await waitForServerExit(serverToStop);
// Start server on new port
startNextServer();
await waitForServer(getServerUrl());
// Reload window and update tray
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.loadURL(getServerUrl());
}
createTray();
sendToRenderer("port-changed", serverPort);
sendToRenderer("server-status", { status: "running", port: serverPort });
console.log(`[Electron] Port changed: ${oldPort}${serverPort}`);
}
// ── Server Lifecycle (#1, #5, #10) ─────────────────────────
function startNextServer() {
if (isDev) {
console.log("[Electron] Dev mode — connect to existing Next.js server");
sendToRenderer("server-status", { status: "running", port: serverPort });
return;
}
const serverScript = path.join(NEXT_SERVER_PATH, "server.js");
if (!fs.existsSync(serverScript)) {
console.error("[Electron] Server script not found:", serverScript);
sendToRenderer("server-status", { status: "error", port: serverPort });
return;
}
console.log("[Electron] Starting Next.js server on port", serverPort);
sendToRenderer("server-status", { status: "starting", port: serverPort });
// Fix #10: Use pipe instead of inherit for logging & readiness detection
nextServer = spawn("node", [serverScript], {
cwd: NEXT_SERVER_PATH,
env: {
...process.env,
PORT: String(serverPort),
NODE_ENV: "production",
},
stdio: "pipe",
});
// Capture server output for logging
nextServer.stdout?.on("data", (data) => {
const text = data.toString();
process.stdout.write(`[Server] ${text}`);
// Detect server ready
if (text.includes("Ready") || text.includes("started") || text.includes("listening")) {
sendToRenderer("server-status", { status: "running", port: serverPort });
}
});
nextServer.stderr?.on("data", (data) => {
process.stderr.write(`[Server:err] ${data}`);
});
nextServer.on("error", (err) => {
console.error("[Electron] Failed to start server:", err);
sendToRenderer("server-status", { status: "error", port: serverPort });
});
nextServer.on("exit", (code) => {
console.log("[Electron] Server exited with code:", code);
sendToRenderer("server-status", { status: "stopped", port: serverPort });
nextServer = null;
});
}
function stopNextServer() {
if (nextServer) {
nextServer.kill("SIGTERM");
nextServer = null;
}
}
// ── IPC Handlers ───────────────────────────────────────────
function setupIpcHandlers() {
ipcMain.handle("get-app-info", () => ({
name: app.getName(),
version: app.getVersion(),
platform: process.platform,
isDev,
port: serverPort,
}));
ipcMain.handle("open-external", (_event, url) => {
try {
const parsedUrl = new URL(url);
if (["http:", "https:"].includes(parsedUrl.protocol)) {
shell.openExternal(url);
}
} catch {
console.error("[Electron] Blocked invalid URL:", url);
}
});
ipcMain.handle("get-data-dir", () => app.getPath("userData"));
// Fix #2: Add timeout to restart
ipcMain.handle("restart-server", async () => {
const serverToStop = nextServer;
stopNextServer();
await waitForServerExit(serverToStop);
startNextServer();
await waitForServer(getServerUrl());
return { success: true };
});
// Window controls
ipcMain.on("window-minimize", () => mainWindow?.minimize());
ipcMain.on("window-maximize", () => {
if (mainWindow) {
mainWindow.isMaximized() ? mainWindow.unmaximize() : mainWindow.maximize();
}
});
ipcMain.on("window-close", () => mainWindow?.close());
}
// ── App Lifecycle ──────────────────────────────────────────
app.whenReady().then(async () => {
// Fix #15: Set up CSP before any content loads
setupContentSecurityPolicy();
// Fix #1: Start server and WAIT for readiness before showing window
startNextServer();
if (!isDev) {
await waitForServer(getServerUrl());
}
createWindow();
createTray();
setupIpcHandlers();
// macOS: recreate window when dock icon clicked
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else if (mainWindow) {
mainWindow.show();
}
});
});
// Quit when all windows closed (except macOS)
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
// Clean up before quit
app.on("before-quit", () => {
app.isQuitting = true;
stopNextServer();
});
// Global error handlers
process.on("uncaughtException", (error) => {
console.error("[Electron] Uncaught Exception:", error);
});
process.on("unhandledRejection", (reason) => {
console.error("[Electron] Unhandled Rejection:", reason);
});