chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
# OmniRoute Electron Desktop App
|
||||
|
||||
This directory contains the Electron desktop application wrapper for OmniRoute.
|
||||
|
||||
## Architecture (v1.6.4)
|
||||
|
||||
```
|
||||
electron/
|
||||
├── main.js # Main process — window, tray, server lifecycle, CSP, IPC
|
||||
├── preload.js # Preload script — secure IPC bridge with disposer pattern
|
||||
├── package.json # Electron-specific dependencies & electron-builder config
|
||||
├── types.d.ts # TypeScript definitions (AppInfo, ServerStatus, ElectronAPI)
|
||||
└── assets/ # Application icons and resources
|
||||
|
||||
src/shared/hooks/
|
||||
└── useElectron.ts # React hooks — useSyncExternalStore, zero re-renders
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `waitForServer()` polling | Prevents blank screen on cold start — polls `http://localhost:PORT` before loading |
|
||||
| `stdio: 'pipe'` | Captures server stdout/stderr for logging + readiness detection (not `inherit`) |
|
||||
| Disposer pattern | `onServerStatus()` returns `() => void` for precise listener cleanup (no `removeAllListeners`) |
|
||||
| `useSyncExternalStore` | Zero re-renders for `useIsElectron()` — no `useState` + `useEffect` cycle |
|
||||
| CSP via session headers | `Content-Security-Policy` restricts `script-src`, `connect-src` etc. per Electron best practices |
|
||||
| Platform-conditional titlebar | `titleBarStyle: 'hiddenInset'` only on macOS; `default` on Windows/Linux |
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Build the Next.js app first:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Install Electron dependencies:
|
||||
|
||||
```bash
|
||||
cd electron
|
||||
npm install
|
||||
```
|
||||
|
||||
### Running in Development
|
||||
|
||||
1. Start the Next.js development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. In another terminal, start Electron:
|
||||
|
||||
```bash
|
||||
cd electron
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Running in Production Mode
|
||||
|
||||
1. Build Next.js in standalone mode:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Start Electron:
|
||||
|
||||
```bash
|
||||
cd electron
|
||||
npm start
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Build for Current Platform
|
||||
|
||||
```bash
|
||||
cd electron
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Build for Specific Platforms
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
npm run build:win
|
||||
|
||||
# macOS (x64 + arm64)
|
||||
npm run build:mac
|
||||
|
||||
# Linux
|
||||
npm run build:linux
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Built applications are placed in `dist-electron/`:
|
||||
|
||||
- Windows: `.exe` installer (NSIS) + portable `.exe`
|
||||
- macOS: `.dmg` installer (Intel + Apple Silicon)
|
||||
- Linux: `.AppImage`
|
||||
|
||||
## Installation
|
||||
|
||||
### macOS
|
||||
|
||||
1. Download the latest `.dmg` from the [Releases](https://github.com/diegosouzapw/OmniRoute/releases) page.
|
||||
2. Open the `.dmg` file.
|
||||
3. Drag `OmniRoute.app` to the Applications folder.
|
||||
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.
|
||||
|
||||
### Linux
|
||||
|
||||
1. Download the `.AppImage` from [Releases](https://github.com/diegosouzapw/OmniRoute/releases).
|
||||
2. Make it executable:
|
||||
```bash
|
||||
chmod +x OmniRoute-*.AppImage
|
||||
```
|
||||
3. Run:
|
||||
```bash
|
||||
./OmniRoute-*.AppImage
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
## 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 |
|
||||
|
||||
### Custom Icon
|
||||
|
||||
Place your icons in `assets/`:
|
||||
|
||||
- `icon.ico` — Windows icon (256×256)
|
||||
- `icon.icns` — macOS icon bundle
|
||||
- `icon.png` — Linux/general use (512×512)
|
||||
- `tray-icon.png` — System tray icon (16×16 or 32×32)
|
||||
|
||||
## IPC Channels
|
||||
|
||||
### 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) |
|
||||
|
||||
### Send (Renderer → Main, fire-and-forget)
|
||||
|
||||
| Channel | Description |
|
||||
| ----------------- | ------------------------------- |
|
||||
| `window-minimize` | Minimize window |
|
||||
| `window-maximize` | Toggle maximize/restore |
|
||||
| `window-close` | Close window (minimize to tray) |
|
||||
|
||||
### Receive (Main → Renderer, events)
|
||||
|
||||
| Channel | Payload | Emitted When |
|
||||
| --------------- | -------------- | ----------------------------------------- |
|
||||
| `server-status` | `ServerStatus` | Server starts, stops, errors, or restarts |
|
||||
| `port-changed` | `number` | Port change via tray menu |
|
||||
|
||||
> **Note**: Listeners return disposer functions for precise cleanup. See `useServerStatus` and `usePortChanged` hooks.
|
||||
|
||||
## Security
|
||||
|
||||
| Feature | Implementation |
|
||||
| ----------------- | ------------------------------------------------------------------------------- |
|
||||
| Context Isolation | `contextIsolation: true` — renderer cannot access Node.js |
|
||||
| Node Integration | `nodeIntegration: false` — no `require()` in renderer |
|
||||
| IPC Whitelist | Channel names validated in preload via `safeInvoke`/`safeSend`/`safeOn` |
|
||||
| URL Validation | `shell.openExternal()` only allows `http:` / `https:` protocols |
|
||||
| CSP | `Content-Security-Policy` header set via `session.webRequest.onHeadersReceived` |
|
||||
| Web Security | `webSecurity: true` — same-origin policy enforced |
|
||||
|
||||
## React Hooks
|
||||
|
||||
| Hook | Returns | Description |
|
||||
| ---------------------- | ------------------------------- | ------------------------------------------------ |
|
||||
| `useIsElectron()` | `boolean` | Zero-render detection via `useSyncExternalStore` |
|
||||
| `useElectronAppInfo()` | `{ appInfo, loading, error }` | App info from main process |
|
||||
| `useDataDir()` | `{ dataDir, loading, error }` | User data directory |
|
||||
| `useWindowControls()` | `{ minimize, maximize, close }` | Window control actions |
|
||||
| `useOpenExternal()` | `{ openExternal }` | Open URLs in browser |
|
||||
| `useServerControls()` | `{ restart, restarting }` | Server restart control |
|
||||
| `useServerStatus(cb)` | Disposer | Listen for server status events |
|
||||
| `usePortChanged(cb)` | Disposer | Listen for port change events |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### App Won't Start
|
||||
|
||||
1. Check if port 20128 is available: `lsof -i :20128`
|
||||
2. Check console logs for `[Electron]` prefix
|
||||
3. Verify the build output exists in `.build/next/standalone`
|
||||
|
||||
### White Screen
|
||||
|
||||
1. Verify Next.js build exists — server readiness waits 30s max
|
||||
2. Check `[Server]` and `[Server:err]` log output
|
||||
3. Look for CSP violations in developer console
|
||||
|
||||
### Build Fails
|
||||
|
||||
Ensure you have build tools installed:
|
||||
|
||||
- Windows: Visual Studio Build Tools
|
||||
- macOS: Xcode Command Line Tools
|
||||
- Linux: `build-essential`, `libsecret-1-dev`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 713 B |
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* resolveServerEntry.js — pure helper for selecting the Next.js server entrypoint.
|
||||
*
|
||||
* The WS-aware wrapper `server-ws.mjs` installs the trusted peer-IP stamp
|
||||
* (peer-stamp.mjs) that the authz middleware needs to allow loopback/LAN access
|
||||
* to LOCAL_ONLY routes (AgentBridge, MCP, services, etc.). Without it every
|
||||
* LOCAL_ONLY request returns 403.
|
||||
*
|
||||
* We prefer `server-ws.mjs` when it exists — mirroring run-standalone.mjs — and
|
||||
* fall back to the bare `server.js` only when the wrapper is absent (e.g. an
|
||||
* older build or a stripped bundle).
|
||||
*
|
||||
* Extracted as a pure helper so it can be unit-tested without importing the full
|
||||
* Electron main process (which requires the Electron binary).
|
||||
*
|
||||
* @param {string} serverDir - directory that contains server.js / server-ws.mjs
|
||||
* @param {Function} existsSyncFn - injectable fs.existsSync (for unit tests)
|
||||
* @returns {string} filename ("server-ws.mjs" or "server.js")
|
||||
*/
|
||||
function resolveServerEntry(serverDir, existsSyncFn) {
|
||||
const path = require("path");
|
||||
const wsEntry = path.join(serverDir, "server-ws.mjs");
|
||||
return existsSyncFn(wsEntry) ? "server-ws.mjs" : "server.js";
|
||||
}
|
||||
|
||||
module.exports = { resolveServerEntry };
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* LoginManager — Electron BrowserWindow-based web login for cookie providers
|
||||
*
|
||||
* Opens a native Electron window navigated to the provider's login page.
|
||||
* Polls the session cookie store for target cookies after login completes.
|
||||
*
|
||||
* Events:
|
||||
* "status" — { status: string, message: string, providerId: string }
|
||||
* status values: starting, navigating, waiting, polling, complete, error, cancelled
|
||||
*/
|
||||
|
||||
const { BrowserWindow, session } = require("electron");
|
||||
const { EventEmitter } = require("events");
|
||||
const path = require("path");
|
||||
|
||||
// In production, the tokenExtractionConfig is bundled under open-sse/services/.
|
||||
// We resolve relative to the Electron resources path.
|
||||
let TOKEN_EXTRACTION_CONFIGS = null;
|
||||
function getConfigs() {
|
||||
if (TOKEN_EXTRACTION_CONFIGS) return TOKEN_EXTRACTION_CONFIGS;
|
||||
try {
|
||||
const mod = require("../open-sse/services/tokenExtractionConfig");
|
||||
TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS;
|
||||
} catch {
|
||||
// Fallback: try from app resources
|
||||
try {
|
||||
const mod = require("./open-sse/services/tokenExtractionConfig");
|
||||
TOKEN_EXTRACTION_CONFIGS = mod.TOKEN_EXTRACTION_CONFIGS;
|
||||
} catch {}
|
||||
}
|
||||
return TOKEN_EXTRACTION_CONFIGS;
|
||||
}
|
||||
|
||||
class LoginManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.window = null;
|
||||
this.activeProviderId = null;
|
||||
this.resolvePromise = null;
|
||||
this.rejectPromise = null;
|
||||
this.timeoutId = null;
|
||||
this.isCompleted = false;
|
||||
this.pollIntervalId = null;
|
||||
this.loginSession = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a login flow for a web-cookie provider.
|
||||
* @param {string} providerId - e.g. "claude-web", "chatgpt-web"
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.timeout] - Total timeout in ms (default: config or 300s)
|
||||
* @returns {Promise<{success: boolean, credentials?: Record<string, string>, error?: string}>}
|
||||
*/
|
||||
startLogin(providerId, options = {}) {
|
||||
const configs = getConfigs();
|
||||
if (!configs) {
|
||||
return Promise.resolve({
|
||||
success: false,
|
||||
error: "tokenExtractionConfig module not found",
|
||||
});
|
||||
}
|
||||
|
||||
const extractionConfig = configs.get(providerId);
|
||||
if (!extractionConfig) {
|
||||
return Promise.resolve({
|
||||
success: false,
|
||||
error: `No extraction config for provider: ${providerId}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.activeProviderId) {
|
||||
return Promise.resolve({
|
||||
success: false,
|
||||
error: "A login process is already in progress",
|
||||
});
|
||||
}
|
||||
|
||||
this.activeProviderId = providerId;
|
||||
this.isCompleted = false;
|
||||
|
||||
const timeout = options.timeout || extractionConfig.pollingConfig.timeout || 300_000;
|
||||
const minLoginTime = extractionConfig.pollingConfig.minLoginTime || 5000;
|
||||
const pollInterval = extractionConfig.pollingConfig.pollInterval || 1000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.resolvePromise = resolve;
|
||||
this.rejectPromise = reject;
|
||||
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "starting",
|
||||
message: `Opening ${extractionConfig.displayName} login...`,
|
||||
});
|
||||
|
||||
try {
|
||||
this._openLoginWindow(providerId, extractionConfig, timeout, minLoginTime, pollInterval);
|
||||
} catch (err) {
|
||||
this._cleanup();
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "error",
|
||||
message: `Failed to open window: ${err.message}`,
|
||||
});
|
||||
resolve({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Electron BrowserWindow for login
|
||||
*/
|
||||
_openLoginWindow(providerId, config, timeout, minLoginTime, pollInterval) {
|
||||
this.window = new BrowserWindow({
|
||||
width: 1000,
|
||||
height: 750,
|
||||
title: `Login - ${config.displayName}`,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
session: session.fromPartition(`login-${providerId}-${Date.now()}`),
|
||||
},
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
});
|
||||
|
||||
const winSession = this.window.webContents.session;
|
||||
|
||||
// Track navigation for success URL detection
|
||||
let navigatedToLogin = false;
|
||||
const startTime = Date.now();
|
||||
|
||||
this.window.webContents.on("did-navigate", (_event, url) => {
|
||||
if (this.isCompleted) return;
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
// Check if we've navigated away from the login page (successful login)
|
||||
if (navigatedToLogin && config.successUrlPattern) {
|
||||
if (config.successUrlPattern.test(url)) {
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "detected",
|
||||
message: "Login page redirect detected — extracting cookies...",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!navigatedToLogin) {
|
||||
navigatedToLogin = true;
|
||||
}
|
||||
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "navigating",
|
||||
message: `Navigated to ${parsedUrl.hostname}`,
|
||||
});
|
||||
} catch {
|
||||
// ignore bad URLs
|
||||
}
|
||||
});
|
||||
|
||||
// Load the login page
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "navigating",
|
||||
message: `Loading ${config.loginUrl}...`,
|
||||
});
|
||||
this.window.loadURL(config.loginUrl);
|
||||
|
||||
// Show window when ready
|
||||
this.window.once("ready-to-show", () => {
|
||||
this.window.show();
|
||||
});
|
||||
|
||||
// Handle window close by user
|
||||
this.window.on("closed", () => {
|
||||
if (!this.isCompleted) {
|
||||
this._cleanup();
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "cancelled",
|
||||
message: "Login window closed by user",
|
||||
});
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise({ success: false, error: "Login window closed" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start polling for cookies after minLoginTime has elapsed
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (this.isCompleted) return;
|
||||
this._startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime);
|
||||
}, minLoginTime);
|
||||
|
||||
// Overall timeout
|
||||
this._timeoutTimer = setTimeout(() => {
|
||||
if (!this.isCompleted) {
|
||||
this._cleanup();
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "error",
|
||||
message: "Login timed out",
|
||||
});
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise({ success: false, error: "Login timed out" });
|
||||
}
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the Electron session cookie store for the target cookies
|
||||
*/
|
||||
_startPolling(providerId, config, winSession, pollInterval, startTime, minLoginTime) {
|
||||
const maxPolls = Math.floor(config.pollingConfig.timeout / pollInterval);
|
||||
let pollCount = 0;
|
||||
|
||||
const poll = () => {
|
||||
if (this.isCompleted) return;
|
||||
pollCount++;
|
||||
|
||||
// Emit progress every 30 polls
|
||||
if (pollCount % 30 === 0) {
|
||||
const elapsed = Math.round((Date.now() - startTime) / 60000);
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "waiting",
|
||||
message: `Waiting for login... (${elapsed}m)`,
|
||||
});
|
||||
}
|
||||
|
||||
winSession.cookies
|
||||
.get({})
|
||||
.then((cookies) => {
|
||||
if (this.isCompleted) return;
|
||||
|
||||
const tokenSources = config.tokenSources;
|
||||
const credentials = {};
|
||||
|
||||
// Collect all cookie-based sources
|
||||
const cookieSources = tokenSources.filter((s) => s.type === "cookie");
|
||||
for (const source of cookieSources) {
|
||||
const domain = source.domain || undefined;
|
||||
const matched = cookies.find(
|
||||
(c) => c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, "")))
|
||||
);
|
||||
if (matched) {
|
||||
credentials[source.name] = matched.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check localStorage-based tokens via executeJavaScript
|
||||
const storageSources = tokenSources.filter(
|
||||
(s) => s.type === "localStorage" || s.type === "sessionStorage"
|
||||
);
|
||||
|
||||
if (storageSources.length > 0 && this.window && !this.window.isDestroyed()) {
|
||||
// Execute JS to extract all localStorage/sessionStorage tokens
|
||||
const storageType = storageSources[0].type === "localStorage" ? "localStorage" : "sessionStorage";
|
||||
const keys = storageSources.map((s) => s.key);
|
||||
const js = `(() => {
|
||||
const res = {};
|
||||
${JSON.stringify(keys)}.forEach(k => {
|
||||
try { res[k] = ${storageType}.getItem(k); } catch {}
|
||||
});
|
||||
return res;
|
||||
})()`;
|
||||
|
||||
this.window.webContents
|
||||
.executeJavaScript(js)
|
||||
.then((values) => {
|
||||
if (values && typeof values === "object") {
|
||||
Object.assign(credentials, values);
|
||||
}
|
||||
this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval);
|
||||
})
|
||||
.catch(() => {
|
||||
this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval);
|
||||
});
|
||||
} else {
|
||||
this._checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!this.isCompleted) {
|
||||
this.pollIntervalId = setTimeout(poll, pollInterval);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start first poll
|
||||
this.pollIntervalId = setTimeout(poll, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have all required credentials, otherwise continue polling
|
||||
*/
|
||||
_checkCredentials(providerId, credentials, cookieSources, storageSources, poll, pollInterval) {
|
||||
if (this.isCompleted) return;
|
||||
|
||||
// Collect the required source names/keys
|
||||
const requiredKeys = [
|
||||
...cookieSources.map((s) => s.name),
|
||||
...storageSources.map((s) => s.key),
|
||||
];
|
||||
const foundKeys = Object.keys(credentials);
|
||||
const allFound = requiredKeys.every((k) => foundKeys.includes(k));
|
||||
|
||||
if (allFound && foundKeys.length > 0) {
|
||||
// Success — all credentials extracted
|
||||
this._completeLogin(providerId, foundKeys.reduce((acc, k) => {
|
||||
acc[k] = credentials[k];
|
||||
return acc;
|
||||
}, {}));
|
||||
} else if (!this.isCompleted) {
|
||||
// Continue polling using the configured interval
|
||||
this.pollIntervalId = setTimeout(poll, pollInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the login flow successfully
|
||||
*/
|
||||
_completeLogin(providerId, credentials) {
|
||||
this._cleanup();
|
||||
this.emit("status", {
|
||||
providerId,
|
||||
status: "complete",
|
||||
message: "Credentials extracted successfully",
|
||||
});
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise({ success: true, credentials });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current login flow
|
||||
*/
|
||||
cancel() {
|
||||
if (!this.activeProviderId) return;
|
||||
this._cleanup();
|
||||
this.emit("status", {
|
||||
providerId: this.activeProviderId,
|
||||
status: "cancelled",
|
||||
message: "Login cancelled",
|
||||
});
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise({ success: false, error: "Login cancelled" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources
|
||||
*/
|
||||
_cleanup() {
|
||||
this.isCompleted = true;
|
||||
this.activeProviderId = null;
|
||||
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
if (this._timeoutTimer) {
|
||||
clearTimeout(this._timeoutTimer);
|
||||
this._timeoutTimer = null;
|
||||
}
|
||||
if (this.pollIntervalId) {
|
||||
clearTimeout(this.pollIntervalId);
|
||||
this.pollIntervalId = null;
|
||||
}
|
||||
if (this.window && !this.window.isDestroyed()) {
|
||||
this.window.close();
|
||||
}
|
||||
this.window = null;
|
||||
this.loginSession = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active provider ID, if any
|
||||
*/
|
||||
getActiveProvider() {
|
||||
return this.activeProviderId;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { LoginManager, loginManager: new LoginManager() };
|
||||
+1028
File diff suppressed because it is too large
Load Diff
Generated
+3373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.8.46",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
"name": "OmniRoute Team",
|
||||
"email": "support@omniroute.online"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://omniroute.online",
|
||||
"engines": {
|
||||
"node": ">=22.22.2 <23 || >=24.0.0 <27"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "electron . --no-sandbox",
|
||||
"prepare:bundle": "node ../scripts/build/prepare-electron-standalone.mjs",
|
||||
"build": "npm run prepare:bundle && electron-builder",
|
||||
"build:win": "npm run prepare:bundle && electron-builder --win",
|
||||
"build:mac": "npm run prepare:bundle && electron-builder --mac",
|
||||
"build:mac-x64": "npm run prepare:bundle && electron-builder --mac --x64",
|
||||
"build:mac-arm64": "npm run prepare:bundle && electron-builder --mac --arm64",
|
||||
"build:linux": "npm run prepare:bundle && electron-builder --linux",
|
||||
"pack": "npm run prepare:bundle && electron-builder --dir"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^43.1.0",
|
||||
"electron-builder": "^26.15.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"plist": "^4.0.0",
|
||||
"form-data": "^4.0.6",
|
||||
"js-yaml": "^4.2.0",
|
||||
"undici": "^7.28.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "online.omniroute.desktop",
|
||||
"productName": "OmniRoute",
|
||||
"copyright": "Copyright © 2025 OmniRoute",
|
||||
"buildDependenciesFromSource": true,
|
||||
"directories": {
|
||||
"output": "dist-electron",
|
||||
"buildResources": "assets"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "diegosouzapw",
|
||||
"repo": "OmniRoute"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"loginManager.js",
|
||||
"processTree.js",
|
||||
"sqlite-inspection.js",
|
||||
"lib/resolveServerEntry.js",
|
||||
"package.json",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../.build/electron-standalone",
|
||||
"to": "app",
|
||||
"filter": [
|
||||
"**/*",
|
||||
"node_modules/**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../.build/electron-standalone/node_modules",
|
||||
"to": "app/node_modules",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "assets",
|
||||
"to": "assets",
|
||||
"filter": [
|
||||
"icon.png",
|
||||
"tray-icon.png"
|
||||
]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "assets/icon.ico"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "assets/icon.icns",
|
||||
"category": "public.app-category.productivity"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "assets/icons",
|
||||
"category": "Utility"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"installerIcon": "assets/icon.ico",
|
||||
"uninstallerIcon": "assets/icon.ico"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* OmniRoute Electron Desktop App - Preload Script
|
||||
*
|
||||
* Secure bridge between renderer (Next.js) and main process (Electron).
|
||||
* Uses contextIsolation: true for maximum security.
|
||||
*
|
||||
* Code Review Fixes Applied:
|
||||
* #6 Listener accumulation — return disposer functions instead of using removeAllListeners
|
||||
* #16 Simplified channel validation — generic wrapper reduces boilerplate
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
const MAC_DRAG_STYLE_ID = "omniroute-electron-drag-region-style";
|
||||
const MAC_DRAG_FALLBACK_ID = "omniroute-electron-drag-region";
|
||||
const MAC_DRAG_OBSERVER_KEY = "__omnirouteMacDragRegionObserver";
|
||||
|
||||
function installMacDragRegion() {
|
||||
if (process.platform !== "darwin") return;
|
||||
|
||||
const attach = () => {
|
||||
if (!document.head || !document.body) return;
|
||||
|
||||
document.getElementById(MAC_DRAG_STYLE_ID)?.remove();
|
||||
document.getElementById(MAC_DRAG_FALLBACK_ID)?.remove();
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = MAC_DRAG_STYLE_ID;
|
||||
style.textContent = `
|
||||
header,
|
||||
.omniroute-electron-drag-region {
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
header a,
|
||||
header button,
|
||||
header input,
|
||||
header select,
|
||||
header textarea,
|
||||
header [role="button"],
|
||||
header [role="link"],
|
||||
header [tabindex]:not([tabindex="-1"]) {
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.omniroute-electron-drag-region {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 96px;
|
||||
right: 180px;
|
||||
height: 46px;
|
||||
z-index: 9999;
|
||||
}
|
||||
`;
|
||||
|
||||
const dragRegion = document.createElement("div");
|
||||
dragRegion.id = MAC_DRAG_FALLBACK_ID;
|
||||
dragRegion.className = "omniroute-electron-drag-region";
|
||||
dragRegion.setAttribute("aria-hidden", "true");
|
||||
|
||||
document.head.appendChild(style);
|
||||
document.body.appendChild(dragRegion);
|
||||
|
||||
const syncDragFallback = () => {
|
||||
const hasHeader = Boolean(document.querySelector("header"));
|
||||
dragRegion.hidden = hasHeader;
|
||||
if (hasHeader) observer.disconnect();
|
||||
};
|
||||
const previousObserver = window[MAC_DRAG_OBSERVER_KEY];
|
||||
if (previousObserver) previousObserver.disconnect();
|
||||
|
||||
const observer = new MutationObserver(syncDragFallback);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
window[MAC_DRAG_OBSERVER_KEY] = observer;
|
||||
window.setTimeout(() => observer.disconnect(), 5000);
|
||||
window.addEventListener("pagehide", () => observer.disconnect(), { once: true });
|
||||
syncDragFallback();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
window.addEventListener("DOMContentLoaded", attach, { once: true });
|
||||
} else {
|
||||
attach();
|
||||
}
|
||||
}
|
||||
|
||||
installMacDragRegion();
|
||||
|
||||
// ── Channel Whitelist ──────────────────────────────────────
|
||||
const VALID_CHANNELS = {
|
||||
invoke: [
|
||||
"get-app-info",
|
||||
"open-external",
|
||||
"get-data-dir",
|
||||
"restart-server",
|
||||
"check-for-updates",
|
||||
"download-update",
|
||||
"install-update",
|
||||
"get-app-version",
|
||||
"get-autostart-status",
|
||||
"enable-autostart",
|
||||
"disable-autostart",
|
||||
"login:start",
|
||||
"login:cancel",
|
||||
"login:status",
|
||||
],
|
||||
send: ["window-minimize", "window-maximize", "window-close"],
|
||||
receive: ["server-status", "port-changed", "update-status", "login:status"],
|
||||
};
|
||||
|
||||
// ── Fix #16: Generic IPC wrappers ──────────────────────────
|
||||
function safeInvoke(channel, ...args) {
|
||||
if (!VALID_CHANNELS.invoke.includes(channel)) {
|
||||
return Promise.reject(new Error(`Blocked IPC invoke: ${channel}`));
|
||||
}
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
|
||||
function safeSend(channel, ...args) {
|
||||
if (VALID_CHANNELS.send.includes(channel)) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #6: Return disposer function for proper listener cleanup
|
||||
function safeOn(channel, callback) {
|
||||
if (!VALID_CHANNELS.receive.includes(channel)) return () => {};
|
||||
const handler = (_event, data) => callback(data);
|
||||
ipcRenderer.on(channel, handler);
|
||||
// Return a disposer — caller removes only THIS specific listener
|
||||
return () => ipcRenderer.removeListener(channel, handler);
|
||||
}
|
||||
|
||||
// ── Expose API to Renderer ─────────────────────────────────
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// ── Invoke (async, returns Promise) ──────────────────────
|
||||
getAppInfo: () => safeInvoke("get-app-info"),
|
||||
openExternal: (url) => safeInvoke("open-external", url),
|
||||
getDataDir: () => safeInvoke("get-data-dir"),
|
||||
restartServer: () => safeInvoke("restart-server"),
|
||||
getAppVersion: () => safeInvoke("get-app-version"),
|
||||
|
||||
// ── Auto-Update ──────────────────────────────────────────
|
||||
checkForUpdates: () => safeInvoke("check-for-updates"),
|
||||
downloadUpdate: () => safeInvoke("download-update"),
|
||||
installUpdate: () => safeInvoke("install-update"),
|
||||
|
||||
// ── Autostart ────────────────────────────────────────────
|
||||
getAutostartStatus: () => safeInvoke("get-autostart-status"),
|
||||
enableAutostart: () => safeInvoke("enable-autostart"),
|
||||
disableAutostart: () => safeInvoke("disable-autostart"),
|
||||
|
||||
// ── Send (fire-and-forget) ───────────────────────────────
|
||||
minimizeWindow: () => safeSend("window-minimize"),
|
||||
maximizeWindow: () => safeSend("window-maximize"),
|
||||
closeWindow: () => safeSend("window-close"),
|
||||
|
||||
// ── Receive (event listeners) ────────────────────────────
|
||||
// Fix #6: Returns a disposer function for precise cleanup
|
||||
onServerStatus: (callback) => safeOn("server-status", callback),
|
||||
onPortChanged: (callback) => safeOn("port-changed", callback),
|
||||
onUpdateStatus: (callback) => safeOn("update-status", callback),
|
||||
|
||||
// ── Web-Cookie Login ──────────────────────────────────────
|
||||
startLogin: (providerId, options) => safeInvoke("login:start", providerId, options),
|
||||
cancelLogin: () => safeInvoke("login:cancel"),
|
||||
getLoginStatus: () => safeInvoke("login:status"),
|
||||
onLoginStatus: (callback) => safeOn("login:status", callback),
|
||||
|
||||
// ── Static Properties ────────────────────────────────────
|
||||
isElectron: true,
|
||||
platform: process.platform,
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
|
||||
// Cross-platform "kill the whole process tree" helper (#3347).
|
||||
//
|
||||
// The embedded server is spawned via process.execPath (= omniroute.exe) with
|
||||
// ELECTRON_RUN_AS_NODE=1, and it in turn spawns grandchildren (embedded services,
|
||||
// MITM proxy, tunnels — several also omniroute.exe-as-node). On Windows, Node's
|
||||
// ChildProcess.kill()/SIGTERM/SIGKILL only terminate the DIRECT child via
|
||||
// TerminateProcess — they do NOT walk the tree. Surviving grandchildren keep a lock
|
||||
// on omniroute.exe, so the process "hangs in memory" after Exit and updates fail with
|
||||
// "file in use". Windows needs `taskkill /PID <pid> /T /F` (the /T flag terminates the
|
||||
// process AND its descendants). POSIX keeps signal-based termination, which propagates.
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
/**
|
||||
* Terminate a child process and all of its descendants.
|
||||
* @param {{ pid?: number, kill?: (signal?: string) => void } | null | undefined} proc
|
||||
* @param {{ platform?: string, signal?: string, spawnFn?: typeof spawn }} [options]
|
||||
*/
|
||||
function killProcessTree(proc, options = {}) {
|
||||
if (!proc || proc.pid == null) return;
|
||||
const platform = options.platform || process.platform;
|
||||
const signal = options.signal || "SIGTERM";
|
||||
|
||||
if (platform === "win32") {
|
||||
const spawnFn = options.spawnFn || spawn;
|
||||
try {
|
||||
// Array args + no shell → the pid (an integer we own) is never interpolated into a
|
||||
// shell command string (Hard Rule #13). /T walks the tree, /F forces termination.
|
||||
const killer = spawnFn("taskkill", ["/PID", String(proc.pid), "/T", "/F"], {
|
||||
windowsHide: true,
|
||||
});
|
||||
if (killer && typeof killer.on === "function") {
|
||||
killer.on("error", () => {
|
||||
try {
|
||||
proc.kill(signal);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// taskkill unavailable (rare) — fall back to the direct kill.
|
||||
try {
|
||||
proc.kill(signal);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// POSIX: signals propagate to the process group of a normally-spawned child.
|
||||
try {
|
||||
proc.kill(signal);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { killProcessTree };
|
||||
@@ -0,0 +1,67 @@
|
||||
const fs = require("fs");
|
||||
|
||||
function formatLoadError(error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function openBetterSqliteReadOnly(dbPath) {
|
||||
const Database = require("better-sqlite3");
|
||||
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
}
|
||||
|
||||
function openNodeSqliteReadOnly(dbPath) {
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
return new DatabaseSync(dbPath, { readOnly: true });
|
||||
}
|
||||
|
||||
function openReadOnlySqliteDatabase(dbPath) {
|
||||
const errors = [];
|
||||
|
||||
try {
|
||||
return openBetterSqliteReadOnly(dbPath);
|
||||
} catch (error) {
|
||||
errors.push(`better-sqlite3: ${formatLoadError(error)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return openNodeSqliteReadOnly(dbPath);
|
||||
} catch (error) {
|
||||
errors.push(`node:sqlite: ${formatLoadError(error)}`);
|
||||
}
|
||||
|
||||
throw new Error(errors.join("; "));
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dbPath, openDatabase = openReadOnlySqliteDatabase) {
|
||||
if (!fs.existsSync(dbPath)) return false;
|
||||
|
||||
let db = null;
|
||||
try {
|
||||
db = openDatabase(dbPath);
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM provider_connections
|
||||
WHERE access_token LIKE 'enc:v1:%'
|
||||
OR refresh_token LIKE 'enc:v1:%'
|
||||
OR api_key LIKE 'enc:v1:%'
|
||||
OR id_token LIKE 'enc:v1:%'
|
||||
LIMIT 1`
|
||||
)
|
||||
.get();
|
||||
return !!row;
|
||||
} catch (error) {
|
||||
const message = formatLoadError(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
} finally {
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasEncryptedCredentials,
|
||||
openNodeSqliteReadOnly,
|
||||
openReadOnlySqliteDatabase,
|
||||
};
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* OmniRoute Electron Types
|
||||
*
|
||||
* TypeScript definitions for the Electron API exposed to the renderer process.
|
||||
*
|
||||
* Updated to reflect:
|
||||
* - Fix #6: onServerStatus/onPortChanged return disposer functions
|
||||
* - Removed removeServerStatusListener/removePortChangedListener (replaced by disposers)
|
||||
*/
|
||||
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
isDev: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
status: "starting" | "running" | "stopped" | "restarting" | "error";
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
// ── Invoke (async) ─────────────────────────────────────
|
||||
getAppInfo(): Promise<AppInfo>;
|
||||
openExternal(url: string): Promise<void>;
|
||||
getDataDir(): Promise<string>;
|
||||
restartServer(): Promise<{ success: boolean }>;
|
||||
|
||||
// ── Send (fire-and-forget) ─────────────────────────────
|
||||
minimizeWindow(): void;
|
||||
maximizeWindow(): void;
|
||||
closeWindow(): void;
|
||||
|
||||
// ── Receive (returns disposer for cleanup) ─────────────
|
||||
onServerStatus(callback: (data: ServerStatus) => void): () => void;
|
||||
onPortChanged(callback: (port: number) => void): () => void;
|
||||
|
||||
// ── Static Properties ──────────────────────────────────
|
||||
isElectron: boolean;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user