A desktop web browser built on Electron 44, with per-tab process suspension, network-level ad blocking, and a local-first page assistant.
npm install # downloads Electron's ~100 MB binary
npm run dev # HMR for the chrome UI, auto-reload for main
npm run build # typecheck + bundle to out/
npm run build:win # installer (also :mac and :linux)
npm run verify # storage/completions + tab-suspension suitesnpm install downloads Electron's ~50 MB binary in the postinstall step. On
first clone, Electron's install may be skipped by your npm or CI — the app's
postinstall script detects this and downloads the binary itself.
npm run verify runs two suites. verify:storage exercises the SQLite engine
and URL normalisation on Electron's own Node runtime; verify:freeze measures
the suspension tiers against a live interval in a real WebContentsView — it
is the only way to confirm the freezing mechanism actually works, because a
failure there is silent.
Icons are generated, not hand-drawn: npm run icons renders the Omega mark
(violet ring + dot) into build/icon.png, build/icon.ico and
build/icon.icns with a dependency-free rasteriser in
scripts/generate-icons.cjs. electron-builder picks all three up from
build/ automatically — run it if the brand mark ever changes.
Zero runtime dependencies. No native modules, no node-gyp, no
electron-rebuild — storage uses node:sqlite, which ships inside Electron's
own Node 24 runtime.
Three layers, and the boundary between them is the whole design.
┌─ Chrome UI ──────────────── BrowserWindow webContents ─────────────┐
│ React + Tailwind. Tab strip, toolbar, omnibox, side panels. │
│ Draws only where the page is NOT. Measures its own layout and │
│ reports the page viewport rect over IPC. │
└────────────────────────────────────────────────────────────────────┘
│ IPC (contextBridge, allowlisted)
┌─ Browser process ──────────────────────────────────────────────────┐
│ TabManager WebContentsView lifecycle, z-order, bounds │
│ AdBlocker session.webRequest onBeforeRequest │
│ HistoryStore node:sqlite + FTS5 (frecency-ranked autocomplete) │
│ SuggestionCtrl owns omnibox suggestion state │
│ AiService in-page extraction + streaming completion │
│ OverlayView a second WebContentsView for the omnibox dropdown│
└────────────────────────────────────────────────────────────────────┘
│ native child views
┌─ Web content ──────────────────────────────────────────────────────┐
│ One WebContentsView per tab, own sandboxed renderer, own session │
└────────────────────────────────────────────────────────────────────┘
contentView.addChildView() paints above the window's own webContents. The
page is therefore always on top of the React chrome. Anything that must float
over a page — the autocomplete dropdown — has to be its own native view, which
is what OverlayView is.
The dropdown is display-only: keyboard focus stays in the omnibox input, so arrow keys and Enter are handled by the chrome UI and forwarded to the overlay as a highlight index. This removes an entire class of focus-stealing bugs.
Side panels take the other route: the renderer reports a narrower viewport and the page view is resized, so the panel sits in real space rather than on top. Full-content panels (history, settings) hide the page view entirely.
In production the built chrome UI is served over the internal omega://
protocol rather than loaded from disk. This matters for two reasons:
file://is an opaque origin. Under a custom scheme the renderer has a real origin to scope things to, and relative asset URLs resolve normally.- A
file://load produces no response headers, so the only CSP available there is a<meta>tag. Serving it ourselves means the CSP arrives as a real header — which is also what Chromium's own security check looks at, so the "no Content Security Policy set" warning goes away instead of being papered over.
One consequence worth knowing: protocol.handle registers on the default
session only. The new tab page loads inside a tab view on the
persist:omega-web partition, so the same handler is attached to that session
too — an unregistered scheme there fails the navigation outright.
| Concern | Where it runs |
|---|---|
| Tab chrome, omnibox, panels | chrome renderer (defaultSession) |
| Suggestion list | overlay renderer (same session, second entry) |
| Page content | one sandboxed renderer per tab (persist:omega-web) |
| New tab page, chrome assets | omega:// (handler attached to both sessions) |
| History, blocking, AI calls | browser process |
contextIsolation: true, sandbox: true, nodeIntegration: false, and a
strict CSP on the chrome. The preload exposes two generic methods —
invoke/on — gated by an allowlist derived from the channel arrays in
src/shared/ipc.ts. A channel that isn't declared there is unreachable from
every renderer, including the overlay, which gets a smaller allowlist still.
There is no sendSync anywhere.
Hidden tabs move through tiers, and each one was measured on a 50 ms interval
inside a real WebContentsView (Electron 44 / Chromium 152):
| Tier | Trigger | Timers | Renderer | GPU surface |
|---|---|---|---|---|
| live | active | 19 / sec | alive | allocated |
| throttled | hidden | 1 / sec | alive | allocated |
| frozen | hidden 2 min | 0 | alive, DOM intact | released |
| discarded | hidden 30 min | — | destroyed | released |
npm run verify:freeze # prints the tick counts for each tierFreezing uses Page.setWebLifecycleState over the DevTools protocol. Two
things worth knowing, both found by testing rather than reading:
- Dispatching a synthetic
visibilitychangeevent does not stop timers. It only fools libraries that listen for it. The lifecycle state is the real mechanism, and it is only reachable over CDP. - A visible page cannot be frozen. Chromium rejects the state transition
and
sendCommandresolves anyway, so it fails silently.TabManagerhides the view on deactivate and only schedules the freeze after that.
Chromium's own background throttling already does most of the CPU reduction (19/sec → 1/sec). Freezing is what takes it to exactly zero and releases the GPU surface; discarding is what returns the RAM.
renderer-process-limit=8— beyond ~8 renderers, tabs share processes instead of each costing a sandboxed child.spellcheck: falseper tab — the hunspell service costs 15–40 MB per renderer and most pages don't need it.electronLanguages: [en-US]— drops ~45 MB of Chromium locale packs.- IPC is coalesced. One page load fires a dozen
did-*events;TabManagerbatches tab updates into one flush per frame. - All switches are consolidated in
src/main/app-flags.ts. CallingappendSwitchtwice with the same key silently discards the first value, so there is exactly one append per key.
A dev-only overlay (bottom right) shows live process counts and memory.
The renderer measures its own DOM and reports the rect the page should occupy.
The main process never computes the chrome height, because any disagreement
between the two shows up as a visible overlap or gap. A ResizeObserver on the
viewport element covers window resize, sidebar toggle, and the find bar
appearing, with no special cases.
- Tab session and UI session are separate partitions, so blocking and CSP apply to web content and never to the chrome.
- Permissions are deny by default with a small allowlist. Electron's defaults grant several of these silently.
- First-party requests are never blocked, even if a domain appears in a rule list. This single rule prevents most "the blocker broke my site" reports.
<webview>is rejected globally; popups become tabs.- The AI assistant receives page text only when asked, and an OpenAI key stays in the browser process — it never enters a renderer.
src/
shared/ ipc.ts (the contract), url.ts, constants.ts — no Node, no DOM
preload/ index.ts — the allowlisted contextBridge
main/ index.ts, tab-manager.ts, tab-freezer.ts, overlay-view.ts,
sessions.ts, ad-blocker.ts, history-store.ts, suggestions.ts,
ai.ts, perf.ts, menu.ts, context-menu.ts, newtab-page.ts
renderer/ index.html + overlay.html (two entries), App.tsx, store.ts,
components/
- Node 20+ and npm (or your preferred package manager)
- Electron is downloaded automatically on first
npm install(~100 MB). On slower connections this can be the longest part of setup.
npm install # downloads Electron's ~100 MB binary
npm run dev # HMR for the chrome UI, auto-reload for main processThen press F12 (or use the menu) to open DevTools on the active tab if you need to debug a page.
| Task | How |
|---|---|
| Add a new tab | Click the + button or press ⌘T / Ctrl+T |
| Open settings | Click the gear icon or press ⌘, / Ctrl+, |
| Toggle dark/light theme | Open Settings → Appearance |
| Enable ad blocking | Settings → Ad & tracker blocker |
| Configure AI assistant | Settings → Assistant provider (Ollama is local and needs no key) |
npm run build:win, :mac or :linux produces a platform installer via electron-builder, configured in electron-builder.yml.
- Windows: produces
omega-<version>-setup.exe(NSIS). The MSI target is available too. - macOS: produces a DMG plus a signed zip target, and assumes a code
signing identity with hardened runtime. The
--pubflag enables GitHub release upload once upload tokens are configured. - Linux: produces an AppImage and a
.deb.
npm run build && npx electron-builder --win --publish neverThe electron-builder.yml already references an Apple entitlements plist in
build/entitlements.mac.plist. For a real signed release, code signing material
(CSC_LINK, CSC_KEY_PASSWORD on Windows; signing identity on macOS) is
configured outside the repo.
Before publishing a release:
- Update version — edit
versioninpackage.json - Build —
npm run build(runs typecheck + bundle) - Test —
npm run verify(storage + freeze tests) - Package —
npm run build:win(or:mac/:linux) - Upload — the installer appears in
dist/
For GitHub Releases, configure a GitHub token and use electron-builder --publish always.
- The chrome UI is React + Tailwind CSS v4. Edit files in
src/renderer/. - The main process is plain TypeScript. Edit files in
src/main/. - IPC channels are defined in
src/shared/ipc.ts— the preload allowlist is derived from these. - Tab suspension logic is in
src/main/tab-manager.tsandsrc/main/tab-freezer.ts. - The new tab page is
src/main/newtab-page.ts(served overomega://).
test/ contains two verification suites:
test/storage.ts— exercises URL normalisation, SQLite backend, FTS-based autocomplete, and session round trips on Electron's own Node runtime.test/freeze-probe.cjs— measures each suspension tier against a 50 ms interval in a realWebContentsViewand prints the tick counts. The only evidence that freezing works, because a failure there is silent.
Honest list of what this does not do yet:
- Downloads have no UI. Cancelled and oversized downloads are unhandled.
- Bookmarks don't exist. History is the only persistence.
- Ad-block rules cover host rules (
||domain^) and exceptions only. Cosmetic rules (##.banner) and path/regex patterns need a different matcher. - Only the first tab's session is restored, by URL. Scroll position and form state are not preserved, and discarded tabs come back at their current URL.
- No auto-update.
electron-builder.ymlproduces a zip target for it on macOS, but no update feed is configured. - macOS
open-url/ WindowsregisterProtocolHandleraren't wired up; only command-line URLs are.