mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* chore(release): open v3.8.13 development cycle Bump 3.8.12 → 3.8.13 across package.json, lockfile, electron/, open-sse/, and docs/reference/openapi.yaml; add the [3.8.13] cycle placeholder to the root CHANGELOG and the 41 i18n mirrors. Integration branch for the v3.8.13 cycle — fixes/features land here via per-issue PRs and it merges to main at release time. * fix(ci): skip auto-deploy when VPS host is unreachable from the runner (#3299) Integrated into release/v3.8.13 * fix(dev): auto-rebuild better-sqlite3 on Node ABI mismatch at dev startup (#3301) Integrated into release/v3.8.13 * feat(api): accept path-scoped API keys on client API routes (#3300) Integrated into release/v3.8.13 * fix(sse): harden against empty responses causing Copilot Chat failures (#3297) Integrated into release/v3.8.13 * fix(api): remove Completions.me rickroll provider (discussion #3293) (#3302) Integrated into release/v3.8.13 * fix(opencode-provider): extract contextLength from live model catalog (#3298) Integrated into release/v3.8.13 * feat(web-cookie): self-service login infrastructure + auto-refresh daemon (#3292) Integrated into release/v3.8.13 * docs(changelog): record the v3.8.13 PRs merged this round (#3292/#3300/#3297/#3298/#3301/#3302/#3299) * fix(auth): harden URL token extraction — drop query-string fallback, gate to client routes (security follow-up to #3300) (#3309) Security follow-up to #3300 — integrated into release/v3.8.13 * docs: rename resolve-issues → review-issues skill references * fix(dashboard): keep no-auth providers visible under 'Show configured only' (#3290) (#3312) no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) never create a DB connection row so stats.total stays 0, which the configured-only filter treated as 'unconfigured' and hid them — even though they are always usable and appear unconditionally in /v1/models. filterConfiguredProviderEntries now treats displayAuthType === 'no-auth' as configured. Co-authored-by: uniQta <uniQta@users.noreply.github.com> * fix(cli): resolve update paths relative to script + recursive backup (#3295) (#3313) omniroute update always failed on a global install: - getCurrentVersion() read package.json from process.cwd(), which on a global npm/brew install is the user's working dir, not the package root → null → 'Could not determine current version'. - createBackup() resolved bin/ from cwd too, and passed the 'cli' directory to copyFileSync → EISDIR, swallowed by the catch → 'Failed to create backup'. Both now resolve package.json/bin relative to the script via import.meta.url, and the backup uses cpSync({recursive:true}) so the cli/ directory is copied. Co-authored-by: uniQta <uniQta@users.noreply.github.com> * fix(theoldllm): read upstream body once to avoid [502] body-already-read (#3296) (#3314) On the cached-token path the executor never enters the refresh branch, so the same upstream Response was read with .text() twice (token-rejection check + final body). A Response body is single-use, so the second read threw 'Body is unusable: Body has already been read', caught and surfaced as [502]. Read the body once into finalBody and only re-read after a token-rejection refetch. Co-authored-by: onizukashonan14-png <onizukashonan14-png@users.noreply.github.com> * fix(sse): strip leaked internal tool envelopes from streaming output (#3311) Integrated into release/v3.8.13 * fix(sse): expose Claude + Gemini budget tiers in the antigravity catalog (#3184) (#3303) Integrated into release/v3.8.13 (#3184) * fix(catalog): compute combo context_length from known targets only (#3304) Integrated into release/v3.8.13 — live contextLength + known-targets combo context (#3298 follow-up) * chore(i18n): add message keys for proxy UI + vscode/ollama endpoint (#3307) Integrated into release/v3.8.13 — i18n message keys for proxy UI + vscode/ollama * feat(dashboard): i18n the proxy settings UI (#3310) Integrated into release/v3.8.13 — i18n the proxy settings UI * feat(api): model catalog enrichment + MCP model-catalog tools (#3306) Integrated into release/v3.8.13 — model catalog enrichment + MCP model-catalog tools, reconciled with #3309 URL-token hardening * test(catalog): align Antigravity preview-alias test with #3303 budget tiers #3303 added the Gemini `-high`/`-low` budget tiers to ANTIGRAVITY_PUBLIC_MODELS (user-callable on the Antigravity OAuth backend, verified via #3184), but did not update the catalog-route test that asserted `antigravity/gemini-3.1-pro-high` must NOT be exposed. The assertion now reflects the intended behavior — the client-visible budget alias IS surfaced — while keeping the legacy `gemini-claude-*` alias keys unexposed. Caught running the full catalog suite on the merged release HEAD (the #3303 round only ran the antigravity-aliases and usage-hardening files). * docs(changelog): record the 6 PRs merged this review round into v3.8.13 #3306/#3307/#3310 (New Features — VS Code split: catalog+MCP, i18n keys, proxy UI i18n), #3311/#3303/#3304 (Bug Fixes — SSE envelope sanitizer, antigravity budget tiers, combo known-targets context_length). * chore(release): finalize v3.8.13 changelog and cleanup Finalize the v3.8.13 changelog with release date, maintenance notes, and contributor credits. Update MCP docs to reference the correct tool inventory diagram, exclude nested .claude worktrees from ESLint scans, and tighten a response sanitizer type guard. * fix(dashboard): refresh connections after provider auth import (#3320) Integrated into release/v3.8.13 — refresh connections after provider auth import * fix(codex): strip client-only params on native /responses passthrough (#3317) (#3325) A /v1/responses request against the built-in codex/ provider does an openai-responses -> openai-responses passthrough (CodexExecutor.transformRequest returns the body early for _nativeCodexPassthrough). It forwarded client-only fields verbatim and the Codex upstream rejected them with 400 Unsupported parameter: prompt_cache_retention / safety_identifier / user — breaking Factory Droid (which injects all three). The chat-completions path already strips these (base.ts #1884, openai-responses translator #2770) but the passthrough skips translation. Strip the three fields in the shared block before the passthrough return; user is removed unconditionally since Codex /responses always rejects it. Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com> * fix(dashboard): normalize agent-bridge /state response to stop page crash (#3318) (#3326) The Agent Bridge page seeded a well-shaped initialData default then replaced it wholesale with the raw /api/tools/agent-bridge/state response. The route returns { server, agents } but the UI reads { serverState, agentStates, bypassPatterns, mappings }, so serverState became undefined and AgentBridgeServerCard crashed on serverState.running — surfaced as the full-page 'Internal Server Error' boundary (client render error, not a real 5xx). Add a shared normalizeAgentBridgeState() that maps the route shape into the page contract (server.running/certExists -> serverState) and always returns safe defaults (never undefined serverState). Wired into both the SSR loader (page.tsx) and the polling hook. The legacy 'agents' entry shape differs from AgentStateEntry so it is not coerced; full route<->page contract reconciliation (port, upstreamCa, bypassPatterns, mappings, agentStates) is a follow-up. Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com> * docs: VS Code/Ollama endpoints + env & i18n tooling (#3319) Integrated into release/v3.8.13 — VS Code/Ollama docs + env & i18n tooling * feat(provider): test-all endpoint, rate-limit overrides, visibility f… (#3267) Integrated into release/v3.8.13 — provider test-all endpoint, rate-limit overrides, model visibility * feat: auto-combo optimization, playground model dropdown, only-configured toggle (#3322) Integrated into release/v3.8.13 — auto-combo candidate expansion + playground dropdown + only-configured toggle * feat(api): VS Code Copilot Ollama-compatible BYOK endpoint (#3316) Integrated into release/v3.8.13 — VS Code Copilot Ollama-compatible BYOK endpoint (reconciled with #3306/#3309 auth hardening) * chore(release): document #3320 in the v3.8.13 changelog + contributor credits --------- Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: uniQta <uniQta@users.noreply.github.com> Co-authored-by: onizukashonan14-png <onizukashonan14-png@users.noreply.github.com> Co-authored-by: tycronk20 <tycronk20@users.noreply.github.com> Co-authored-by: Vinayrnani <vinayrnani@gmail.com>
980 lines
32 KiB
JavaScript
980 lines
32 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");
|
|
|
|
// ── 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 {
|
|
proc.kill("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;
|
|
}
|
|
await autoUpdater.checkForUpdates();
|
|
}
|
|
|
|
async function downloadUpdate() {
|
|
await autoUpdater.downloadUpdate();
|
|
}
|
|
|
|
function installUpdate() {
|
|
if (nextServer) {
|
|
nextServer.kill("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) {
|
|
nextServer.kill("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);
|
|
});
|