Notifio watches rental search pages from your own machine and emails you when a new listing appears. There is no account and no sync: the searches, the licence and the history all live on the laptop. Which means the writable-state story is the product's data model, and it is worth knowing exactly what is in it.
So I went and listed it.
One module owns every path
/**
* Centralised path resolution for Notifio.
*
* 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
*/
Every writable location is one of eight exports from that file, and three of them are directories created at startup:
export const USER_DATA_DIR = app.getPath('userData');
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');
// 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.
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');
// 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.
export const FINDS_PATH = path.join(USER_DATA_DIR, 'finds.json');
The two comments are the only reason this is more than one file. config.json is read on the hot path, and it is the file whose corruption stops the product working, so nothing else is allowed to share it: not the reply history, not the find history, not the cached entitlement.
What is actually on my disk
config.json 1,117 bytes
entitlement.json 106 bytes
logs.txt 1,725 bytes
data/ 10,078 bytes (3 files)
ledger/ 0 bytes (empty)
finds.json missing
profile.json missing
recipes.json missing
13,026 bytes. Every search I have saved, the licence the app runs on, its cached entitlement, the log of what it did and the per-search snapshot it compares against. Months of use, and the whole lot is 2 KB bigger than the markdown file this post is written in.
The directory holding it is 63.4 MB across 25 entries, and 19 of those entries are not ours at all. userData in an Electron app doubles as the Chromium profile, so Cache, Code Cache, GPUCache, DawnWebGPUCache, Trust Tokens, DIPS, Shared Dictionary and friends all land in the same folder as our four files. The two browser profile directories, browser-data at 35 MB for the signed-in sessions and Partitions at 15.9 MB for the login windows, are 80% of the total between them.
That gap is worth staring at for a second. The product's entire knowledge of its user is 0.02% of the directory it sits in. Everything else is browser bookkeeping. Any conversation about what a local app "stores about you" should start with that ratio, because the thing people imagine is the small file and the thing taking the disk is the cache.
Five strategies for eight files
The interesting part of the inventory is that no two of these files are persisted the same way, and each difference has a reason.
| File | Strategy | Why |
|---|---|---|
config.json |
plain JSON, temp file plus rename | Read fresh on every use, so activating mid-session is picked up without a restart |
profile.json, recipes.json
|
encrypted envelope via Electron safeStorage
|
They hold what you would say to a landlord, so they are not plain text at rest |
finds.json |
ring buffer of 200, atomic write, in-memory cache | A history that must never grow without bound |
ledger/replies.ndjson |
append-only NDJSON, folded last-wins, compacted at 8,000 rows down to 2,000 | Writes must be atomic by construction, because this file answers "have we already messaged this listing" |
| per-search runtime state | no file at all | A stale "checking..." restored from disk after a restart would be a lie |
The envelope is shared by the two encrypted files so there is one code path for local user data, and it fails open rather than losing anything:
export function canEncrypt(): boolean {
try {
return safeStorage.isEncryptionAvailable();
} catch {
// Not running under Electron, or the OS keychain is unavailable. Fall back to
// plaintext rather than losing the data.
return false;
}
}
That is a real trade and I want it written down rather than discovered: on a Linux box with no keyring, your saved reply flow is stored as enc: 'none' instead of refusing to save. The envelope records which one it is, so a file written on a machine with a keychain and read on one without fails loudly instead of returning nonsense.
The ledger is the one I would defend hardest, because the obvious answer there is SQLite:
/**
* NDJSON rather than sqlite on purpose: no native module, so the existing
* electron-builder pipeline (already fiddly with the bundled Playwright
* browsers) stays untouched. Updates are appended as a new row with the same
* id and readers fold last-wins, which keeps writes atomic by construction.
*/
A native module in an Electron app is a rebuild per platform per Node ABI, and this app already ships a Chromium build with ten symlinks that have to survive packaging. An append to a text file cannot be half-applied, and a torn final line from a power cut is skipped on read:
try {
rows.push(JSON.parse(trimmed) as T);
} catch {
// A torn final line (power loss mid-append) must not poison the read.
}
Three of the eight files do not exist
finds.json, profile.json and recipes.json are absent from my disk, because I have never used the features that write them. Nothing anywhere special-cases that. Every reader treats a missing file as the default:
function read(): Find[] {
if (_cache) return _cache;
try {
const parsed = JSON.parse(fs.readFileSync(FINDS_PATH, 'utf8'));
_cache = Array.isArray(parsed) ? (parsed as Find[]) : [];
} catch {
// Missing or corrupt: an empty history is a fine place to start and must
// never stop the monitor.
_cache = [];
}
return _cache;
}
One catch covering both "not there yet" and "something chewed it", with a comment saying why those two deserve the same answer. The same shape appears in the entitlement cache, where anything that is not literally true reads as false, and in the settings loader, where a missing file produces the defaults.
ledger/ is the exception: it exists on my disk as an empty directory, because initDataDirs() creates all three directories eagerly whether or not anything will write to them. Harmless, and the honest reason is that a mkdirSync(..., { recursive: true }) at startup is one line in one place, whereas an ensure-on-write is one line in every writer.
The branch that can never run
Here is the first-run seeding, in full:
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'
);
}
}
The if has never been taken in a shipped build. config.json is listed in the electron-builder files array and in asarUnpack, and it is one of three paths in that config that do not exist, so there is nothing to copy. I checked the packaged app rather than trusting that post:
$ ls release/mac-arm64/Notifio.app/Contents/Resources/app.asar.unpacked/
node_modules
playwright-browsers
Every real first run falls through to the else, which writes two empty fields:
const DEFAULT_CONFIG = {
email: { to: '' },
sites: [] as Array<{ name: string; url: string; enabled: boolean }>,
};
And email.to is immediately overwritten at activation, because the alert address is set to the licence email rather than asked for separately. So the file a brand new install starts from is, in practice, {"sites": []} with an empty string next to it.
I am not going to delete the dead branch. It is the hook for a thing I will probably want eventually, which is shipping a config with a search pre-filled for a campaign. What I will do is make the file exist, because a files entry pointing at nothing is the kind of green build that hides a change in behaviour: the day somebody adds app/config.json for an unrelated reason, every fresh install starts seeding from it.
The gap the inventory found
Nothing in the app deletes any of this. There is a "start over" that resets the auto-reply settings, a DELETE /api/finds behind the clear-history button, and deactivating a licence removes the cached entitlement file. There is no single action that empties the directory, and uninstalling leaves all 63 MB behind, including the signed-in browser sessions.
For a local-only product that is a reasonable default, since the data is sitting in the user's own home directory and nothing has been uploaded anywhere. But the directory contains logged-in sessions for rental sites, and "delete everything Notifio kept" is a request a user is entitled to make without being told to go and find ~/Library/Application Support/Notifio themselves. That is the next thing I am adding, and the inventory above is what it has to clear.
If you want to see the product the 13 KB belongs to: the app itself is on the download page, what it costs is on the pricing page, and the storage questions come up on the help page. The per-site setup guides are at notifio.app/alerts, including Kamernet and SpareRoom.
Top comments (0)