Notifio runs on your machine and checks your saved rental searches on a loop. Everything it knows is in files on your disk, because there is no account and no server-side copy of your searches. That makes the on-disk layout a design decision rather than an implementation detail, and the whole of it is one 56-line module.
Where it is allowed to live
/** * Inside a packaged asar, __dirname is read-only. All writable data must live * in app.getPath('userData') which resolves to: * Windows: %APPDATA%\Notifio * macOS: ~/Library/Application Support/Notifio */ export const USER_DATA_DIR = app.getPath('userData'); This is the constraint that catches everyone once. In development the app runs from a directory you can write to, so fs.writeFileSync(path.join(__dirname, 'config.json')) works, ships, and then fails on the first user's machine, where the same path is inside a read-only archive. The symptom arrives as a support email rather than a test failure.
So there is exactly one writable root, read from Electron rather than constructed, and everything else hangs off it:
export const DATA_DIR = path.join(USER_DATA_DIR, 'data'); export const BROWSER_DATA_DIR = path.join(USER_DATA_DIR, 'browser-data'); export const CONFIG_PATH = path.join(USER_DATA_DIR, 'config.json'); export const PROFILE_PATH = path.join(USER_DATA_DIR, 'profile.json'); export const ENTITLEMENT_PATH = path.join(USER_DATA_DIR, 'entitlement.json'); export const RECIPES_PATH = path.join(USER_DATA_DIR, 'recipes.json'); export const LEDGER_DIR = path.join(USER_DATA_DIR, 'ledger'); export const FINDS_PATH = path.join(USER_DATA_DIR, 'finds.json'); Eight entries. The interesting question is why it is eight and not one, because a single state.json would be less code, one write path, one read path, one atomic rename.
Two different answers produced this split, and they are written into the file as comments next to the thing they explain.
Reason one: what else breaks when this file is garbage
// Auto-reply data. Kept in separate files from config.json on purpose: a // corrupt profile or ledger must never stop the monitor from finding listings. The app has one job people paid for, which is noticing a new listing before everyone else does. Everything else is secondary, including the feature that fills in the reply form for you.
With a single state file, a half-written ledger entry takes the searches with it. The parse fails, and the recovery is either "fall back to defaults", which silently discards fifteen configured searches, or "refuse to start", which is worse. Splitting the file means a damaged recipes.json costs you your recordings and nothing else, and the monitor keeps working while you notice.
This is a blast-radius argument rather than a probability argument. Each individual file is written atomically through a temp file and a rename, so none of them is especially likely to tear. The split is about what the unlikely case costs.
Reason two: the file on the hot path has to stay small
// The recent-finds history shown in the app. Kept out of config.json so a long // history can never make the file the monitor reads every poll any bigger. config.json holds your searches and your alert email. It is read on the poll cycle, which runs every 30 seconds, measured start to start. With a cap of 15 searches it stays well under a kilobyte, so reading it is free and the monitor never has to care about a cached copy going stale behind the UI.
The finds history is the opposite shape. It is a ring buffer of the last 200 listings found:
/** * How many finds to keep. A busy hunt produces a few hundred a week; this is * enough to cover "what did I miss overnight" without the file growing forever. */ const MAX_FINDS = 200; 200 listings with titles and URLs is tens of kilobytes. Merged into config.json, every poll would parse the user's entire browsing history to find out which searches to check, and the cost would grow with how successful the app has been for them. That is a bad slope to be on even when the absolute numbers are small, because the shape of it says that a heavy user gets the slower app.
The same instinct explains the one path that is a directory. The ledger is append-only, a line at a time, and a torn last line costs exactly one row rather than the file.
First run has to create all of it
export function initDataDirs(): void { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(BROWSER_DATA_DIR, { recursive: true }); fs.mkdirSync(LEDGER_DIR, { recursive: true }); if (!fs.existsSync(CONFIG_PATH)) { const bundledConfig = path.join(__dirname, '..', 'config.json'); if (fs.existsSync(bundledConfig)) { fs.copyFileSync(bundledConfig, CONFIG_PATH); } else { fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf8'); } } } recursive: true is doing double duty: it creates missing parents, and it does not throw when the directory already exists, so this function is safe to call on every launch and there is no "is this the first run" flag anywhere.
Only config.json is seeded. The other files are created lazily by the modules that own them, each of which already has to handle "this file does not exist yet" for the first-run case, so pre-creating empty ones would add a second state to handle rather than removing one.
The seed is copied from a bundled config.json when one exists, falling back to a literal default. That lets the packaged app ship a starting config without the default being duplicated in two places that can disagree.
The ordering problem, solved with a require
Here is the part I would not have predicted. In main.ts:
initDataDirs(); // ensure writable dirs + config exist before server loads require('./server'); A require in the middle of a function, in a TypeScript codebase that uses ES imports everywhere else, is usually a mistake. This one is the point.
Imports hoist. Writing import './server' at the top of the file means the entire module graph behind it, which includes the modules that own those paths, is evaluated before any statement in the file body runs, and some of those modules read their file at module scope. initDataDirs() would be called after the readers had already decided the directory was missing.
So the sequencing is expressed with the one construct that actually sequences: a function call, then a runtime require. The comment on the line exists because this looks like something to tidy up, and tidying it up reintroduces the bug.
The path that got away
For honesty's sake, the module that centralises writable paths does not have all of them. From main.ts:
const LOG_PATH = path.join(app.getPath('userData'), 'logs.txt'); The file logger has to work before anything else does, including while diagnosing a failure in initDataDirs itself, so it cannot depend on the module whose job is to set up the directories. It builds its own path from the same root.
Nine writable paths, eight of them centralised. That is a defensible exception rather than a clean design, and writing this post is the first time I have looked straight at it.
What it costs
paths.ts imports electron at module scope, which means every one of the ten modules importing FINDS_PATH or CONFIG_PATH from it also imports Electron, transitively. The project rule is that testable files may not import electron, because the test runner is plain tsx with no Electron around it.
Those two facts together mean this module is the boundary. Anything that wants to be tested takes its data as arguments and never learns where the data came from, which is why the diffing logic and the retry logic have their own test scripts, and why the file layer does not. That was not planned, it fell out of the import graph, and it has turned out to be the right line.
If you want to look at the files themselves, the install and first-run steps are written up at notifio.app/help, the installers are on notifio.app/download, and the per-site pages like notifio.app/alerts/kamernet describe what ends up in finds.json once it is running.

