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
- Where the folders are
- Four folders, one precedence rule
- The config file lives in the OS config folder, not the data folder
- Read synchronously, before anything opens a folder
- Changes apply at the next start, and nothing is moved
- A folder is checked when it is chosen, not when it is needed
- A named models folder outranks the stored one
- config.* and engine restart
- Importing a browser profile
- Bundles
- Browser mirror
- Review findings
☑ Overview#
☑ Three tiers, one source of truth#
decision
- The filesystem is authoritative. Every project, script, item and document is a plain file the user can read, edit, diff and back up.
- A bundle is one JSON file holding a whole project, for moving it between machines.
- 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-96Cookies), local storage, session storage, IndexedDB, plusLocal Statefrom 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 "
- 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.
Links between nodes#
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"]