Architecture#
The shape#
┌──────────────────────────── Electron main ────────────────────────────┐
│ apps/desktop composition root: picks the engine, wires core │
│ │
│ packages/engine-electron the ONLY package that imports electron │
│ │ implements │
│ packages/engine-api BrowserEngine / EngineWindow / EngineTab │
│ ▲ depends on / Automation / SecretStore│
│ packages/core tabs, scripts, projects, models, AI │
│ ├─ ohm-lang splitter → lexer → parser → Lua codegen │
│ ├─ ohm-runtime wasmoon VM in a worker, async scheduler │
│ ├─ curves, graph, docview │
│ └─ protocol typed methods and events │
└───────────────────────────────────────────────────────────────────────┘
▲ ohm:call / ohm:event over a 2-function preload
┌───────────────────────────── Renderer ────────────────────────────────┐
│ apps/ui React + Monaco, served from ohm-app:// │
│ engine-agnostic: it only knows the HostBridge │
└───────────────────────────────────────────────────────────────────────┘
The engine seam#
Ohm is built to outlive Electron. packages/engine-api defines what a browser engine must
provide — windows, tabs, page state, raw CDP automation, file dialogs, a secret store — and
packages/engine-electron is the only package allowed to import 'electron'.
scripts/check-engine-imports.mjs fails the build otherwise, and npm run check runs it
first.
The proof that the seam is real: the tab tests run a complete second implementation of
EngineTab and EngineWindow with no Electron at all.
Automation is deliberately raw CDP rather than an Electron-shaped API, because a Chromium fork speaks CDP natively — so every script that drives a page survives the engine swap.
To replace the engine: implement the engine-api interfaces, and change the one file that
constructs an engine (apps/desktop/src/main.ts).
Where state lives#
Core owns everything. The engine reports (state, openRequest) and obeys (navigate,
attach); the UI renders snapshots and sends commands. Every tab mutation funnels through
one function that emits a complete tabs.changed snapshot — no incremental events, no
client-side merge.
The page view is a real Chromium view layered over the UI, so the UI hides a page by
reporting a zero-sized rectangle rather than by unmounting anything. Bounds flow UI → core →
engine via a ResizeObserver.
Process and thread model#
| Runs in | What |
|---|---|
| Main process | core, tabs, projects, models, embedded llama.cpp |
| Worker thread | the Ohm interpreter (one per session), so a runaway script can be terminated |
| Renderer | the UI, and web models (WebGPU exists only there) |
| Renderer worker | Monaco's workers, transformers.js |
Scripts never run in the renderer, and the renderer never touches the filesystem — it asks core.
Custom schemes#
| Scheme | Serves |
|---|---|
ohm-app:// |
the built UI, so it has a real origin (workers, wasm, code cache) |
ohm-models:// |
local model weights, for transformers.js |
ohm-file:// |
project documents, read-only, for the viewers |
Each handler resolves the request against its root and refuses anything that escapes it.
The production CSP is injected at build time; the dev server runs without one because React
refresh needs inline scripts — so if something works in npm run dev but not npm start,
suspect the CSP first.
The language pipeline#
source.ohm
→ splitter Lua | $inline$ | $$display$$ (string- and comment-aware)
→ lexer LaTeX tokens
→ parser AST
→ codegen Lua, line for line
→ wasmoon Lua 5.4 in a worker thread
Line preservation is the only source map: the generated Lua has the same line numbering as
the .ohm file, so a runtime error names the right line with no extra machinery.
Host calls cross as JSON strings only — no live object proxies — and the Lua side runs a cooperative scheduler over coroutines so asynchronous calls read as straight-line code.
Data flow for a typical action#
Running a script from the editor:
- UI:
host.call('scripts.run', { path }) - preload →
ipcMain.handle('ohm:call')→ authorised bywebContentsid - core: compile (diagnostics stream back as
errlines), start a worker - worker: run the Lua, emitting
outputmessages andhostCallrequests - core: serves host calls (tabs, AI, files, projects) and forwards output as
scripts.outputevents - UI: appends to the console live; a final
scripts.statuscloses the run
Testing#
npm run check runs the engine-boundary check, the spec validator, the typecheck and the
test suite. The tests cover the language (splitter through codegen), the runtime (including
real worker termination), curves and sweeps, graphs, projects, specs, settings and models,
document parsing and the tab state machine — with fakes for the engine, so none of it needs
a running browser.
The app is also drivable from outside over CDP (--remote-debugging-port), which is how the
end-to-end checks in Build and package work.