Files
OmniRoute/electron/main.js
Diego Rodrigues de Sa e Souza 7db430a352 Release v3.8.14 (#3340)
* chore(release): open v3.8.14 development cycle

Version bump 3.8.13 -> 3.8.14 (root + electron + open-sse + openapi + lockfiles).
Seed the v3.8.14 changelog with the four post-tag hotfixes that shipped to
Docker/Electron in v3.8.13 but missed the immutable npm 3.8.13 (#3336 SSRF /
CodeQL #323, #3334/#3335/#3339 Electron packaging). i18n CHANGELOG mirrors get
the in-progress placeholder section.

* feat: add per-provider custom headers support for OpenAI/Anthropic-compatible nodes (#3338)

Integrated into release/v3.8.14

* fix: Kiro Builder ID token import fails with Bad credentials (#3333)

Integrated into release/v3.8.14 — adds Builder ID cached-creds + OIDC refresh path for Kiro token import, with regression tests (#3333).

* Improve code quality: auto-pr/docstrings-1780792063 (#3337)

Integrated into release/v3.8.14 — docstring for context analytics route re-export.

* fix(catalog): remove minimaxai/minimax-m3 from NVIDIA NIM tier (404 upstream) (#3329) (#3341)

NVIDIA NIM does not host minimaxai/minimax-m3 — every request returns
404 page not found, while sibling minimaxai/minimax-m2.7 on the same provider
works. Advertising a model that 404s is a catalog bug; remove it from the nvidia
tier (it remains on the tiers that actually serve MiniMax M3). Re-add only once
NVIDIA serves it.

Co-authored-by: mikmaneggahommie <mikmaneggahommie@users.noreply.github.com>

* fix(cli): write OpenCode config to ~/.config on all platforms incl. Windows (#3330) (#3343)

resolveOpencodeConfigDir used %APPDATA% on Windows, but OpenCode reads its
config from XDG ~/.config/opencode/ on every platform (on Windows:
%USERPROFILE%\.config\opencode\, NOT %APPDATA%). So a Windows user who
configured OpenCode via the dashboard had the file written where OpenCode never
looks — it silently had no effect.

Use the XDG path (XDG_CONFIG_HOME || ~/.config) unconditionally. Update the UI
note + route JSDoc, and flip the three tests that encoded the old %APPDATA%
behavior (t40 per-platform + card-note, cli-runtime-extended getCliConfigPaths).

Co-authored-by: abdulkadirozyurt <abdulkadirozyurt@users.noreply.github.com>

* fix(proxy): make auto-selection fallback opt-in (#3332) (#3344)

selectWorkingProxyFallback (Step 11 of resolveProxyForConnection) listed ALL
registry proxies, ignoring assignments and per-connection proxy_enabled, and
returned the first working one with level:'autoSelect'. So a single proxy added
to the registry silently became a global fallback for every connection's traffic.

Gate it behind a new PROXY_AUTO_SELECT_ENABLED feature flag (default off): the
fallback now no-ops unless the operator opts in. No registry proxy becomes a
silent global default anymore.

Co-authored-by: hertznsk <hertznsk@users.noreply.github.com>

* fix(sse): treat MiniMax M3 as multimodal so vision isn't stripped (#3328) (#3342)

MiniMax M3 via the opencode provider (oc/minimax-m3-free) appeared blind:
image inputs didn't reach the model, while the same model in Cline could
see them. Verified empirically that MiniMax M3 on the opencode upstream IS
multimodal -- a base64 image is described correctly (it returns 403 only
for remote image URLs, which it doesn't accept).

Root cause: OmniRoute treated MiniMax M3 as a non-vision model in two
places, so when compression was active the image was replaced with a text
placeholder before dispatch:
- compression's modelSupportsVision() heuristic (lite.ts) only matched
  gpt-4/4o/claude-3/gemini/vision -- minimax was absent -> replaceImageUrls
  stripped the image.
- the opencode minimax-m3-free catalog entry lacked supportsVision, so the
  combo vision-capability gate could also exclude/mishandle it.

Add 'minimax-m3' to the vision heuristic and supportsVision: true to the
opencode minimax-m3-free entry. TDD: a failing-then-passing test in
compression/lite.test.ts proves replaceImageUrls now keeps images for
minimax-m3 ids, plus a registry assertion mirroring the #2822 qwen test.

Reported-by: @mikmaneggahommie

* docs(i18n): translate 25 core documentation files to Indonesian (#3348)

Integrated into release/v3.8.14 — Indonesian i18n docs.

* fix(review): resolve /review-reviews battery findings (LEDGER-1..11) on v3.8.14 (#3350)

Integrated into release/v3.8.14 — /review-reviews battery hardening (LEDGER-1..11) for #3338 custom-headers + #3333 kiro, plus cycle-test drift fixes (#3329/#3330/#3332).

* fix(provider-proxy): honor per-account proxy toggles (#3349)

Integrated into release/v3.8.14 — honor per-account proxy toggles + auto-fallback opt-in via PROXY_AUTO_SELECT_ENABLED.

* fix(dashboard): remove duplicate Distribute Proxies button on provider page (#3352)

* fix(providers): reduce proxy label noise (#3346)

Integrated into release/v3.8.14 — reduce proxy label noise + a11y (aria-label/sr-only).

* fix(duckduckgo): restore bare Response contract and rebase onto release/v3.8.14 (#3323)

Integrated into release/v3.8.14 — browser-backed cookie providers (duckduckgo/claude-web) with restored executor contract + unit tests.

* fix(noauth): expose only usable model aliases (#3345)

Integrated into release/v3.8.14 — noauth usable-alias filtering + registry alias plumbing (veo-free).

* fix(dashboard): stop infinite config-load loop on Hermes Agent detail page (#3353)

* fix(electron): tree-kill the server on exit/update to release the omniroute.exe lock (#3347) (#3354)

* chore(release): finalize v3.8.14 changelog + clear release-gate drift

- CHANGELOG: finalize the v3.8.14 section (date, full New Features/Bug Fixes/
  Maintenance coverage of all 16 cycle commits, Contributors hall of 12).
- docs: document OMNIROUTE_BROWSER_POOL + WEB_COOKIE_USE_BROWSER (#3323) in
  .env.example + ENVIRONMENT.md; regenerate the id/llm.txt strict mirror (#3348
  had translated it; llm.txt mirrors must match root).
- test(proxy-fetch): #3323 made tlsClient.available a computed getter — stub it
  via Object.defineProperty instead of assignment (5 tests were red on the base).

* fix(translator): coerce Gemini functionDeclaration parameters to an OBJECT schema (#3357) (#3360)

* fix(gemini): resolve truncation/suppression of false positive textual tool call markers in backticks (#3358)

Integrated into release/v3.8.14 — Gemini/Antigravity textual tool-call marker normalization (no false-positive suppression + split-chunk buffering).

* docs(changelog): add #3358 Gemini textual tool-call normalization to v3.8.14

* fix(dashboard): surface real analytics error instead of generic placeholder (#3356) (#3361)

The Analytics page discarded the server's error body on a non-OK response and
rendered a generic "An error occurred", so users (and maintainers) could not see
why /api/usage/analytics 500'd after an upgrade. Now the route returns the real
reason via buildErrorBody (sanitized, Hard Rule #12) and the page surfaces it via
a new readFetchErrorMessage helper that handles both the OpenAI-style and legacy
error shapes.

Reported-by: @superti4r

---------

Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: Someres <168349709+quanturbo@users.noreply.github.com>
Co-authored-by: Dong Mengzhe <154944819+Lang-Qiu@users.noreply.github.com>
Co-authored-by: mikmaneggahommie <mikmaneggahommie@users.noreply.github.com>
Co-authored-by: abdulkadirozyurt <abdulkadirozyurt@users.noreply.github.com>
Co-authored-by: hertznsk <hertznsk@users.noreply.github.com>
Co-authored-by: Krisna Santosa <54174372+KrisnaSantosa15@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Wilson <pedbookmed@gmail.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Ardem2025 <ardemb22@gmail.com>
2026-06-07 07:20:02 -03:00

999 lines
33 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,
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");
// ── 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;
}
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;
}
// ── 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);
console.log("[Electron] Starting Next.js server on port", serverPort);
console.log("[Electron] Using Node executable:", nodeExecutable);
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),
},
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);
});