title: "Storage, bundles and the browser mirror" description: "Files are the source of truth; a bundle is a portable copy; the browser database is a backup." nodes: 25 progress: 88% generated: ohm spec editor#

Storage, bundles and the browser mirror#

Files are the source of truth; a bundle is a portable copy; the browser database is a backup.

Contents#

☑ Overview#

☑ Three tiers, one source of truth#

decision

  1. The filesystem is authoritative. Every project, script, item and document is a plain file the user can read, edit, diff and back up.
  2. A bundle is one JSON file holding a whole project, for moving it between machines.
  3. The browser mirror (IndexedDB) is a convenience backup inside the app, never a second source of truth.
  • spec: 08-projects-and-research

☑ Where the folders are#

☑ Four folders, one precedence rule#

decision

The data folder (settings, credentials), Chromium's profile folder (cookies, cache, local storage), the scripts folder and the models folder can each be moved, and each is decided by the first of these that says anything:

command-line flag  →  environment variable  →  config file  →  default
Folder Flag Variable Default
data --data-dir OHM_DATA_DIR, OHM_USER_DATA Electron's userData
profile --profile-dir OHM_PROFILE_DIR the data folder
scripts --scripts-dir OHM_SCRIPTS_DIR <Documents>/Ohm
models --models-dir OHM_MODELS_DIR <data>/models

A flag is the most explicit thing a user can say, so it outranks a variable, which outranks the file. Relative paths resolve against the working directory, and a flag takes its value as the next argument or after = or :.

packages/core/src/app-config.ts holds all of this and imports no Electron: it takes argv, env and the defaults and returns paths.

  • spec: 01-engine-and-shell

☑ The config file lives in the OS config folder, not the data folder#

decision

%APPDATA%\Ohm\config.json on Windows, ~/Library/Application Support/Ohm/config.json on macOS, $XDG_CONFIG_HOME/ohm/config.json elsewhere — holding dataDir, profileDir, scriptsDir and modelsDir.

It cannot live in the data folder, because it is what says where the data folder is. Keeping it in the standard location means it stays put however much else moves, and with nothing configured it simply sits beside settings.json.

Keys it does not recognise survive a write, so hand-editing is safe.

☑ Read synchronously, before anything opens a folder#

requirement #regression

userData and sessionData can only be moved before Chromium opens them, which is earlier than any promise resolves — so the config is read with readFileSync at module scope in apps/desktop/src/main.ts, before whenReady.

A missing file is the normal case. A corrupt one yields the defaults plus a problem string that the warning banner in Settings shows: a bad config must never be the reason Ohm will not start.

  • node: config-file

☑ Changes apply at the next start, and nothing is moved#

requirement #regression

Chromium holds its profile for the life of the process and settings are already loaded, so a folder never changes mid-run. config.setDir writes the file and the UI shows both the running folder and the queued one, with Restart now.

No data is copied: a new folder starts empty, which is what makes a second isolated instance a one-liner and what makes "take my data with me" a manual step. The guide says so in as many words.

restartRequired is false while a flag or variable is in force, because app.relaunch() keeps the same argv and environment — a restart would change nothing, so offering one would be a lie.

  • node: dir-precedence

☑ A folder is checked when it is chosen, not when it is needed#

requirement #safety

checkWritableDir resolves the path, creates it, writes a probe file and removes it again. A folder that cannot be created or written to is refused at the moment the user picks it, and nothing is written to the config — the alternative is an app that will not start next time.

☑ A named models folder outranks the stored one#

requirement #regression

settings.json remembers a models folder, so a --models-dir flag would otherwise be silently ignored on every run after the first. When the folder comes from a flag or a variable it is passed as forcedModelsDir, which wins over the stored value, and setModelsDir refuses with an explanation rather than storing over an instruction the user just gave.

The config file is different: it sets the default, and a folder chosen in Settings still wins.

  • spec: 04-models-and-settings

☑ config.* and engine restart#

api

config.get returns { file, dirs, restartRequired, problem? } where each dir carries path, source, fallback, and configured/overriddenBy when they apply — enough for the UI to explain why a folder is what it is. config.setDir (with dir: null to restore the default), config.pickDir and config.restart do the rest.

Restarting needed a new engine capability: BrowserEngine.restart(), implemented as app.relaunch() + app.exit(0) — exit rather than quit because a relaunch that waits for windows to agree to close can be cancelled halfway and leave nothing running.

  • spec: 01-engine-and-shell

☑ Importing a browser profile#

☑ Copy what helps and say what was left behind#

decision #security

packages/core/src/chrome-import.ts copies a profile out of another Chromium browser (Chrome, Edge, Brave, Vivaldi, Chromium) into the profile folder Ohm will use next. The selection is an allow list, not an exclude list, in two groups:

  • sessions — Network/Cookies (and the pre-Chrome-96 Cookies), local storage, session storage, IndexedDB, plus Local State from the user-data root, which holds the key the cookies are encrypted with.
  • browsing — bookmarks, history, favicons, top sites, autofill.

