From 7feafd52c9a1490cb3e97dd92361baa1e9181d01 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 6 Aug 2026 04:05:24 -0500 Subject: [PATCH] [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) --- .../8799-electron-remote-server-mode.md | 1 + electron/README.md | 50 +++- electron/assets/remoteServerPrompt.html | 86 ++++++ electron/lib/remoteServerPreferences.js | 75 ++++++ electron/lib/resolveRemoteServerUrl.js | 79 ++++++ electron/main.js | 139 +++++++++- electron/package.json | 5 + electron/preload.js | 12 +- electron/remoteServerPromptPreload.js | 15 ++ electron/remoteServerPromptRenderer.js | 40 +++ electron/types.d.ts | 4 + tests/unit/electron-remote-server.test.ts | 254 ++++++++++++++++++ 12 files changed, 747 insertions(+), 13 deletions(-) create mode 100644 changelog.d/features/8799-electron-remote-server-mode.md create mode 100644 electron/assets/remoteServerPrompt.html create mode 100644 electron/lib/remoteServerPreferences.js create mode 100644 electron/lib/resolveRemoteServerUrl.js create mode 100644 electron/remoteServerPromptPreload.js create mode 100644 electron/remoteServerPromptRenderer.js create mode 100644 tests/unit/electron-remote-server.test.ts diff --git a/changelog.d/features/8799-electron-remote-server-mode.md b/changelog.d/features/8799-electron-remote-server-mode.md new file mode 100644 index 0000000000..f7c4016b3c --- /dev/null +++ b/changelog.d/features/8799-electron-remote-server-mode.md @@ -0,0 +1 @@ +- **feat(electron):** Desktop app can now attach to an already-running OmniRoute server (e.g. a Docker/OrbStack container) instead of always spawning its own bundled server — configurable via the tray's "Remote Server → Connect to Remote Server…" or the `OMNIROUTE_REMOTE_URL` env var ([#8799](https://github.com/diegosouzapw/OmniRoute/pull/8799)) — thanks @soulhakr diff --git a/electron/README.md b/electron/README.md index b03e3a919d..ea13c6f6e2 100644 --- a/electron/README.md +++ b/electron/README.md @@ -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 `/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 (64–16384 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 (64–16384 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) diff --git a/electron/assets/remoteServerPrompt.html b/electron/assets/remoteServerPrompt.html new file mode 100644 index 0000000000..80ae972942 --- /dev/null +++ b/electron/assets/remoteServerPrompt.html @@ -0,0 +1,86 @@ + + + + + + Connect to Remote Server + + + +

+ 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. +

