Notifio is an Electron app that watches rental search pages and alerts you when a new listing appears. It ships as a 600MB DMG or a 300MB Windows installer, and it has no auto-updater at all.
That is written down, in app/RELEASES.md, as a three-step procedure:
There is no auto-update. To ship a new version:
- Bump
versioninapp/package.json.- Build and upload the installers exactly as above (overwriting the files in R2).
- Tell users to download and install again from the download page.
Grep the app for autoUpdater or electron-updater and you get nothing. There is no publish block in the electron-builder config either, which means no latest.yml and no latest-mac.yml are generated, so there is nothing an updater could read even if one were added tomorrow.
I think this is the right call for this product and I want to lay out what it actually costs, because "just tell people to reinstall" is a sentence that quietly depends on a lot of things being true.
Why there is nothing to update against
The installers are large because each one carries a browser. The breakdown in our own release notes is roughly 350MB of Electron, 180MB of Playwright's Chromium at a pinned revision, and 70MB of node modules and app code. Shipping a delta updater over that is real work, and the thing being updated changes a few times a year.
The second reason is that an auto-updater is a background process that downloads and swaps a 600MB application while the app is doing the one job it exists to do, which is being awake and watching. An update that interrupts monitoring for the fifteen minutes somebody's ideal flat was listed is worse than no update.
So: no updater, and the download page is the update channel.
The thing that makes a reinstall safe
Reinstalling only works because nothing the user owns is inside the app. Every writable path is resolved in one 56-line module:
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');
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 paths under one root, and the comment above them says why:
/**
* 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
*/
The licence is in config.json along with the saved searches. Drag a new build into /Applications, or let the NSIS installer replace the old one, and all of it is still there, so nobody re-activates and nobody re-enters their searches.
Now look at the last path component in those two example paths. Notifio is not a constant anywhere in the source. It is the productName field in the build config:
"build": {
"appId": "app.notifio",
"productName": "Notifio",
...
}
Electron derives app.getPath('userData') from the application name, which for a packaged app is that string. Rename it to "Notifio Desktop" in a moment of tidying, ship the build, and every existing user launches an app that looks at %APPDATA%\Notifio Desktop, finds nothing, seeds a fresh config, and asks them to activate a licence they already own. Their old data is not gone, it is simply not where the new build looks, which is a worse failure than deleting it because nothing anywhere reports an error.
Our release notes carry the warning in bold, which is the right place for it and the wrong mechanism:
Keep
productNameas "Notifio" because it anchors the userData path. Renaming it would orphan every user's existing config and licence.
A warning in a markdown file is a comment with extra steps. The enforceable version is four lines in the release script, which already reads and rewrites package.json to bump the version: assert that build.productName is exactly Notifio and refuse to build if it is not. A release script that will not ship a rename is worth more than a sentence asking people not to do one.
The seed that never seeds
First run creates the directories and writes a default config:
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');
}
}
}
The if (!fs.existsSync(CONFIG_PATH)) is the second half of what makes reinstalling safe: the seed is skipped when a config is already there, so an upgrade cannot overwrite somebody's searches with an empty list.
The branch above it, though, is dead. app/config.json does not exist in the repository. The electron-builder files array names it anyway, along with two directories that also do not exist:
"files": [
"dist-ts/**/*",
"public/**/*",
"assets/**/*",
"config.json",
"data",
"browser-data",
...
],
"asarUnpack": [
"node_modules/playwright/**/*",
"playwright-browsers/**/*",
"config.json",
"data",
"browser-data"
]
Three phantom entries in the packaging config, repeated across two lists, left over from when listing snapshots and the browser profile lived inside the app directory rather than in userData. They are harmless, electron-builder does not mind being told to include something that is not there, and they are exactly the kind of leftover that makes somebody later believe a shipped default config exists.
Two things follow from the branch being dead. Every fresh install gets DEFAULT_CONFIG, an empty email and an empty search list, which is the right onboarding anyway. And if we ever did want to ship a default search, adding config.json back would only reach new installs, because the seed is skipped for everyone who has already run the app once. A "default" in a desktop app means "default for people who have not used it yet", and that is worth being deliberate about rather than discovering.
What the user is not told
The update path works. The experience around it has four gaps, and three of them are the same gap.
Nothing tells you a new version exists. Not a banner, not a check, nothing. The user's cue that they should reinstall is an email, or this blog.
Nothing tells you which version you have. app.getVersion() does not appear in the codebase. The version in package.json is 1.1.0, the release script bumps it, uploads it as R2 object metadata, and exactly one script ever reads it back. The app never shows it. I ran into the other end of this gap today while writing about the price the app sends back to checkout: the server would like to behave differently for builds from before a feature existed, and it cannot, because no request from the app carries a version.
Nothing tells us which version you have either. The licence check runs against our server whenever its cached answer is more than six hours old, and that request is the obvious place for one more field. One string in that request would turn "are people still on the old build" into a query instead of a guess.
And on macOS, every reinstall is a Gatekeeper prompt. There is no signing identity and no notarisation step in the build config, so the DMGs are unsigned, which our own release notes acknowledge with a "right-click, Open" instruction. For an app whose entire pitch is that it runs quietly in the background, asking the user to defeat an operating system security dialog once per update is the single least reassuring moment in the product.
The Windows side has a smaller version of the same problem from the opposite direction:
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
Letting people choose the install directory is friendly, and on a reinstall it means a second copy can land somewhere else while the first stays where it was. Both copies read the same %APPDATA%\Notifio, so no data is lost and no two monitors fight, but the shortcut the user clicks tomorrow may still be the old binary, and nothing in either copy can tell them which is which. Which is, again, the version problem.
What I am doing about it
Not writing an auto-updater. Reinstalling is a perfectly good update mechanism for software that changes four times a year, and the thing that makes it bad right now is not the download, it is that the product cannot answer "am I current?".
So, in order: the version string goes into the window and into the licence check, the licence check response gains the latest version so the app can say one sentence about it, and the release script gets the four-line assertion about productName. The three phantom paths come out of the packaging config at the same time, because a packaging list that names files which do not exist is a list nobody is reading carefully.
If you want to see the end of this pipeline, the installers are on the download page, with three buttons and no user agent sniffing. The licence is a one-time purchase on the pricing page, the reinstall and background-running questions are answered on the help page, and the per-site setup pages are at notifio.app/alerts, including Kamernet and SpareRoom.
Top comments (0)