Spaces:
Runtime error
Runtime error
| /** | |
| * 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, | |
| Notification, | |
| } = require("electron"); | |
| const path = require("path"); | |
| const { spawn } = require("child_process"); | |
| const fs = require("fs"); | |
| const { autoUpdater } = require("electron-updater"); | |
| const { hasEncryptedCredentials } = require("./sqlite-inspection"); | |
| const { loginManager } = require("./loginManager"); | |
| const { killProcessTree } = require("./processTree"); | |
| const { resolveServerEntry } = require("./lib/resolveServerEntry"); | |
| // ββ 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; | |
| let isServerStopped = false; | |
| const getServerUrl = () => `http://localhost:${serverPort}`; | |
| function resolveNodeExecutable(env = process.env) { | |
| // #1081: Ensure Next.js standalone runs using Electron's Node runtime | |
| // instead of a randomly found system Node to prevent ABI architecture mismatches. | |
| // | |
| // On macOS packaged builds, process.execPath is the main Electron binary | |
| // (e.g. OmniRoute.app/Contents/MacOS/OmniRoute). Spawning it with | |
| // ELECTRON_RUN_AS_NODE causes macOS to show a second dock icon and/or | |
| // flash a shell window. Use the Helper binary instead β macOS treats | |
| // Helper processes as background tasks with no visible UI artifacts. | |
| if (process.platform === "darwin" && !isDev) { | |
| const helperPath = path.join(path.dirname(process.execPath), `${app.getName()} Helper`); | |
| if (fs.existsSync(helperPath)) { | |
| return helperPath; | |
| } | |
| // Electron \u003e= 20 may use "(Renderer)" / "(GPU)" / "(Plugin)" suffixed helpers. | |
| // The unsuffixed Helper is the one suitable for ELECTRON_RUN_AS_NODE. | |
| const frameworkHelper = path.join( | |
| path.dirname(process.execPath), | |
| "..", | |
| "Frameworks", | |
| `${app.getName()} Helper.app`, | |
| "Contents", | |
| "MacOS", | |
| `${app.getName()} Helper` | |
| ); | |
| if (fs.existsSync(frameworkHelper)) { | |
| return frameworkHelper; | |
| } | |
| } | |
| return process.execPath; | |
| } | |
| function resolveServerNodePath(env = process.env) { | |
| const seen = new Set(); | |
| const entries = []; | |
| const addEntry = (entry) => { | |
| if (!entry || typeof entry !== "string") return; | |
| const trimmed = entry.trim(); | |
| if (!trimmed) return; | |
| const normalized = path.normalize(trimmed); | |
| if (seen.has(normalized)) return; // already included | |
| if (!fs.existsSync(normalized)) { | |
| console.debug("[Electron] NODE_PATH candidate not found (skipped):", normalized); | |
| return; | |
| } | |
| seen.add(normalized); | |
| entries.push(normalized); | |
| }; | |
| for (const existing of (env.NODE_PATH || "").split(path.delimiter)) { | |
| addEntry(existing); | |
| } | |
| // Electron-builder installs native modules like better-sqlite3 under | |
| // app.asar.unpacked, while the standalone bundle still carries helper deps | |
| // such as bindings/file-uri-to-path inside resources/app/node_modules. | |
| addEntry(path.join(process.resourcesPath, "app.asar.unpacked", "node_modules")); | |
| addEntry(path.join(NEXT_SERVER_PATH, "node_modules")); | |
| return entries.join(path.delimiter); | |
| } | |
| function resolveDataDir(overridePath, env = process.env) { | |
| if (overridePath && overridePath.trim()) return path.resolve(overridePath); | |
| const configured = env.DATA_DIR?.trim(); | |
| if (configured) return path.resolve(configured); | |
| if (process.platform === "win32") { | |
| const appData = env.APPDATA || path.join(require("os").homedir(), "AppData", "Roaming"); | |
| return path.join(appData, "omniroute"); | |
| } | |
| const xdg = env.XDG_CONFIG_HOME?.trim(); | |
| if (xdg) return path.join(path.resolve(xdg), "omniroute"); | |
| return path.join(require("os").homedir(), ".omniroute"); | |
| } | |
| function getPreferredEnvFilePath(env = process.env) { | |
| const candidates = []; | |
| if (env.DATA_DIR?.trim()) { | |
| candidates.push(path.join(path.resolve(env.DATA_DIR.trim()), ".env")); | |
| } | |
| candidates.push(path.join(resolveDataDir(null, env), ".env")); | |
| candidates.push(path.join(process.cwd(), ".env")); | |
| return candidates.find((filePath) => fs.existsSync(filePath)) || null; | |
| } | |
| // ββ Auto-Updater Configuration ββββββββββββββββββββββββββββββ | |
| autoUpdater.autoDownload = false; | |
| autoUpdater.autoInstallOnAppQuit = true; | |
| autoUpdater.logger = console; | |
| // ββ 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) ββββββββββββ | |
| // Default raised to 180s: the first launch after an upgrade can run long DB | |
| // migrations, during which the server accepts the TCP connection but holds the | |
| // HTTP response until handlers initialize. The previous 30s cap timed out and | |
| // left the window stuck on a hanging connection (#2460). | |
| async function waitForServer(url, timeoutMs = 180000) { | |
| 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 { | |
| // #3347: force-kill the whole tree (Windows leaves grandchildren alive on a | |
| // bare SIGKILL of the direct child, keeping omniroute.exe locked). | |
| killProcessTree(proc, { signal: "SIGKILL" }); | |
| } catch { | |
| /* already dead */ | |
| } | |
| r(); | |
| }, timeoutMs) | |
| ), | |
| ]); | |
| } | |
| // ββ Auto-Updater Event Handlers βββββββββββββββββββββββββββββ | |
| function setupAutoUpdater() { | |
| autoUpdater.on("checking-for-update", () => { | |
| sendToRenderer("update-status", { status: "checking" }); | |
| console.log("[Electron] Checking for updates..."); | |
| }); | |
| autoUpdater.on("update-available", (info) => { | |
| sendToRenderer("update-status", { status: "available", version: info.version }); | |
| console.log("[Electron] Update available:", info.version); | |
| }); | |
| autoUpdater.on("update-not-available", (info) => { | |
| sendToRenderer("update-status", { status: "not-available", version: info.version }); | |
| console.log("[Electron] No update available"); | |
| }); | |
| autoUpdater.on("download-progress", (progress) => { | |
| sendToRenderer("update-status", { | |
| status: "downloading", | |
| percent: Math.round(progress.percent), | |
| transferred: progress.transferred, | |
| total: progress.total, | |
| }); | |
| }); | |
| autoUpdater.on("update-downloaded", (info) => { | |
| sendToRenderer("update-status", { status: "downloaded", version: info.version }); | |
| console.log("[Electron] Update downloaded:", info.version); | |
| if (Notification.isSupported()) { | |
| const notification = new Notification({ | |
| title: "OmniRoute Update Ready", | |
| body: `Version ${info.version} is ready to install. Click to restart.`, | |
| }); | |
| notification.on("click", () => { | |
| autoUpdater.quitAndInstall(); | |
| }); | |
| notification.show(); | |
| } | |
| }); | |
| autoUpdater.on("error", (error) => { | |
| sendToRenderer("update-status", { status: "error", message: error.message }); | |
| console.error("[Electron] Update error:", error); | |
| }); | |
| } | |
| async function checkForUpdates(silent = false) { | |
| if (isDev) { | |
| console.log("[Electron] Dev mode β skipping auto-update"); | |
| if (!silent) { | |
| sendToRenderer("update-status", { status: "error", message: "Updates disabled in dev mode" }); | |
| } | |
| return; | |
| } | |
| // Update-check failures (404 when the release manifest isn't published yet, | |
| // offline, rate-limited) are surfaced to the user via the autoUpdater "error" | |
| // event handler. The promise returned by checkForUpdates() ALSO rejects on | |
| // those, so it must be caught here β the startup check (line ~928) fires it | |
| // unawaited inside a setTimeout, and an uncaught rejection there becomes an | |
| // "Unhandled Rejection" that the packaged-app smoke test treats as fatal. | |
| try { | |
| await autoUpdater.checkForUpdates(); | |
| } catch (err) { | |
| const msg = err instanceof Error ? err.message : String(err); | |
| console.warn("[Electron] Update check failed (non-fatal):", msg); | |
| } | |
| } | |
| async function downloadUpdate() { | |
| await autoUpdater.downloadUpdate(); | |
| } | |
| function installUpdate() { | |
| if (nextServer) { | |
| // #3347: tree-kill before quitAndInstall β a surviving server child (and its | |
| // grandchildren) keeps omniroute.exe locked and the updater fails with "file in use". | |
| killProcessTree(nextServer, { signal: "SIGTERM" }); | |
| nextServer = null; | |
| } | |
| autoUpdater.quitAndInstall(); | |
| } | |
| // ββ Content Security Policy (#15) ββββββββββββββββββββββββββ | |
| function setupContentSecurityPolicy() { | |
| session.defaultSession.webRequest.onHeadersReceived((details, callback) => { | |
| // React/Next.js needs 'unsafe-eval' only for source maps + HMR in development. | |
| // Gate it on the real dev flag (isDev = NODE_ENV==="development" || !app.isPackaged), | |
| // NOT on the request URL: a packaged production build still talks to its embedded | |
| // server on localhost:20128, so a URL-substring check would silently grant | |
| // 'unsafe-eval' in production and open a code-injection vector via XSS. | |
| const scriptSrc = isDev | |
| ? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:" | |
| : "script-src 'self' 'unsafe-inline' blob:"; | |
| const csp = [ | |
| "default-src 'self'", | |
| "base-uri 'self'", | |
| "object-src 'none'", | |
| "frame-ancestors 'none'", | |
| "frame-src 'none'", | |
| "child-src 'none'", | |
| "form-action 'self'", | |
| // Single connect-src: a duplicate directive is ignored by the browser (first wins), | |
| // which previously dropped the 127.0.0.1 origins. Keep both loopback forms here. | |
| `connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:* https://*.omniroute.online https://*.omniroute.dev`, | |
| scriptSrc, | |
| "script-src-attr 'none'", | |
| "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' data: blob:", | |
| "worker-src 'self' blob:", | |
| "manifest-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, | |
| webviewTag: false, | |
| }, | |
| show: false, | |
| backgroundColor: "#0a0a0a", | |
| ...platformWindowOptions, | |
| }); | |
| // Load the Next.js app | |
| mainWindow.loadURL(getServerUrl()); | |
| if (isDev) { | |
| mainWindow.webContents.openDevTools({ mode: "detach" }); | |
| } | |
| // Show window when ready (unless starting minimized/hidden in tray) | |
| mainWindow.once("ready-to-show", () => { | |
| const startHidden = | |
| process.argv.includes("--hidden") || | |
| process.argv.includes("--minimized") || | |
| app.getLoginItemSettings().wasOpenedAsHidden; | |
| if (!startHidden) { | |
| mainWindow.show(); | |
| } else { | |
| console.log("[Electron] Launched hidden in background tray"); | |
| } | |
| }); | |
| // 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(); | |
| if (process.platform === "darwin" && !icon.isEmpty()) { | |
| icon = icon.resize({ width: 20, height: 20 }); | |
| icon.setTemplateImage(true); | |
| } | |
| } 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: "Check for Updates", | |
| click: () => checkForUpdates(false), | |
| }, | |
| { 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; | |
| } | |
| // Prefer server-ws.mjs (peer-stamp wrapper) when present; fall back to server.js. | |
| // Without server-ws.mjs every LOCAL_ONLY route (AgentBridge, MCP, services, β¦) returns | |
| // 403 because the authz middleware can't verify the trusted loopback peer-stamp. (#3386) | |
| const serverEntryName = resolveServerEntry(NEXT_SERVER_PATH, fs.existsSync.bind(fs)); | |
| const serverScript = path.join(NEXT_SERVER_PATH, serverEntryName); | |
| if (!fs.existsSync(serverScript)) { | |
| console.error("[Electron] Server script not found:", serverScript); | |
| sendToRenderer("server-status", { status: "error", port: serverPort }); | |
| return; | |
| } | |
| // ββ Zero-config bootstrap: auto-generate required secrets βββββββββββββββββ | |
| // Electron uses CJS β cannot dynamically import ESM bootstrap-env.mjs. | |
| // This mirrors bootstrap-env.mjs logic synchronously: | |
| // 1. Read persisted secrets from the resolved DATA_DIR/server.env | |
| // 2. Generate missing secrets with crypto.randomBytes() | |
| // 3. Persist back to DATA_DIR/server.env for future restarts | |
| const crypto = require("crypto"); | |
| // Parse a simple KEY=VALUE file | |
| function parseEnvFile(filePath) { | |
| if (!fs.existsSync(filePath)) return {}; | |
| const env = {}; | |
| for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) { | |
| const t = line.trim(); | |
| if (!t || t.startsWith("#")) continue; | |
| const eq = t.indexOf("="); | |
| if (eq < 1) continue; | |
| env[t.slice(0, eq).trim()] = t.slice(eq + 1).trim(); | |
| } | |
| return env; | |
| } | |
| const preferredEnvPath = getPreferredEnvFilePath(process.env); | |
| const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {}; | |
| const dataDir = resolveDataDir(null, { ...preferredEnv, ...process.env }); | |
| const serverEnvPath = path.join(dataDir, "server.env"); | |
| const persisted = parseEnvFile(serverEnvPath); | |
| const serverEnv = { ...persisted, ...preferredEnv, ...process.env }; | |
| let changed = false; | |
| if (!serverEnv.JWT_SECRET) { | |
| serverEnv.JWT_SECRET = persisted.JWT_SECRET = crypto.randomBytes(64).toString("hex"); | |
| changed = true; | |
| console.log("[Electron] β¨ JWT_SECRET auto-generated"); | |
| } | |
| if (!serverEnv.STORAGE_ENCRYPTION_KEY) { | |
| if (hasEncryptedCredentials(path.join(dataDir, "storage.sqlite"))) { | |
| console.error( | |
| `[Electron] Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${path.join( | |
| dataDir, | |
| "storage.sqlite" | |
| )}. Restore the key via ${preferredEnvPath || "an appropriate .env file"}, ${serverEnvPath}, or process.env.` | |
| ); | |
| sendToRenderer("server-status", { status: "error", port: serverPort }); | |
| return; | |
| } | |
| serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto | |
| .randomBytes(32) | |
| .toString("hex"); | |
| serverEnv.STORAGE_ENCRYPTION_KEY_VERSION = persisted.STORAGE_ENCRYPTION_KEY_VERSION = "v1"; | |
| changed = true; | |
| console.log("[Electron] β¨ STORAGE_ENCRYPTION_KEY auto-generated"); | |
| } | |
| if (!serverEnv.API_KEY_SECRET) { | |
| serverEnv.API_KEY_SECRET = persisted.API_KEY_SECRET = crypto.randomBytes(32).toString("hex"); | |
| changed = true; | |
| console.log("[Electron] β¨ API_KEY_SECRET auto-generated"); | |
| } | |
| if (changed) { | |
| serverEnv.OMNIROUTE_BOOTSTRAPPED = "true"; | |
| try { | |
| fs.mkdirSync(dataDir, { recursive: true }); | |
| const lines = [ | |
| "# Auto-generated by OmniRoute bootstrap", | |
| "", | |
| ...Object.entries(persisted).map(([k, v]) => `${k}=${v}`), | |
| "", | |
| ]; | |
| fs.writeFileSync(serverEnvPath, lines.join("\n"), "utf8"); | |
| console.log("[Electron] π Secrets persisted to:", serverEnvPath); | |
| } catch (e) { | |
| console.warn("[Electron] Could not persist secrets:", e.message); | |
| } | |
| } | |
| const nodeExecutable = resolveNodeExecutable(serverEnv); | |
| // #5172/#5160/#5152: the Electron-spawned server inherited the runtime's low | |
| // default V8 heap (~512MB) and OOM-crashed on RAM-rich boxes under load | |
| // (65 providers / 2600 models β "Ineffective mark-compacts near heap limit"). | |
| // Default the heap to ~35% of physical RAM (clamped [512, 4096]); an explicit | |
| // OMNIROUTE_MEMORY_MB or a pre-set --max-old-space-size still wins. Mirrors | |
| // scripts/build/runtime-env.mjs (CJS can't import the ESM helper). | |
| const serverNodeOptions = (() => { | |
| const existing = serverEnv.NODE_OPTIONS || ""; | |
| if (existing.includes("--max-old-space-size")) return existing; | |
| const explicit = parseInt(serverEnv.OMNIROUTE_MEMORY_MB, 10); | |
| let heapMb; | |
| if (Number.isFinite(explicit) && explicit >= 64 && explicit <= 16384) { | |
| heapMb = explicit; | |
| } else { | |
| const totalMb = require("os").totalmem() / (1024 * 1024); | |
| heapMb = | |
| Number.isFinite(totalMb) && totalMb > 0 | |
| ? Math.min(4096, Math.max(512, Math.floor(totalMb * 0.35))) | |
| : 512; | |
| } | |
| return `${existing} --max-old-space-size=${heapMb}`.trim(); | |
| })(); | |
| console.log("[Electron] Starting Next.js server on port", serverPort); | |
| console.log("[Electron] Using Node executable:", nodeExecutable); | |
| console.log("[Electron] Server NODE_OPTIONS:", serverNodeOptions); | |
| sendToRenderer("server-status", { status: "starting", port: serverPort }); | |
| // Fix #10: Use pipe instead of inherit for logging & readiness detection | |
| // windowsHide prevents a visible console window from spawning alongside the GUI app. | |
| // shell: false avoids launching via a shell wrapper which can flash a terminal on macOS. | |
| nextServer = spawn(nodeExecutable, [serverScript], { | |
| cwd: NEXT_SERVER_PATH, | |
| env: { | |
| ...serverEnv, | |
| DATA_DIR: dataDir, | |
| PORT: String(serverPort), | |
| NODE_ENV: "production", | |
| ELECTRON_RUN_AS_NODE: "1", | |
| NODE_PATH: resolveServerNodePath(serverEnv), | |
| NODE_OPTIONS: serverNodeOptions, | |
| }, | |
| stdio: "pipe", | |
| windowsHide: true, | |
| shell: false, | |
| }); | |
| // 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 }); | |
| const isHeadless = | |
| process.argv.includes("--headless") || | |
| process.argv.includes("--cli") || | |
| process.env.OMNIROUTE_HEADLESS === "true"; | |
| if (isHeadless && !global.loggedHeadlessReady) { | |
| global.loggedHeadlessReady = true; | |
| console.log("\n\x1b[32mβ OmniRoute Headless CLI Server is ready and listening!\x1b[0m"); | |
| console.log(` \x1b[1mPort:\x1b[0m http://localhost:${serverPort}`); | |
| console.log(` \x1b[1mAPI Base:\x1b[0m http://localhost:${serverPort}/v1`); | |
| console.log(" \x1b[2mPress Ctrl+C to terminate the process.\x1b[0m\n"); | |
| } | |
| } | |
| }); | |
| 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) { | |
| // #3347: kill the whole tree, not just the direct child. On Windows the server | |
| // (omniroute.exe-as-node) spawns grandchildren that a bare SIGTERM leaves alive, | |
| // holding a lock on omniroute.exe and blocking updates. | |
| killProcessTree(nextServer, { signal: "SIGTERM" }); | |
| nextServer = null; | |
| } | |
| } | |
| // Linux-specific autostart helpers using standard .desktop entry placement | |
| function enableLinuxDesktopAutostart() { | |
| try { | |
| const os = require("os"); | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const autostartDir = path.join(os.homedir(), ".config", "autostart"); | |
| fs.mkdirSync(autostartDir, { recursive: true }); | |
| const execPath = app.getPath("exe"); | |
| const desktopFileContent = | |
| [ | |
| "[Desktop Entry]", | |
| "Type=Application", | |
| "Name=OmniRoute", | |
| "Comment=OmniRoute Desktop Client", | |
| `Exec="${execPath}" --hidden`, | |
| "Terminal=false", | |
| "Hidden=false", | |
| "X-GNOME-Autostart-enabled=true", | |
| ].join("\n") + "\n"; | |
| fs.writeFileSync(path.join(autostartDir, "omniroute-desktop.desktop"), desktopFileContent, { | |
| mode: 0o644, | |
| }); | |
| return true; | |
| } catch (err) { | |
| console.error("[Electron] Failed to enable Linux autostart:", err); | |
| return false; | |
| } | |
| } | |
| function disableLinuxDesktopAutostart() { | |
| try { | |
| const os = require("os"); | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| const desktopPath = path.join( | |
| os.homedir(), | |
| ".config", | |
| "autostart", | |
| "omniroute-desktop.desktop" | |
| ); | |
| if (fs.existsSync(desktopPath)) { | |
| fs.unlinkSync(desktopPath); | |
| } | |
| return true; | |
| } catch (err) { | |
| console.error("[Electron] Failed to disable Linux autostart:", err); | |
| return false; | |
| } | |
| } | |
| function isLinuxDesktopAutostartEnabled() { | |
| try { | |
| const os = require("os"); | |
| const fs = require("fs"); | |
| const path = require("path"); | |
| return fs.existsSync( | |
| path.join(os.homedir(), ".config", "autostart", "omniroute-desktop.desktop") | |
| ); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| // ββ 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()); | |
| // Auto-update IPC handlers | |
| ipcMain.handle("check-for-updates", async () => { | |
| try { | |
| await checkForUpdates(false); | |
| return { success: true }; | |
| } catch (error) { | |
| console.error("[Electron] Check for updates failed:", error); | |
| sendToRenderer("update-status", { status: "error", message: error.message }); | |
| return { success: false, error: error.message }; | |
| } | |
| }); | |
| ipcMain.handle("download-update", async () => { | |
| try { | |
| await downloadUpdate(); | |
| return { success: true }; | |
| } catch (error) { | |
| console.error("[Electron] Download update failed:", error); | |
| sendToRenderer("update-status", { status: "error", message: error.message }); | |
| return { success: false, error: error.message }; | |
| } | |
| }); | |
| ipcMain.handle("install-update", () => { | |
| installUpdate(); | |
| // No return value β app will quit and restart | |
| }); | |
| ipcMain.handle("get-app-version", () => app.getVersion()); | |
| // ββ Web-Cookie Login IPC Handlers ββββββββββββββββββββββββββ | |
| // Forward login status events to the renderer. Registered ONCE here β never | |
| // inside the login:start handler, which would attach a fresh listener (and | |
| // duplicate every subsequent status event) on each invocation. | |
| loginManager.on("status", (status) => { | |
| sendToRenderer("login:status", status); | |
| }); | |
| ipcMain.handle("login:start", async (_event, providerId, options) => { | |
| const result = await loginManager.startLogin(providerId, options); | |
| // Persist extracted credentials | |
| if (result.success && result.credentials) { | |
| try { | |
| // Store as JSON blob under the provider ID | |
| const { persistSecret: ps } = require("../src/lib/db/secrets"); | |
| if (typeof ps === "function") { | |
| ps(providerId, JSON.stringify(result.credentials)); | |
| } | |
| sendToRenderer("login:status", { | |
| providerId, | |
| status: "persisted", | |
| message: "Credentials saved", | |
| }); | |
| } catch (err) { | |
| console.error("[Electron] Failed to persist credentials:", err); | |
| return { success: false, error: "Extracted but failed to save credentials" }; | |
| } | |
| } | |
| return result; | |
| }); | |
| ipcMain.handle("login:cancel", async () => { | |
| loginManager.cancel(); | |
| return { success: true }; | |
| }); | |
| ipcMain.handle("login:status", async () => { | |
| return { active: loginManager.getActiveProvider() !== null }; | |
| }); | |
| // Autostart management handlers | |
| ipcMain.handle("get-autostart-status", () => { | |
| if (process.platform === "linux") { | |
| return isLinuxDesktopAutostartEnabled(); | |
| } | |
| return app.getLoginItemSettings().openAtLogin; | |
| }); | |
| ipcMain.handle("enable-autostart", () => { | |
| if (process.platform === "linux") { | |
| return enableLinuxDesktopAutostart(); | |
| } | |
| try { | |
| app.setLoginItemSettings({ | |
| openAtLogin: true, | |
| openAsHidden: true, | |
| args: ["--hidden"], | |
| }); | |
| return true; | |
| } catch (err) { | |
| console.error("[Electron] Enable autostart failed:", err); | |
| return false; | |
| } | |
| }); | |
| ipcMain.handle("disable-autostart", () => { | |
| if (process.platform === "linux") { | |
| return disableLinuxDesktopAutostart(); | |
| } | |
| try { | |
| app.setLoginItemSettings({ | |
| openAtLogin: false, | |
| }); | |
| return true; | |
| } catch (err) { | |
| console.error("[Electron] Disable autostart failed:", err); | |
| return false; | |
| } | |
| }); | |
| } | |
| // ββ App Lifecycle ββββββββββββββββββββββββββββββββββββββββββ | |
| app.whenReady().then(async () => { | |
| // Fix #15: Set up CSP before any content loads | |
| setupContentSecurityPolicy(); | |
| // Headless mode check: supports running without any UI windows or tray icons | |
| const isHeadless = | |
| process.argv.includes("--headless") || | |
| process.argv.includes("--cli") || | |
| process.env.OMNIROUTE_HEADLESS === "true"; | |
| // Fix #1: Start server and WAIT for readiness before showing window | |
| startNextServer(); | |
| let serverReady = true; | |
| if (!isDev) { | |
| // Probe the auth-exempt health endpoint (not the root URL, which may redirect). | |
| serverReady = await waitForServer(`${getServerUrl()}/api/monitoring/health`); | |
| } | |
| if (isHeadless) { | |
| console.log("[Electron] Headless mode active β UI window and tray icon skipped"); | |
| } else { | |
| createWindow(); | |
| createTray(); | |
| } | |
| setupIpcHandlers(); | |
| setupAutoUpdater(); | |
| // 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) { | |
| void waitForServer(`${getServerUrl()}/api/monitoring/health`, 300000).then((ready) => { | |
| if (ready && mainWindow && !mainWindow.isDestroyed()) { | |
| mainWindow.loadURL(getServerUrl()); | |
| } | |
| }); | |
| } | |
| // Check for updates after a short delay (don't block startup) | |
| if (!isDev) { | |
| setTimeout(() => { | |
| checkForUpdates(true); | |
| }, 3000); | |
| } | |
| // macOS: recreate window when dock icon clicked | |
| app.on("activate", () => { | |
| if (isHeadless) return; | |
| if (BrowserWindow.getAllWindows().length === 0) { | |
| createWindow(); | |
| } else if (mainWindow) { | |
| mainWindow.show(); | |
| } | |
| }); | |
| }); | |
| // Quit when all windows closed (except macOS) | |
| app.on("window-all-closed", () => { | |
| const isHeadless = | |
| process.argv.includes("--headless") || | |
| process.argv.includes("--cli") || | |
| process.env.OMNIROUTE_HEADLESS === "true"; | |
| if (process.platform !== "darwin" && !isHeadless) { | |
| app.quit(); | |
| } | |
| }); | |
| // Clean up before quit | |
| app.on("before-quit", async (event) => { | |
| if (nextServer && !isServerStopped) { | |
| event.preventDefault(); // Stop immediate quit | |
| app.isQuitting = true; | |
| // Stop server and wait up to 5s for graceful WAL checkpoint | |
| const serverToStop = nextServer; | |
| stopNextServer(); | |
| await waitForServerExit(serverToStop, 5000); | |
| isServerStopped = true; | |
| app.quit(); // Resume quit safely | |
| } else { | |
| app.isQuitting = true; | |
| } | |
| }); | |
| // Global error handlers | |
| process.on("uncaughtException", (error) => { | |
| console.error("[Electron] Uncaught Exception:", error); | |
| }); | |
| process.on("unhandledRejection", (reason) => { | |
| console.error("[Electron] Unhandled Rejection:", reason); | |
| }); | |