+ +
+
+ + +
+ + + + diff --git a/electron/lib/remoteServerPreferences.js b/electron/lib/remoteServerPreferences.js new file mode 100644 index 0000000000..21b683290f --- /dev/null +++ b/electron/lib/remoteServerPreferences.js @@ -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 }; diff --git a/electron/lib/resolveRemoteServerUrl.js b/electron/lib/resolveRemoteServerUrl.js new file mode 100644 index 0000000000..97703b6308 --- /dev/null +++ b/electron/lib/resolveRemoteServerUrl.js @@ -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 /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 }; diff --git a/electron/main.js b/electron/main.js index a949bef103..b98692b295 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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); diff --git a/electron/package.json b/electron/package.json index a2bb7614b5..81f07da02e 100644 --- a/electron/package.json +++ b/electron/package.json @@ -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/**/*" ], diff --git a/electron/preload.js b/electron/preload.js index 0eabaa2748..21a40b178e 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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), diff --git a/electron/remoteServerPromptPreload.js b/electron/remoteServerPromptPreload.js new file mode 100644 index 0000000000..af05f55b9c --- /dev/null +++ b/electron/remoteServerPromptPreload.js @@ -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"), +}); diff --git a/electron/remoteServerPromptRenderer.js b/electron/remoteServerPromptRenderer.js new file mode 100644 index 0000000000..f1689920ec --- /dev/null +++ b/electron/remoteServerPromptRenderer.js @@ -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(); + }); +})(); diff --git a/electron/types.d.ts b/electron/types.d.ts index c93a04fc77..c78fbaf2b1 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -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 { diff --git a/tests/unit/electron-remote-server.test.ts b/tests/unit/electron-remote-server.test.ts new file mode 100644 index 0000000000..05903db782 --- /dev/null +++ b/tests/unit/electron-remote-server.test.ts @@ -0,0 +1,254 @@ +/** + * Tests for Electron Remote Server Mode + * + * Covers: + * - resolveRemoteServerUrl precedence (env > persisted prefs > null) + * - URL validation (only http/https accepted, trailing slash stripped) + * - Corrupt/partial prefs file handled gracefully (falls back to local server) + * - remoteServerPreferences read/write round-trip + * - main.js wiring: startNextServer() short-circuits in remote mode, tray + * menu exposes the toggle, packaging manifest ships the new files + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + resolveRemoteServerUrl, + isValidHttpUrl, +} = require("../../electron/lib/resolveRemoteServerUrl"); +const { + readPreferences, + writeRemoteServerUrl, +} = require("../../electron/lib/remoteServerPreferences"); + +function withTempDir(fn: (dir: string) => void) { + const dir = mkdtempSync(join(tmpdir(), "omniroute-remote-server-")); + try { + fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("resolveRemoteServerUrl precedence", () => { + it("returns null when neither env var nor prefs file are set", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + const result = resolveRemoteServerUrl({ env: {}, prefsPath }); + assert.equal(result, null); + }); + }); + + it("prefers OMNIROUTE_REMOTE_URL env var over the persisted prefs file", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + writeRemoteServerUrl(prefsPath, "http://from-prefs:20128"); + + const result = resolveRemoteServerUrl({ + env: { OMNIROUTE_REMOTE_URL: "http://from-env:20128" }, + prefsPath, + }); + assert.equal(result, "http://from-env:20128"); + }); + }); + + it("falls back to the persisted prefs file when no env var is set", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + writeRemoteServerUrl(prefsPath, "http://localhost:20128"); + + const result = resolveRemoteServerUrl({ env: {}, prefsPath }); + assert.equal(result, "http://localhost:20128"); + }); + }); + + it("strips a trailing slash from the resolved URL", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + const result = resolveRemoteServerUrl({ + env: { OMNIROUTE_REMOTE_URL: "http://localhost:20128/" }, + prefsPath, + }); + assert.equal(result, "http://localhost:20128"); + }); + }); + + it("rejects a non-http(s) URL (e.g. file:// or javascript:) and returns null", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + for (const bad of ["file:///etc/passwd", "javascript:alert(1)", "not a url", ""]) { + const result = resolveRemoteServerUrl({ env: { OMNIROUTE_REMOTE_URL: bad }, prefsPath }); + assert.equal(result, null, `expected null for ${JSON.stringify(bad)}`); + } + }); + }); + + it("ignores a corrupt prefs file and falls back to null rather than throwing", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + require("node:fs").writeFileSync(prefsPath, "{ not valid json", "utf8"); + + const result = resolveRemoteServerUrl({ env: {}, prefsPath }); + assert.equal(result, null); + }); + }); + + it("treats a missing prefs file as absent rather than throwing", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "does-not-exist.json"); + assert.doesNotThrow(() => resolveRemoteServerUrl({ env: {}, prefsPath })); + }); + }); +}); + +describe("isValidHttpUrl", () => { + it("accepts http and https", () => { + assert.equal(isValidHttpUrl("http://localhost:20128"), true); + assert.equal(isValidHttpUrl("https://omniroute.example.com"), true); + }); + + it("rejects other protocols and invalid strings", () => { + assert.equal(isValidHttpUrl("ftp://example.com"), false); + assert.equal(isValidHttpUrl("file:///etc/passwd"), false); + assert.equal(isValidHttpUrl("javascript:alert(1)"), false); + assert.equal(isValidHttpUrl("not a url"), false); + }); +}); + +describe("remoteServerPreferences read/write", () => { + it("round-trips a URL through write then read", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + writeRemoteServerUrl(prefsPath, "http://localhost:20128"); + assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: "http://localhost:20128" }); + }); + }); + + it("clearing with null removes the preference", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + writeRemoteServerUrl(prefsPath, "http://localhost:20128"); + writeRemoteServerUrl(prefsPath, null); + assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: null }); + }); + }); + + it("creates the parent directory if it does not exist yet", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "nested", "deep", "electron-preferences.json"); + assert.doesNotThrow(() => writeRemoteServerUrl(prefsPath, "http://localhost:20128")); + assert.equal(existsSync(prefsPath), true); + assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: "http://localhost:20128" }); + }); + }); + + it("reading a nonexistent prefs file returns remoteServerUrl: null", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: null }); + }); + }); +}); + +// ─── main.js wiring (static-analysis style, matching the repo's existing +// convention for asserting structure without importing the Electron binary) ─── + +describe("Electron main.js Remote Server Mode wiring", () => { + const mainSrc = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + + it("startNextServer() short-circuits before the isDev branch when remoteServerUrl is set", () => { + const fn = mainSrc.match(/function startNextServer\(\)[\s\S]*?\n}/); + assert.ok(fn, "startNextServer function should exist in electron/main.js"); + const body = fn![0]; + const remoteIdx = body.indexOf("if (remoteServerUrl)"); + const devIdx = body.indexOf("if (isDev)"); + assert.ok(remoteIdx !== -1, "startNextServer must check remoteServerUrl"); + assert.ok(devIdx !== -1, "startNextServer must still check isDev"); + assert.ok(remoteIdx < devIdx, "the remoteServerUrl check must come before the isDev check"); + }); + + it("getServerUrl() prefers remoteServerUrl over the local port", () => { + assert.match( + mainSrc, + /const getServerUrl = \(\) => remoteServerUrl \|\| `http:\/\/localhost:\$\{serverPort\}`;/ + ); + }); + + it("exposes a tray menu entry to configure or clear the remote server", () => { + assert.match(mainSrc, /label: "Remote Server"/); + assert.match(mainSrc, /Connect to Remote Server/); + assert.match(mainSrc, /Disconnect \(use Local Server\)/); + }); + + it("the remote-server prompt window uses contextIsolation and disables nodeIntegration", () => { + const fn = mainSrc.match(/function showRemoteServerPrompt\(\)[\s\S]*?\n}/); + assert.ok(fn, "showRemoteServerPrompt function should exist"); + const body = fn![0]; + assert.match(body, /contextIsolation:\s*true/); + assert.match(body, /nodeIntegration:\s*false/); + }); + + // setRemoteServerUrl() is the runtime, UI-driven path (tray prompt / IPC) for + // applying an operator-supplied URL — distinct from resolveRemoteServerUrl()'s + // startup precedence, which is already covered above. A URL typed into the + // "Connect to Remote Server…" prompt must go through the same isValidHttpUrl + // guard (only http/https accepted) *before* any server-lifecycle mutation, so + // an arbitrary/malicious string (file://, javascript:, garbage) can never reach + // stopNextServer()/startNextServer() or get persisted to prefs. Exercised via + // static analysis (matching this file's convention) since setRemoteServerUrl + // requires the full Electron main process to invoke directly. + it("setRemoteServerUrl() validates via isValidHttpUrl and rejects before mutating server state", () => { + const fn = mainSrc.match(/async function setRemoteServerUrl\(nextUrl\)[\s\S]*?\n}/); + assert.ok(fn, "setRemoteServerUrl function should exist in electron/main.js"); + const body = fn![0]; + + const validationIdx = body.indexOf("isValidHttpUrl(normalized)"); + const stopServerIdx = body.indexOf("stopNextServer()"); + assert.ok(validationIdx !== -1, "setRemoteServerUrl must validate via isValidHttpUrl"); + assert.ok( + stopServerIdx !== -1, + "setRemoteServerUrl must stop the running server when switching modes" + ); + assert.ok( + validationIdx < stopServerIdx, + "URL validation must run before any server-lifecycle mutation" + ); + + const rejectBranch = body.slice(validationIdx, stopServerIdx); + assert.match( + rejectBranch, + /return;/, + "an invalid URL must short-circuit setRemoteServerUrl instead of falling through" + ); + assert.match( + rejectBranch, + /console\.warn/, + "an invalid URL should be logged so operators can see it was rejected" + ); + }); +}); + +describe("Electron packaging manifest includes Remote Server Mode files", () => { + const pkg = JSON.parse( + readFileSync(join(import.meta.dirname, "../../electron/package.json"), "utf8") + ); + const files: string[] = pkg.build?.files ?? []; + + for (const expected of [ + "lib/resolveRemoteServerUrl.js", + "lib/remoteServerPreferences.js", + "remoteServerPromptPreload.js", + "remoteServerPromptRenderer.js", + "assets/remoteServerPrompt.html", + ]) { + it(`ships ${expected} in package.json build.files`, () => { + assert.ok(files.includes(expected), `${expected} is missing from build.files`); + }); + } +});