title: "Packaging and installers" description: "How Ohm is built, staged and turned into a branded Windows installer and a macOS disk image." nodes: 43 progress: 91% generated: ohm spec editor#

Packaging and installers#

How Ohm is built, staged and turned into a branded Windows installer and a macOS disk image.

Contents#

☑ Overview#

☑ Scope#

note

Everything needed to hand a user an installable Ohm: the app build, the staged layout that ships, the per-platform installers and their branding.

Source: installers/ (deliberately outside the app's npm workspaces so packaging tools never enter the app's dependency tree), scripts/build-desktop.mjs.

  • spec: 01-engine-and-shell — the layout the staged app must reproduce

☑ Installers live outside the workspaces#

decision

installers/ has its own package.json and node_modules. electron-builder and its ~277 transitive packages are build tooling, not app dependencies, and keeping them separate means npm install at the repo root stays about the product.

☑ Build pipeline#

☑ App build#

flow

npm run build at the repo root runs three steps:

  1. npm run docs — compiles docs/ into apps/ui/public/help, which Vite then copies verbatim into the bundle.
  2. npm run build -w @ohm/ui — Vite builds the renderer into apps/ui/dist, injecting the production CSP meta tag.
  3. node scripts/build-desktop.mjs — esbuild bundles three CommonJS entries into apps/desktop/dist: main.js, preload.js and ohm-worker.js.

