Build and package#

Layout#

ohm/
├─ apps/
│  ├─ desktop/     Electron main + preload (the composition root)
│  └─ ui/          React renderer — engine-agnostic, talks only to a HostBridge
├─ packages/
│  ├─ engine-api/       the interfaces a browser engine must implement
│  ├─ engine-electron/  the only code allowed to import electron
│  ├─ protocol/         typed UI ↔ core JSON-RPC contracts
│  ├─ core/             tabs, scripts, projects, models, AI
│  ├─ ohm-lang/         splitter, LaTeX lexer/parser, Lua codegen
│  ├─ ohm-runtime/      wasmoon VM, async scheduler, worker runner
│  ├─ curves/           curves, expressions, typed sweep variables
│  ├─ graph/            Rete components, profiles, graph→Ohm compiler
│  └─ docview/          slide and spreadsheet parsing for the viewers
├─ installers/    Windows and macOS installers (own workspace)
├─ docs/          this documentation, and docs/specs
└─ scripts/       dev loop, builds, checks

Commands#

Command What it does
npm run dev Vite dev server for the UI + esbuild watch for the main process; Electron restarts on change
npm run build Production build: apps/ui/dist and apps/desktop/dist
npm start npm run build, then launch Electron
npm run check check:engine + check:specs + check:docs + typecheck + test — run this before committing
npm run specs Regenerate docs/specs/*.spec.{json,md} from docs/specs/src
npm run docs Compile docs/**.md into the in-app help site at apps/ui/public/help
npm run shots Recapture the documentation screenshots from a real running app
npm run docs:serve Serve the built site on http://127.0.0.1:4173 to read it as a visitor
npm test Vitest

The engine boundary#

Ohm is written so the engine can be replaced — Electron today, a Chromium fork later. packages/engine-api defines the interfaces; packages/engine-electron is the only package permitted to import 'electron', and scripts/check-engine-imports.mjs fails the build if anything else does. npm run check runs it first, so the rule is enforced rather than merely documented.

Practically: if you need a new capability from the engine (a dialog, a scheme, a way to capture a page), add it to engine-api as an interface, implement it in engine-electron, and let core depend only on the interface.

The production build#

Three steps, all in npm run build:

  1. Help — scripts/build-operators.mjs regenerates the operator reference from the registry, then scripts/build-docs.mjs renders every page under docs/ to HTML in apps/ui/public/help, with a contents sidebar, a search index and the screenshots from docs/images. Vite copies public/ verbatim, so the Help tab is part of the bundle and a shipped build always carries the documentation that matches it.

    The output is a plain static site: index.html is a landing page generated from what the documentation actually contains — the guide and reference counts, the number of operators and specification nodes are all read at build time rather than written down — and docs.html is the index of everything. It can be published as-is to any static host; npm run docs:serve shows it the way a visitor sees it, which matters because file:// blocks the fetch the search box makes.

  2. UI — Vite builds apps/ui into apps/ui/dist and injects the production Content-Security-Policy meta tag. The dev server does not use that CSP, because React refresh needs inline scripts; if something works in npm run dev but not in npm start, suspect the CSP first.

  3. Desktop — esbuild bundles three CommonJS entries into apps/desktop/dist: main.js, preload.js and ohm-worker.js.

Four packages stay external to the bundle: electron, wasmoon, node-llama-cpp and @node-llama-cpp/*. wasmoon loads its .wasm next to its own module, and node-llama-cpp picks a native binary package at runtime, so both have to exist as real files under node_modules.

Installers#

Installers live in installers/, outside the app's workspaces, so electron-builder and its dependencies never enter the app's dependency tree.

cd installers
npm install
npm run assets                # render the branding once
npm run build:win             # on Windows → installers/windows/dist
npm run build:mac             # on macOS   → installers/macos/dist
npm run build:mac -- --validate   # anywhere: check config and staging without building

Two things are worth repeating here because they surprise people:

  • The package is not asar'd. The app serves its UI with net.fetch(file://…) and runs the interpreter in a worker thread from a file path; neither can read inside an asar archive.
  • Only the CPU llama.cpp binary ships by default. The Vulkan and CUDA builds add ~600 MB. Pass --backends cpu,vulkan,cuda when you want them.

See the packaging spec for the full contract, including the staging layout, the branding pipeline and the install/uninstall rules.

Testing an installed build#

The app is drivable over CDP, which makes an end-to-end check cheap:

Ohm.exe --remote-debugging-port=9333

Then attach to the ohm-app://ui/index.html target and run print("ok", $\sqrt{144}$) in the console. If it prints 12, the compiler, the worker thread and wasmoon are all working from the packaged layout.

One caveat when driving a background window: Chromium throttles requestAnimationFrame when the window is not visible, and pdf.js renders through it, so a PDF render promise can stall until a frame is forced. Take a screenshot (or front the window) before measuring anything in a document viewer.