* fix(release): let the Electron workflow start again — grant actions:read to the npm leg (#11973) v3.8.50 shipped with zero desktop assets. The tag push did trigger electron-release.yml (run 33005490476) but GitHub refused the run at startup: Error calling workflow 'npm-publish.yml@5458026'. The nested job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'. npm-publish.yml's `publish` job gained `actions: read` (it downloads the next-build artefact) and the caller job here never widened its grant — a reusable workflow may not request more than its caller allows, and the refusal is a startup failure of the WHOLE run, so the `release` job that attaches the installers, the source archives and the SBOM never ran either. Nothing about it is visible through the API (no jobs, no check-runs); only the run page shows the annotation. - publish-npm: `actions: read` added, with the rule written down (keep the block a superset of every job in npm-publish.yml). - workflow_dispatch: new boolean input `publish_npm` (default true) and the npm leg is gated on it, so re-attaching assets to a release whose package already shipped does not try to publish the same version twice. - web-build / build / release checkouts pin `ref: needs.validate.outputs.version`: a dispatch builds the tag it names, not the dispatching branch (a tag push resolves to the same commit, so nothing changes on the normal path). actionlint clean; electron-release-desktop-channel-8949, electron-release-efficiency, build-next-isolated-windows-home-2402, electron-release-latest-yml.repro and check-workflows suites pass. Next step: dispatch on main with version=v3.8.50 and publish_npm=false to attach the missing assets. * fix(ci): stop a stalled Codecov upload from cancelling the Coverage job and the main run (main twin of #11972) (#11978) Same change as #11972 on release/v3.8.51: the Coverage job had timeout-minutes: 20, the c8 merge across 8 shards takes ~10 min and the informational Codecov upload hung for the rest of the budget on two consecutive main runs (33207760653, 33215115341), ending the job cancelled and turning the run's conclusion cancelled with every blocking job green. Codecov step: 5-minute ceiling + continue-on-error; job: 30 min. * fix(release): resync the electron lockfile and let a dispatch build from a repaired ref (#11982) * fix(release): resync the electron lockfile and let a dispatch build from a repaired ref The v3.8.50 desktop re-dispatch (run 33238093090) lost its Linux leg at `npm ci` in electron/: "Missing: electron-builder-squirrel-windows@26.15.3 from lock file" plus its 12 transitive entries — the optional Windows-installer subtree of electron-builder had been dropped when the lock was last regenerated, and no CI ran the desktop legs between then and the tag (v3.8.49 never ran them; v3.8.50 died at startup, #11973). `npm install --package-lock-only` restores the 13 entries; a clean `npm ci --ignore-scripts` on the result adds 284 packages with no complaint. The tag itself carries the broken lock, and the workflow now checks out the tag on dispatch (#11973), so a dispatch input `build_ref` (default: the version tag) lets the operator name the repaired line — the v3.8.50 assets will be rebuilt from main, which is 3.8.50 plus its post-release fixes. Push-triggered runs are unaffected. actionlint clean; electron-release-desktop-channel-8949, electron-release-efficiency, electron-release-latest-yml.repro and check-workflows suites pass. * fix(release): do not regenerate release notes on a re-attach dispatch `generate_release_notes: true` on an existing release APPENDS GitHub's auto-generated "What's Changed" block to the curated body — the v3.8.50 re-dispatch (run 33238093090) added 1,416 chars to the 121 KB notes. Only the tag push should generate notes. * fix(release): attach the SBOM to the GitHub Release on dispatch publishes too (#12020) The step was gated on github.event_name == 'release'. v3.8.50's package shipped through a workflow_dispatch (the staged publish, 11 attempts) and the step was skipped, so the GitHub Release carried no SBOM — it was attached by hand from the run's sbom-npm artifact (5.0 MB, 1,886 components). Now it attaches on release or workflow_dispatch whenever a release for the published tag exists, and says so when it does not (the workflow artifact remains the durable copy either way). actionlint and prettier clean; npm-publish-artifact-provenance and check-workflows-provenance-runner suites pass. * fix(release): drop the build_ref input — a dispatch builds the ref it is dispatched on (#12032) Twin of #12022 on main: CodeQL flagged the same input-controlled checkout + npm cache pattern (cache-poisoning/poisonable-step) on main since it's the default branch. Checkouts go back to github.ref; dispatch still works via --ref (documented in the workflow's own on: contract). Also fixes the packaged-app smoke: it now waits on /api/monitoring/health (which touches the DB) instead of /login (which doesn't), so the smoke can actually distinguish "native driver selected" from "database never opened." electron-smoke-script.test.ts 9/9 (2 new cases). * fix(ci): accept CVE-2025-68121 in the prebuilt tls-client .so, auto-close base-red issues, guard Scorecard on the default branch (main twin) (#12086) * fix(ci): accept CVE-2025-68121 in the prebuilt tls-client .so, auto-close base-red issues, guard Scorecard on the default branch - .trivyignore: CVE-2025-68121 (Go stdlib crypto/tls inside bogdanfinn/tls-client v1.15.1, built with go 1.24.1) with justification, expiry and tracker #12084. No upstream rebuild exists; the blocking Trivy gate now also names the ignore file explicitly. - nightly-release-green: close the "not green" issue when the validation passes again (the workflow only ever opened/commented it, so stale issues outlived the fix and stamped new PRs as base-red inherited). - scorecard: the action only accepts the DEFAULT branch (the active release branch, not main) - guard the job on it so pushes to main stop failing. Refs #12084 (cherry picked from commit8adf34bada) * fix(release): never let the tag-push Create Release append auto notes to the curated body Twin of the release/v3.8.51 commit (see #12085). Refs #12084 * fix(docker): bump Bun image to 1.4.0 with Turbopack and port the node image's build memory guards (#11719) Validated in an isolated worktree against main: typecheck:core clean, 15/15 focused tests pass (docker-build-memory-budget, bun-support, resolve-next-build-bundler-flag). Root cause confirmed against the current workflow config (docker-publish.yml triggers on push to both main and release/v*, so this genuinely needed to target main). One out-of-scope change dropped before merging: config/alibaba-free-tier-allowlist.json's validUntil bump (2026-08-27 -> 2027-12-31) was unrelated to the Docker/Bun fix — reverted to the current value, keeping only the Docker/Bun/memory-guard changes this PR is actually about. Thanks for the thorough root-cause writeup and the worker-pool math. * test(infra): retry recursive temp-dir removal on main (main twin of #11968) (#12246) * test(infra): retry recursive temp-dir removal on main (main twin of #11968) `main` has been red sinceb342c1a361on the vitest and integration gates: ✖ tests/unit/autoCombo/provider-family-combos.test.ts > auto/<family> ✖ chat pipeline applies Codex OAuth fingerprint and priority tier inside combos Both call resetStorage() from beforeEach, which does an fs.rmSync(TEST_DATA_DIR, {recursive: true, force: true}) with no retry, and intermittently loses the race with a not-yet-released SQLite handle (ENOTEMPTY). release/v3.8.51 fixed this in #11968 with a mechanical codemod adding maxRetries/retryDelay to every recursive rm/rmSync/rmdirSync under tests/, but that PR landed only on the release branch. Because main only receives work at the release squash, it stayed broken for the whole cycle — and repo-wide gates then turn every open PR into main red on checks unrelated to their diff. This is the --base main twin: re-runs the same codemod that already shipped on the release branch (scripts/ad-hoc/codemod-rm-maxretries.mjs), so the two branches converge on identical test-teardown semantics. Test-only; no product logic is touched. The remaining three failures reported on #12133 (unit full suite exceeding its 4800s ceiling, package-artifact exceeding 1200s, and the boot-smoke that is skipped as a consequence) are runner-contention timeouts, not code defects — validate-release-green.mjs runs those heavy gates concurrently on one shared hosted runner. There is no fix to port for those. * chore(scripts): carry the rm-maxretries codemod onto main alongside its output The codemod that generated the previous commit lives in the repo on release/v3.8.51 (added by #11968) but was never on main. Bringing it over keeps the tool next to the change it produced, so the transformation stays reproducible and auditable from either branch. * fix(ci): port the release-green ESLint gate fix to main (base-red #12363) (#12618) Porta para `main` o fix do gate de ESLint que só havia entrado na branch de release — o padrão de PR-companheiro que `_shared/merge-gates.md` §8 prescreve. As 12 falhas de CI foram discriminadas como o **outro** base-red do main, não deste diff. Todas descendem de um único ponto: `Package Artifact` falha e os 9 shards de E2E mais os 2 Electron Package Smoke consomem esse artefato. A própria issue #12363 lista os dois separadamente: - `❌ ESLint: could not parse eslint json` — que é justamente o que este PR conserta; - `❌ Package artifact (npm pack policy): gate exceeded its 1200s ceiling` — a raiz da cascata. O PR toca apenas `scripts/quality/validate-release-green.mjs` e seu teste, então não tem caminho para afetar o build do pacote. Teste portado primeiro e falhando no script atual do main (TDD). * fix(authz): preserve zed-hosted native-app callback through root middleware redirect The root middleware intercepts `pathname === "/"` and redirects to `/dashboard` using `new URL(basePath+"/dashboard", url)`, which drops the query string entirely. Zed's native-app sign-in always redirects the browser to the loopback root — `http://127.0.0.1:<port>/?user_id=...&access_token=...` — ignoring any path. When the dashboard's own loopback port is reused as `native_app_port` (see `src/lib/oauth/providers/zed-hosted.ts`'s `resolveDashboardLoopbackPort`), that redirect lands on `/` of the running OmniRoute instance. The root page (`src/app/page.tsx`) was already written to forward `user_id`+`access_token` to `/callback`, but this middleware runs first and silently discards the payload — making page.tsx's forward dead code and breaking the entire zed-hosted sign-in flow. Fix: detect `user_id` + `access_token` in `searchParams` and, when present, redirect to `/callback${search}` (preserving the query string) instead of `/dashboard`. Regular root visits (no native callback params) continue to redirect to `/dashboard` unchanged. This approach mirrors what `src/app/page.tsx` already does and is provider-agnostic: any future provider whose native loopback callback lands on `/` with `user_id`+`access_token` params benefits automatically. * test(authz): cover zed-hosted native-app callback root redirect (#13140) Adds automated coverage for the new pathname === "/" branch: with user_id+access_token both present the redirect now forwards to /callback preserving the query string; with only one of the two present, behavior is unchanged (redirect to /dashboard). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: Rouzbeh† <78313022+rqzbeh@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
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
- Build the Next.js app first:
npm run build
- Install Electron dependencies:
cd electron
npm install
Running in Development
- Start the Next.js development server:
npm run dev
- In another terminal, start Electron:
cd electron
npm run dev
Running in Production Mode
- Build Next.js in standalone mode:
npm run build
- Start Electron:
cd electron
npm start
Building
Build for Current Platform
cd electron
npm run build
Build for Specific Platforms
# 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:
.exeinstaller (NSIS) + portable.exe - macOS:
.dmginstaller (Intel + Apple Silicon) - Linux:
.AppImage
Installation
macOS
- Download the latest
.dmgfrom the Releases page. - Open the
.dmgfile. - Drag
OmniRoute.appto the Applications folder. - Launch from Applications.
⚠️ Note: The app is not signed with an Apple Developer certificate yet. If macOS blocks the app, run:
xattr -cr /Applications/OmniRoute.appOr right-click the app → Open → Open (to bypass Gatekeeper on first launch).
Windows
Installer (Recommended):
- Download
OmniRoute.Setup.*.exefrom Releases. - Run the installer.
- Launch from Start Menu or Desktop shortcut.
Portable (No Installation):
- Download
OmniRoute.exefrom Releases. - Run directly from any folder.
Linux
- Download the
.AppImagefrom Releases. - Make it executable:
chmod +x OmniRoute-*.AppImage - Run:
./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)
- Remote Server Mode — Point the shell at an already-running OmniRoute server (e.g. a Docker/OrbStack container, or another machine) instead of spawning a local one — see below
- Window Controls — Custom minimize, maximize, close via IPC
- Content Security Policy — Restrictive CSP via session headers
- Offline Support — Bundled Next.js standalone server
- Single Instance — Only one app instance can run at a time
Remote Server Mode
By default the desktop shell spawns and manages its own bundled Next.js server. If you already run OmniRoute elsewhere — most commonly in a Docker/OrbStack container, so provider credentials and env-var handling stay isolated from the host — you can point the shell at that instance instead, so it's purely a native window + tray onto a server you already run.
Via the tray menu: Remote Server → Connect to Remote Server…, enter the server's
URL (e.g. http://localhost:20128), and save. Leave the field blank and save to
disconnect and go back to the local embedded server. The preference persists across
restarts in <data dir>/electron-preferences.json (see DATA_DIR above for where that
lives on your platform).
Via environment variable: set OMNIROUTE_REMOTE_URL before launching the app (e.g.
OMNIROUTE_REMOTE_URL=http://localhost:20128 npm run dev, or export it in the
environment that launches the packaged app). The env var always wins over the persisted
preference and is session-scoped — it doesn't get written to the prefs file.
Only http:// and https:// URLs are accepted; anything else is rejected before the
window loads.
Configuration
Environment Variables
| Variable | Default | Description |
|---|---|---|
OMNIROUTE_PORT |
20128 |
Server port |
OMNIROUTE_MEMORY_MB |
512 |
Node.js heap limit (64–16384 MB) |
OMNIROUTE_REMOTE_URL |
(unset) | Attach to this server instead of spawning a local one — see Remote Server Mode |
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 bundleicon.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, 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)
| 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
useServerStatusandusePortChangedhooks.
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
- Check if port 20128 is available:
lsof -i :20128 - Check console logs for
[Electron]prefix - Verify the build output exists in
.build/next/standalone
White Screen
- Verify Next.js build exists — server readiness waits 30s max
- Check
[Server]and[Server:err]log output - 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