Three things are never copied, and the result says so in as many words: saved passwords (Ohm has no password manager), extensions (Ohm does not load Chromium extensions, so a copy would only look like it worked) and caches (most of the size, none of the value). Taking a user's most sensitive file to achieve nothing is worse than not having the feature.

☑ Never write into the profile Chromium has open#

requirement #safety

The destination is the profile folder the config file asks for, which may not be the folder this run is using — Chromium holds that one open, so the copy would be refused by the OS partway through and could corrupt a live session.

When they are the same folder the UI says so and points at Change… instead of failing at the end of a long copy.

  • node: dir-precedence

☑ Be honest about app-bound encryption#

requirement #regression

Chrome for Windows now re-encrypts cookies with a key bound to Chrome's own executable, so nothing else can decrypt them — by design, and no amount of copying changes it. The result says so for Chrome on Windows and not otherwise, and points out that unencrypted site data (local storage, IndexedDB) always carries over, which for many sites is where the session token actually lives.

Promising that logins will survive would be the easy copy to write and wrong about half the time.

  • node: chrome-import

☑ Detection reads the names the user recognises#

api

detectChromeProfiles walks the platform's app-data root for each known browser, reads Local State → profile.info_cache to turn Profile 4 into "Rinky", skips folders with no Preferences (leftovers, not profiles), and reports the size of what would be copied rather than the folder on disk — which is a hundredfold difference once caches are excluded.

importChromeProfile returns { files, bytes, locked, notes }: a file the source browser has open is recorded, not fatal, so the user is told to close it and import again.

  • node: chrome-import

☑ Bundles#

☑ Shape#

data-model

{
  ohmBundle: 1,
  exportedAt,
  info,                              // ProjectInfo without the path field
  links,
  items: { prompt?: {...}, curve?: {...}, experiment?: {...}, graph?: {...}, spec?: {...} },
  files:    Record<string, string>,  // docs/, data/, scripts/, annotations/
  binaries?: Record<string, string>  // files/… as base64
}

The manifest and links.json do not travel as files — they are reconstructed from info and links.

Limits: a text file over 5 MB is skipped; binaries stop at a 50 MB running total, and the loop continues rather than breaking, so a smaller later file can still fit.

  • spec: 11-documents

☑ Import always creates a new project#

requirement #security

importBundle validates ohmBundle === 1 ("Not an Ohm project bundle"), then creates a project through the normal path — so the id is uniquified with -2, -3… and an import can never mutate an existing project.

Restore whitelists paths: text only into docs|data|scripts|annotations, binaries only into files/, and no .. segment in either. Links are appended and deduped by URL.

Because the project template is written first and the bundle laid over it, a restored project also contains template items the original never had.

☑ Export and import from the UI#

api

projects.exportFile offers a save dialog defaulting to <id>.ohm-bundle.json; projects.importFile picks a file, imports it and makes the new project active. Both return null when the dialog is cancelled.

☑ Browser mirror#

☑ IndexedDB copy#

component

Database ohm, one object store projects keyed by project id, each entry { project, name, exportedAt, savedAt, bytes, bundle }. One entry per project, so re-mirroring overwrites.

Auto-mirror is a localStorage flag, fires after every successful item save, and is a no-op when IndexedDB is unavailable — every access is wrapped, because a private window or blocked site data must not break saving.

Restoring imports the stored bundle as a new project named " (restored)".

  • node: tiers

☑ Review findings#

☐ Bundles drop content silently#

risk high #data-loss

Text files over 5 MB, documents past the binary budget, and any script that is not .ohm (the scripts list is filtered to that extension before bundling) are all omitted with no marker in the bundle and no warning to the user — so "export, restore" can be quietly lossy.

Record what was skipped in the bundle and report it after an export.

  • node: bundle-shape

☐ Import is not transactional#

risk

The project template is written first, so a failure part-way through — a bundle item name that fails validation, unreadable base64 — leaves a half-populated project on disk that is already marked changed. Stage into a temporary id and rename on success.

  • node: import

☐ Import loses tags and createdAt#

risk

Project creation accepts only name, kind, description and question, so info.tags and the original createdAt are dropped even though both are in the bundle.

graph LR
  read_synchronously_before_anything_opens_a_folde["Read synchronously, before anything opens a folder"] -.-> config_file["The config file lives in the OS config folder, not the data folder"]
  changes_apply_at_the_next_start_and_nothing_is_m["Changes apply at the next start, and nothing is moved"] -.-> dir_precedence["Four folders, one precedence rule"]
  never_write_into_the_profile_chromium_has_open["Never write into the profile Chromium has open"] -.-> dir_precedence["Four folders, one precedence rule"]
  be_honest_about_app_bound_encryption["Be honest about app-bound encryption"] -.-> chrome_import["Copy what helps and say what was left behind"]
  detection_reads_the_names_the_user_recognises["Detection reads the names the user recognises"] -.-> chrome_import["Copy what helps and say what was left behind"]
  mirror["IndexedDB copy"] -.-> tiers["Three tiers, one source of truth"]
  bundles_drop_content_silently["Bundles drop content silently"] -.-> bundle_shape["Shape"]
  import_is_not_transactional["Import is not transactional"] -.-> import["Import always creates a new project"]