Spaces:
Runtime error
Runtime error
File size: 35,222 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 | /**
* 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);
});
|