electron, wasmoon, node-llama-cpp and @node-llama-cpp/* stay external: wasmoon loads its .wasm next to its own module and node-llama-cpp picks a native binary package at runtime, so both must be real files in node_modules.

☑ Staging#

flow

installers/shared/stage.mjs builds the app and lays it out under installers/build/app exactly as it will ship:

app/
├─ package.json        name, version, main: desktop/dist/main.js
├─ desktop/dist/*.js   main, preload, ohm-worker (no source maps)
├─ ui/dist/**          the renderer
└─ node_modules/       runtime packages only

The desktop/ + ui/ shape is the same one the repo uses, so main.js resolves the UI at ../../ui/dist in a packaged build exactly as in development. A reimplementation that flattens this must change uiDir in apps/desktop/src/main.ts.

☑ Ship the full dependency closure#

requirement #regression

Staging walks dependencies transitively from wasmoon and node-llama-cpp, resolving each the way Node would (nearest node_modules, walking up to the repo root), and copies each package with its own nested node_modules intact. That is 91 packages today; node-llama-cpp alone pulls ~89.

Copying only the two roots produces a build that works until someone selects an embedded llama.cpp profile, then fails with a module-not-found at runtime.

  • node: prune-rules

☑ Prune only what is never loaded#

requirement #regression

node-llama-cpp/llama/ is 34 MB, almost all of it gitRelease.bundle — a git bundle of the llama.cpp sources used only when building from source. The rest of llama/ is metadata the package reads at runtime (binariesGithubRelease.json, llama.cpp.info.json, grammars).

Prune the bundle file, never the folder.

☑ Select llama.cpp binaries per target#

requirement

Only the prebuilt binary matching the build target is copied, chosen from a map of <platform>-<arch> → { cpu, vulkan, cuda } package names. CPU is the default; --backends cpu,vulkan,cuda opts into the GPU builds, which add ~600 MB on Windows. A missing package is a warning, not an error: the app degrades to "embedded llama.cpp unavailable" and every other model profile still works.

  • spec: 04-models-and-settings

☑ Staging is verifiable without packaging#

test

node shared/stage.mjs --platform win32 --arch x64 stages alone, and the closure can be checked directly:

node --input-type=module -e "await import('file:///…/build/app/node_modules/node-llama-cpp/dist/index.js')"

This is the cheap test that catches both pruning and closure mistakes.

  • node: prune-rules

☑ Packaged layout#

☑ No asar archive#

decision

The app serves its own UI with net.fetch(pathToFileURL(file)), which cannot read inside an asar archive, and it starts the Ohm interpreter with worker_threads pointed at a file path. Both work from plain files.

electron-builder warns about this on every build. The alternatives — unpacking the UI and the worker, or rewriting the scheme handler to stream bytes through fs with a MIME table — are more moving parts for a file count this small (~20 UI files plus a few large binaries).

  • spec: 01-engine-and-shell — the ohm-app:// scheme handler

☑ Sizes to expect#

data-model

Artefact Size
staged app ~95 MB (UI 35, llama.cpp CPU 47, code and deps ~13)
Windows NSIS installer ~120 MB
Windows portable exe ~106 MB
installed on disk ~460 MB (Electron dominates)

A build far outside these numbers means the backend selection or the prune rules changed.

☑ Documentation and help#

☑ The documentation is compiled into the app#

decision

scripts/build-docs.mjs renders every .md under docs/ (plus installers/README.md) to HTML in apps/ui/public/help, and npm run build runs it first, so a shipped build always carries the documentation that matches it.

Rendering at build time rather than in the app keeps the markdown renderer, its highlighter and the search index out of the bundle, and makes the result a real site: internal links, heading anchors and browser history all work.

requirement

[x](guide/scripts.md) becomes guide/scripts.html, including its #anchor; images, absolute URLs and mailto: are left alone, and docs/images is copied next to the pages so ../images/x.png resolves from a guide page.

The spec sources (docs/specs/src) are skipped: they are the input to npm run specs, and the generated .spec.md files are what readers want.

  • node: help-build

☑ The Help tab#

component

? in the activity bar opens apps/ui/src/components/HelpPane.tsx: a toolbar (back, forward, contents, "Open in a tab") over an <iframe> pointing at help/index.html beside the UI, whatever the origin is — ohm-app:// packaged, http://localhost in dev.

A frame rather than a React markdown view, because the help is already a site. If help/index.html is missing the pane says the help has not been built and names the command, instead of showing an empty frame.

requirement

Every page gets the same sidebar (guides, reference, build, specs) with the current page marked, and search.json — title, headings and text per page — powers a client-side search box. No network, no index server.

  • node: help-build

☑ Staleness is a build failure#

test #regression

npm run docs -- --check re-renders into memory and compares; npm run check runs it, so a doc edit that was never compiled fails CI rather than shipping a help tab that disagrees with the repository.

  • node: help-build

☑ Screenshots are captured, not pasted#

flow #regression

npm run shots (scripts/capture-docs.mjs) starts Ohm against a throwaway profile with --remote-debugging-port, seeds a project, a script and a remote endpoint, drives the real UI over CDP and writes docs/images/*.png at 2×.

Two things this run taught, both now encoded in the script:

  • files.import opens a file picker, which a headless run cannot answer, so the document is copied into the project's files/ folder and the panel is refreshed.
  • Every capture is hashed, and a screenshot identical to an earlier one fails the run — that is what a click that silently did nothing looks like, and it had already shipped one duplicate image before the check existed.

☑ Branding#

☑ One source for every image#

component

installers/shared/branding.html holds every plate (app icon, NSIS sidebar, NSIS header, DMG background). npm run assets renders them with the repo's own Electron — offscreen BrowserWindow + capturePage() — and writes installers/shared/assets.

Electron is the renderer because it is already a dependency, it is the exact engine the app ships, and nativeImage hands back both PNG (toPNG()) and raw BGRA (toBitmap()), which is what the icon and BMP containers need.

☑ The mark is geometry, not type#

requirement

The Ω is drawn as SVG arcs and curves, not a font glyph, so the icon renders identically on a build machine with no fonts installed.

☑ Image containers written by hand#

api

No image library is used; three containers are written directly:

  • .ico — 7 entries. 16/24/32/48/64/128 as 32-bit DIBs (BITMAPINFOHEADER with height = 2 × size for the XOR bitmap plus an empty AND mask, rows bottom-up), 256 as an embedded PNG.
  • .icns — icns magic, total length, then PNG chunks: icp4 16, icp5 32, ic11 32, ic12 64, ic07 128, ic08 256, ic13 256, ic09 512, ic14 512, ic10 1024. Each chunk's length includes its own 8-byte header.
  • .bmp — 24-bit bottom-up, 4-byte aligned rows, alpha flattened onto white. NSIS page images accept nothing else.

☑ Render at device pixels#

requirement #regression

A @2x plate is rendered by sizing the window to width × scale and setting zoomFactor: scale; capturing a 1x-sized window with a zoom factor only crops. Check the result: dmg-background@2x.png must be 1080×760, not 540×380.

☑ Offscreen capture pitfalls#

note #gotcha

Two things bite when rendering with Electron:

  • Destroying the capture window makes Electron quit the whole process unless app.on('window-all-closed', () => {}) is installed, so only the first plate gets written and the exit code is still 0.
  • On Windows, an Electron process is a GUI app: console.log does not reach the terminal. Run with ELECTRON_ENABLE_LOGGING=1 when a render seems silent.

☑ Windows installer#

☑ An assisted wizard, not one click#

requirement

nsis.oneClick: false with allowToChangeInstallationDirectory. People installing a browser should see where it goes. Per-user by default (perMachine: false) so there is no UAC prompt, with "anyone who uses this computer" offered on the install-mode page and elevating when chosen.

☑ A welcome page#

requirement #gotcha

electron-builder's assisted installer has no welcome page: its first page is "Choose Installation Options". A welcome page is added through its customWelcomePage macro hook, which is also what puts the branded sidebar in front of the user — the sidebar bitmap is only used on the welcome and finish pages.

☑ Shortcuts and registry#

requirement

Desktop and Start Menu shortcuts named "Ohm"; an "Ohm version" entry in Apps & features. customInstall writes HKCU\Software\Ohm with InstallLocation and Version.

Note when testing: the Desktop folder may be redirected (OneDrive), so check [Environment]::GetFolderPath("Desktop") rather than %USERPROFILE%\Desktop.

☑ Uninstall never takes the user’s work#

requirement #safety

Uninstalling removes the program, the shortcuts and HKCU\Software\Ohm. It asks — defaulting to No, and silently answering No under /S — whether to also delete %APPDATA%\Ohm (settings, encrypted credentials, downloaded models).

Documents\Ohm, which holds scripts and projects, is never touched. An update reinstall (isUpdated) keeps everything without asking.

  • spec: 12-storage

☑ Install → run → uninstall#

test

The acceptance test for a Windows build:

  1. Setup.exe /S, then confirm the install dir, both shortcuts, HKCU\Software\Ohm and the Apps & features entry exist.
  2. Launch the installed exe with --remote-debugging-port, confirm both targets (https://www.google.com/ and ohm-app://ui/index.html) are present, and run print("ok", $\sqrt{144}$) in the console — it must print 12. That one line exercises the LaTeX→Lua compiler, the worker thread and wasmoon from the packaged layout.
  3. Uninstall Ohm.exe /S, then confirm all four traces are gone.
  • node: uninstall-rules
  • spec: 02-ohm-language

☑ macOS disk image#

☑ DMG plus zip#

requirement

A DMG whose Finder window shows the app, an arrow and the Applications folder on the drawn background, and a .zip of the same .app. The icon positions (150,212) and (390,212) line up with the dashed rings in the background image, and dmg.window matches the background's 540×380.

☑ Hardened runtime entitlements#

requirement

com.apple.security.cs.allow-jit and allow-unsigned-executable-memory for V8 and the Lua VM; disable-library-validation for the prebuilt llama.cpp .node binary; network client/server for browsing, model downloads and API calls.

Without these the app launches and then dies the first time a script runs.

  • spec: 02-ohm-language

☑ Requires macOS#

requirement #platform

hdiutil and codesign exist only on macOS, so the DMG cannot be produced from Windows or Linux. npm run build:mac -- --validate runs anywhere and does everything short of the build: it stages for the darwin target, resolves the full electron-builder config and prints it, and reports missing mac llama.cpp binaries.

☑ Signing#

☑ Environment variables#

api

Platform Variables
Windows CSC_LINK (.pfx path or base64), CSC_KEY_PASSWORD
macOS CSC_LINK, CSC_KEY_PASSWORD; notarization adds APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID

With no certificate the macOS build signs ad-hoc (identity: null) so it at least launches on the build machine, and notarization is skipped. Unsigned Windows builds get a SmartScreen warning on first run; unsigned macOS builds get Gatekeeper's "unidentified developer".

☐ No certificate is configured#

risk high

Every build produced so far is unsigned. Before publishing to users, obtain a Developer ID (macOS) and an EV or OV code-signing certificate (Windows) and set the variables above in CI rather than on a developer machine.

☑ Open work#

☐ Auto-update#

task

Nothing publishes update metadata yet. electron-builder can emit latest.yml and the blockmap is already produced; wiring electron-updater needs a hosting decision first.

☐ File association for .ohm#

task

Associating .ohm with the app would be a natural nicety, but the app does not read a file path from process.argv yet, so a double-clicked script would open an empty window. Implement argv handling first, then add fileAssociations.

  • spec: 03-scripts

☐ Linux packaging#

task

The staging step already has a slot for linux-x64/linux-arm64 llama.cpp binaries; AppImage and deb targets are not configured.

graph LR
  ship_the_full_dependency_closure["Ship the full dependency closure"] -.-> prune_rules["Prune only what is never loaded"]
  staging_is_verifiable_without_packaging["Staging is verifiable without packaging"] -.-> prune_rules["Prune only what is never loaded"]
  markdown_links_are_rewritten_assets_are_not["Markdown links are rewritten, assets are not"] -.-> help_build["The documentation is compiled into the app"]
  the_help_site_has_a_contents_tree_and_offline_se["The help site has a contents tree and offline search"] -.-> help_build["The documentation is compiled into the app"]
  staleness_is_a_build_failure["Staleness is a build failure"] -.-> help_build["The documentation is compiled into the app"]
  install_run_uninstall["Install → run → uninstall"] -.-> uninstall_rules["Uninstall never takes the user’s work"]