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
- Build pipeline
- Packaged layout
- Documentation and help
- Branding
- Windows installer
- macOS disk image
- Signing
- Open work
☑ 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:
npm run docs— compilesdocs/intoapps/ui/public/help, which Vite then copies verbatim into the bundle.npm run build -w @ohm/ui— Vite builds the renderer intoapps/ui/dist, injecting the production CSP meta tag.node scripts/build-desktop.mjs— esbuild bundles three CommonJS entries intoapps/desktop/dist:main.js,preload.jsandohm-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.
☑ Markdown links are rewritten, assets are not#
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.
☑ The help site has a contents tree and offline search#
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.importopens a file picker, which a headless run cannot answer, so the document is copied into the project'sfiles/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 × sizefor the XOR bitmap plus an empty AND mask, rows bottom-up), 256 as an embedded PNG. - .icns —
icnsmagic, total length, then PNG chunks:icp416,icp532,ic1132,ic1264,ic07128,ic08256,ic13256,ic09512,ic14512,ic101024. 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.logdoes not reach the terminal. Run withELECTRON_ENABLE_LOGGING=1when 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:
Setup.exe /S, then confirm the install dir, both shortcuts,HKCU\Software\Ohmand the Apps & features entry exist.- Launch the installed exe with
--remote-debugging-port, confirm both targets (https://www.google.com/andohm-app://ui/index.html) are present, and runprint("ok", $\sqrt{144}$)in the console — it must print12. That one line exercises the LaTeX→Lua compiler, the worker thread and wasmoon from the packaged layout. 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.
Links between nodes#
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"]