[v3.8.50] feat(electron): add Remote Server Mode to attach to an external OmniRoute instance (#8799)

Validated in local merge-train T7 (ungrouped batch 2)
This commit is contained in:
Dave
2026-08-06 04:05:24 -05:00
committed by GitHub
parent 4a6871381f
commit 7feafd52c9
12 changed files with 747 additions and 13 deletions

View File

@@ -114,19 +114,23 @@ Built applications are placed in `dist-electron/`:
4. Launch from Applications.
> ⚠️ **Note:** The app is not signed with an Apple Developer certificate yet. If macOS blocks the app, run:
>
> ```bash
> xattr -cr /Applications/OmniRoute.app
> ```
>
> Or right-click the app → Open → Open (to bypass Gatekeeper on first launch).
### Windows
**Installer (Recommended):**
1. Download `OmniRoute.Setup.*.exe` from [Releases](https://github.com/diegosouzapw/OmniRoute/releases).
2. Run the installer.
3. Launch from Start Menu or Desktop shortcut.
**Portable (No Installation):**
1. Download `OmniRoute.exe` from [Releases](https://github.com/diegosouzapw/OmniRoute/releases).
2. Run directly from any folder.
@@ -147,20 +151,44 @@ Built applications are placed in `dist-electron/`:
- **Server Readiness** — Waits for health check before showing window
- **System Tray** — Minimize to tray with quick actions (open, port change, quit)
- **Port Management** — Change port from tray menu (server restarts automatically)
- **Remote Server Mode** — Point the shell at an already-running OmniRoute server (e.g. a Docker/OrbStack container, or another machine) instead of spawning a local one — see below
- **Window Controls** — Custom minimize, maximize, close via IPC
- **Content Security Policy** — Restrictive CSP via session headers
- **Offline Support** — Bundled Next.js standalone server
- **Single Instance** — Only one app instance can run at a time
## Remote Server Mode
By default the desktop shell spawns and manages its own bundled Next.js server. If you
already run OmniRoute elsewhere — most commonly in a Docker/OrbStack container, so
provider credentials and env-var handling stay isolated from the host — you can point the
shell at that instance instead, so it's purely a native window + tray onto a server you
already run.
**Via the tray menu:** _Remote Server → Connect to Remote Server…_, enter the server's
URL (e.g. `http://localhost:20128`), and save. Leave the field blank and save to
disconnect and go back to the local embedded server. The preference persists across
restarts in `<data dir>/electron-preferences.json` (see `DATA_DIR` above for where that
lives on your platform).
**Via environment variable:** set `OMNIROUTE_REMOTE_URL` before launching the app (e.g.
`OMNIROUTE_REMOTE_URL=http://localhost:20128 npm run dev`, or export it in the
environment that launches the packaged app). The env var always wins over the persisted
preference and is session-scoped — it doesn't get written to the prefs file.
Only `http://` and `https://` URLs are accepted; anything else is rejected before the
window loads.
## Configuration
### Environment Variables
| Variable | Default | Description |
| --------------------- | ------------ | --------------------------------- |
| `OMNIROUTE_PORT` | `20128` | Server port |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit (6416384 MB) |
| `NODE_ENV` | `production` | Set to `development` for dev mode |
| Variable | Default | Description |
| ---------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `OMNIROUTE_PORT` | `20128` | Server port |
| `OMNIROUTE_MEMORY_MB` | `512` | Node.js heap limit (6416384 MB) |
| `OMNIROUTE_REMOTE_URL` | _(unset)_ | Attach to this server instead of spawning a local one — see [Remote Server Mode](#remote-server-mode) |
| `NODE_ENV` | `production` | Set to `development` for dev mode |
### Custom Icon
@@ -175,12 +203,12 @@ Place your icons in `assets/`:
### Invoke (Renderer → Main, async)
| Channel | Returns | Description |
| ---------------- | ------------- | --------------------------------------------- |
| `get-app-info` | `AppInfo` | App name, version, platform, isDev, port |
| `open-external` | `void` | Open URL in default browser (http/https only) |
| `get-data-dir` | `string` | Get userData directory path |
| `restart-server` | `{ success }` | Stop + restart server (5s timeout + SIGKILL) |
| Channel | Returns | Description |
| ---------------- | ------------- | --------------------------------------------------------- |
| `get-app-info` | `AppInfo` | App name, version, platform, isDev, port, remoteServerUrl |
| `open-external` | `void` | Open URL in default browser (http/https only) |
| `get-data-dir` | `string` | Get userData directory path |
| `restart-server` | `{ success }` | Stop + restart server (5s timeout + SIGKILL) |
### Send (Renderer → Main, fire-and-forget)

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'self'"
/>
<title>Connect to Remote Server</title>
<style>
body {
margin: 0;
padding: 20px;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #1a1a1a;
color: #e5e5e5;
user-select: none;
}
p {
margin: 0 0 12px;
font-size: 13px;
color: #a3a3a3;
}
input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
font-size: 13px;
border-radius: 6px;
border: 1px solid #3f3f3f;
background: #262626;
color: #e5e5e5;
}
.error {
color: #f87171;
font-size: 12px;
min-height: 16px;
margin-top: 6px;
}
.actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
button {
padding: 7px 14px;
font-size: 13px;
border-radius: 6px;
border: 1px solid #3f3f3f;
background: #262626;
color: #e5e5e5;
cursor: pointer;
}
button.primary {
background: #ff586b;
border-color: #ff586b;
color: #fff;
}
</style>
</head>
<body>
<p>
Point this desktop app at an already-running OmniRoute server (e.g. a Docker/OrbStack
container) instead of spawning a local one. Leave blank and Save to disconnect.
</p>
<input
id="url-input"
type="text"
placeholder="http://localhost:20128"
autocomplete="off"
spellcheck="false"
/>
<div class="error" id="error"></div>
<div class="actions">
<button id="cancel-btn">Cancel</button>
<button id="save-btn" class="primary">Save</button>
</div>
<script src="../remoteServerPromptRenderer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,75 @@
"use strict";
const fs = require("fs");
const path = require("path");
/**
* remoteServerPreferences.js — pure read/write helpers for the small JSON
* preferences file that persists the operator-configured remote server URL
* across app restarts (see resolveRemoteServerUrl.js for how it's consumed).
*
* Deliberately a plain flat JSON file rather than the app's SQLite database:
* this preference must be readable before deciding whether to spawn (or even
* reach) the local server, so it cannot depend on any server-owned storage.
*
* Extracted as pure, dependency-injectable helpers so they can be unit-tested
* without importing the full Electron main process.
*
* @param {string} prefsPath - absolute path to electron-preferences.json
* @param {(p: string) => boolean} [existsSync]
* @param {(p: string, enc: string) => string} [readFileSync]
* @returns {{remoteServerUrl: string|null}}
*/
function readPreferences(prefsPath, existsSync = fs.existsSync, readFileSync = fs.readFileSync) {
if (!existsSync(prefsPath)) return { remoteServerUrl: null };
try {
const parsed = JSON.parse(readFileSync(prefsPath, "utf8"));
const remoteServerUrl =
typeof parsed.remoteServerUrl === "string" && parsed.remoteServerUrl.trim()
? parsed.remoteServerUrl.trim()
: null;
return { remoteServerUrl };
} catch {
return { remoteServerUrl: null };
}
}
/**
* Persist the remote server URL preference. Pass `null` to clear it (reverts
* to spawning the local embedded server on next restart).
*
* @param {string} prefsPath
* @param {string|null} remoteServerUrl
* @param {(p: string) => boolean} [existsSync]
* @param {(p: string, enc: string) => string} [readFileSync]
* @param {(p: string, data: string, enc: string) => void} [writeFileSync]
* @param {(p: string, opts: object) => void} [mkdirSync]
*/
function writeRemoteServerUrl(
prefsPath,
remoteServerUrl,
{
existsSync = fs.existsSync,
readFileSync = fs.readFileSync,
writeFileSync = fs.writeFileSync,
mkdirSync = fs.mkdirSync,
} = {}
) {
try {
const dir = path.dirname(prefsPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const current = readPreferences(prefsPath, existsSync, readFileSync);
const next = { ...current, remoteServerUrl: remoteServerUrl || null };
writeFileSync(prefsPath, JSON.stringify(next, null, 2) + "\n", "utf8");
} catch (err) {
console.error(
`[remoteServerPreferences] Failed to write preferences to ${prefsPath}:`,
err instanceof Error ? err.message : String(err)
);
}
}
module.exports = { readPreferences, writeRemoteServerUrl };

View File

@@ -0,0 +1,79 @@
"use strict";
const fs = require("fs");
/**
* resolveRemoteServerUrl.js — pure helper for resolving an operator-configured
* remote OmniRoute server URL, so the Electron shell can attach to an
* already-running instance (e.g. a Docker/OrbStack container, or a server on
* another machine on the LAN) instead of spawning its own bundled Next.js
* server.
*
* Some environments make the bundled local server impractical — for example,
* a host that injects provider API keys via a secrets manager in a way the
* packaged app's env-file loading doesn't expect. Running the real server in
* an isolated container and pointing the desktop shell at it sidesteps that
* entirely.
*
* Precedence:
* 1. OMNIROUTE_REMOTE_URL env var (explicit, session-scoped override)
* 2. `remoteServerUrl` key in <dataDir>/electron-preferences.json (persisted
* via the tray menu's "Connect to Remote Server…" prompt)
* 3. null — caller falls back to spawning the local embedded server
*
* Extracted as a pure helper (env + fs injectable) so it can be unit-tested
* without importing the full Electron main process (which requires the
* Electron binary).
*
* @param {object} opts
* @param {NodeJS.ProcessEnv} opts.env - injectable process.env (for tests)
* @param {string} opts.prefsPath - absolute path to electron-preferences.json
* @param {(p: string) => boolean} [opts.existsSync] - injectable fs.existsSync
* @param {(p: string, enc: string) => string} [opts.readFileSync] - injectable fs.readFileSync
* @returns {string|null} the validated http(s) remote URL (no trailing slash), or null if none configured
*/
function resolveRemoteServerUrl({
env,
prefsPath,
existsSync = fs.existsSync,
readFileSync = fs.readFileSync,
}) {
const candidate = readCandidate({ env, prefsPath, existsSync, readFileSync });
if (!candidate) return null;
return isValidHttpUrl(candidate) ? stripTrailingSlash(candidate) : null;
}
function readCandidate({ env, prefsPath, existsSync, readFileSync }) {
const fromEnv = (env.OMNIROUTE_REMOTE_URL || "").trim();
if (fromEnv) return fromEnv;
if (!prefsPath || !existsSync(prefsPath)) return null;
try {
const prefs = JSON.parse(readFileSync(prefsPath, "utf8"));
const fromPrefs = typeof prefs.remoteServerUrl === "string" ? prefs.remoteServerUrl.trim() : "";
return fromPrefs || null;
} catch {
// Corrupt/partial prefs file — fall back to spawning the local server
// rather than crashing the app on startup.
return null;
}
}
/**
* @param {string} candidate
* @returns {boolean}
*/
function isValidHttpUrl(candidate) {
try {
const parsed = new URL(candidate);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function stripTrailingSlash(url) {
return url.replace(/\/+$/, "");
}
module.exports = { resolveRemoteServerUrl, isValidHttpUrl };

View File

@@ -37,6 +37,8 @@ const { loginManager } = require("./loginManager");
const { killProcessTree } = require("./processTree");
const { resolveServerEntry } = require("./lib/resolveServerEntry");
const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper");
const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl");
const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences");
// ── Single Instance Lock ───────────────────────────────────
const gotTheLock = app.requestSingleInstanceLock();
@@ -67,8 +69,23 @@ let tray = null;
let nextServer = null;
let serverPort = 20128;
let isServerStopped = false;
let remoteServerPromptWindow = null;
const getServerUrl = () => `http://localhost:${serverPort}`;
// ── Remote Server Mode ──────────────────────────────────────
// Lets the desktop shell attach to an already-running OmniRoute server (e.g. a
// Docker/OrbStack container, or another machine) instead of spawning its own
// bundled Next.js server. See lib/resolveRemoteServerUrl.js for precedence
// (OMNIROUTE_REMOTE_URL env var, then the persisted prefs file below).
const REMOTE_SERVER_PREFS_PATH = path.join(
resolveDataDir(null, process.env),
"electron-preferences.json"
);
let remoteServerUrl = resolveRemoteServerUrl({
env: process.env,
prefsPath: REMOTE_SERVER_PREFS_PATH,
});
const getServerUrl = () => remoteServerUrl || `http://localhost:${serverPort}`;
function resolveNodeExecutable(env = process.env) {
// #1081: Ensure Next.js standalone runs using Electron's Node runtime
@@ -456,6 +473,23 @@ function createTray() {
{ label: "3000", click: () => changePort(3000) },
{ label: "8080", click: () => changePort(8080) },
],
enabled: !remoteServerUrl,
},
{
label: "Remote Server",
submenu: [
{
label: remoteServerUrl ? `Connected: ${remoteServerUrl}` : "Using local embedded server",
enabled: false,
},
{ type: "separator" },
{ label: "Connect to Remote Server…", click: () => showRemoteServerPrompt() },
{
label: "Disconnect (use Local Server)",
enabled: Boolean(remoteServerUrl),
click: () => setRemoteServerUrl(null),
},
],
},
{ type: "separator" },
{
@@ -512,8 +546,97 @@ async function changePort(newPort) {
console.log(`[Electron] Port changed: ${oldPort}${serverPort}`);
}
// ── Remote Server Mode: prompt window ──────────────────────
function showRemoteServerPrompt() {
if (remoteServerPromptWindow && !remoteServerPromptWindow.isDestroyed()) {
remoteServerPromptWindow.show();
remoteServerPromptWindow.focus();
return;
}
remoteServerPromptWindow = new BrowserWindow({
width: 480,
height: 210,
resizable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
title: "Connect to Remote Server",
parent: mainWindow || undefined,
modal: Boolean(mainWindow),
webPreferences: {
preload: path.join(__dirname, "remoteServerPromptPreload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
remoteServerPromptWindow.setMenuBarVisibility(false);
remoteServerPromptWindow.loadFile(path.join(__dirname, "assets", "remoteServerPrompt.html"));
remoteServerPromptWindow.on("closed", () => {
remoteServerPromptWindow = null;
});
}
// ── Remote Server Mode: apply a new URL (or clear it) ──────
async function setRemoteServerUrl(nextUrl) {
const normalized = (nextUrl || "").trim() || null;
if (normalized === remoteServerUrl) return;
// Reject invalid URLs — only http:// and https:// are accepted.
if (normalized !== null && !isValidHttpUrl(normalized)) {
console.warn("[Electron] Rejected invalid remote server URL:", normalized);
return;
}
sendToRenderer("server-status", { status: "restarting", port: serverPort });
// Stop any locally-spawned server before switching modes in either direction.
const serverToStop = nextServer;
stopNextServer();
await waitForServerExit(serverToStop);
remoteServerUrl = normalized;
writeRemoteServerUrl(REMOTE_SERVER_PREFS_PATH, remoteServerUrl);
startNextServer();
try {
await waitForServer(`${getServerUrl()}/api/monitoring/health`);
} catch (err) {
console.warn("[Electron] Server did not become ready after remote-server change:", err.message);
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.loadURL(getServerUrl());
}
createTray();
sendToRenderer("server-status", {
status: "running",
port: serverPort,
remoteUrl: remoteServerUrl,
});
console.log(
remoteServerUrl
? `[Electron] Now connected to remote server: ${remoteServerUrl}`
: "[Electron] Disconnected from remote server — spawning local server again"
);
}
// ── Server Lifecycle (#1, #5, #10) ─────────────────────────
function startNextServer() {
if (remoteServerUrl) {
console.log("[Electron] Remote server mode — connecting to", remoteServerUrl);
sendToRenderer("server-status", {
status: "running",
port: serverPort,
remoteUrl: remoteServerUrl,
});
return;
}
if (isDev) {
console.log("[Electron] Dev mode — connect to existing Next.js server");
sendToRenderer("server-status", { status: "running", port: serverPort });
@@ -777,8 +900,22 @@ function setupIpcHandlers() {
platform: process.platform,
isDev,
port: serverPort,
remoteServerUrl,
}));
// ── Remote Server Mode: prompt window IPC (main-process-only trust
// boundary — this window never loads remote/untrusted content) ──
ipcMain.handle("remote-server-prompt:get-initial-url", () => remoteServerUrl || "");
ipcMain.on("remote-server-prompt:submit", (_event, url) => {
remoteServerPromptWindow?.close();
void setRemoteServerUrl(url);
});
ipcMain.on("remote-server-prompt:cancel", () => {
remoteServerPromptWindow?.close();
});
ipcMain.handle("open-external", (_event, url) => {
try {
const parsedUrl = new URL(url);

View File

@@ -60,8 +60,13 @@
"loginManager.js",
"processTree.js",
"sqlite-inspection.js",
"remoteServerPromptPreload.js",
"remoteServerPromptRenderer.js",
"lib/resolveServerEntry.js",
"lib/resolveNodeHelper.js",
"lib/resolveRemoteServerUrl.js",
"lib/remoteServerPreferences.js",
"assets/remoteServerPrompt.html",
"package.json",
"node_modules/**/*"
],

View File

@@ -106,8 +106,15 @@ const VALID_CHANNELS = {
"login:start",
"login:cancel",
"login:status",
"remote-server-prompt:get-initial-url",
],
send: [
"window-minimize",
"window-maximize",
"window-close",
"remote-server-prompt:submit",
"remote-server-prompt:cancel",
],
send: ["window-minimize", "window-maximize", "window-close"],
receive: ["server-status", "port-changed", "update-status", "login:status"],
};
@@ -160,6 +167,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
// ── Receive (event listeners) ────────────────────────────
// Fix #6: Returns a disposer function for precise cleanup
// "server-status" payloads include remoteUrl when running in Remote Server
// Mode (see electron/main.js setRemoteServerUrl) — surfaced here read-only;
// the actual URL is configured via the tray menu, not the renderer.
onServerStatus: (callback) => safeOn("server-status", callback),
onPortChanged: (callback) => safeOn("port-changed", callback),
onUpdateStatus: (callback) => safeOn("update-status", callback),

View File

@@ -0,0 +1,15 @@
/**
* Preload for the small "Connect to Remote Server" prompt window.
*
* Kept separate from the main preload.js — this window only ever loads our
* own bundled remoteServerPrompt.html (never remote/untrusted content), but we
* still keep contextIsolation on and expose the minimum surface needed.
*/
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("remoteServerPrompt", {
getInitialUrl: () => ipcRenderer.invoke("remote-server-prompt:get-initial-url"),
submit: (url) => ipcRenderer.send("remote-server-prompt:submit", url),
cancel: () => ipcRenderer.send("remote-server-prompt:cancel"),
});

View File

@@ -0,0 +1,40 @@
(function () {
const input = document.getElementById("url-input");
const errorEl = document.getElementById("error");
const saveBtn = document.getElementById("save-btn");
const cancelBtn = document.getElementById("cancel-btn");
function isValidOrEmpty(value) {
const trimmed = value.trim();
if (!trimmed) return true; // empty = disconnect, handled by main process
try {
const parsed = new URL(trimmed);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
window.remoteServerPrompt.getInitialUrl().then((url) => {
input.value = url || "";
input.focus();
});
saveBtn.addEventListener("click", () => {
const value = input.value.trim();
if (!isValidOrEmpty(value)) {
errorEl.textContent = "Enter a valid http:// or https:// URL, or leave blank to disconnect.";
return;
}
window.remoteServerPrompt.submit(value);
});
cancelBtn.addEventListener("click", () => {
window.remoteServerPrompt.cancel();
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") saveBtn.click();
if (event.key === "Escape") cancelBtn.click();
});
})();

4
electron/types.d.ts vendored
View File

@@ -14,11 +14,15 @@ export interface AppInfo {
platform: "win32" | "darwin" | "linux";
isDev: boolean;
port: number;
/** Set when Remote Server Mode is active (tray → Remote Server → Connect…). */
remoteServerUrl: string | null;
}
export interface ServerStatus {
status: "starting" | "running" | "stopped" | "restarting" | "error";
port: number;
/** Present only while connected to a remote server instead of the embedded one. */
remoteUrl?: string;
}
export interface ElectronAPI {