chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+250
View File
@@ -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 (6416384 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

+26
View File
@@ -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 };
+386
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3373
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -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"
}
]
}
}
}
+176
View File
@@ -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,
});
+62
View File
@@ -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 };
+67
View File
@@ -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,
};
+51
View File
@@ -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 {};