Notifio has no auto-updater. There is no electron-updater in the dependency tree, no update feed, no "a new version is available" banner, and no code anywhere in the app that asks the server what version is current. Shipping a new build is three steps, and the third one is a sentence:
1. Bump `version` in `app/package.json`.
2. Build and upload the installers exactly as above (overwriting the files in R2).
3. Tell users to download and install again from the download page.
That is from the repo's own release notes, and it is the whole procedure. Here is why, what it costs, and the one line in package.json that the entire approach rests on.
Why there is no updater
The first reason is in Electron's own documentation for autoUpdater:
Your application must be signed for automatic updates on macOS. This is a requirement of Squirrel.Mac.
Notifio's macOS builds are not signed. The build config has one mac target, dmg, and Squirrel.Mac wants a ZIP and a valid signature. So auto-update on macOS is not a feature I chose to skip, it is a feature that is downstream of an Apple Developer Program membership and a notarisation step that this app does not have yet. Being clear about that ordering matters, because "we decided against auto-update" and "auto-update is blocked on code signing" are different sentences and only the second one is true.
The second reason is size, and it is the one that would still apply after signing:
| notifio-arm64.dmg | macOS Apple Silicon | ~600MB |
| notifio-x64.dmg | macOS Intel | ~600MB |
| notifio-setup.exe | Windows 10+ 64-bit | ~300MB |
With the breakdown:
- ~350MB Electron framework
- ~180MB Playwright's Chromium at the pinned revision
- ~70MB Node modules, compiled app code, assets
The app's own code is the last line. A typical release changes some of that 70MB and none of the 530MB in front of it, because the two large components only move when I deliberately bump Electron or Playwright. An update mechanism that re-downloads the bundle moves 600MB to deliver a few hundred kilobytes of changed JavaScript, which is a worse experience than the download page, not a better one, and the reason the app ships a browser at all is covered in Shipping Playwright's Chromium inside a packaged Electron app.
Delta updates exist and would fix exactly this. They are also a signing problem first, on the platform where the artefacts are largest.
What "reinstall" has to mean for that to be acceptable
If the update path is "replace the application", then nothing a user would mind losing may live inside the application. Not as a convention, as an enforced property. In this app every writable thing resolves from one place:
/**
* 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
*/
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');
Nine paths, one root, no exceptions. config.json holds the licence and the saved searches, browser-data/ holds the logged-in browser sessions, data/ holds each search's snapshot of what its page looked like last time. Losing the first means re-entering an activation code, losing the second means signing in to every site again, and losing the third means the next check has nothing to compare against.
That is the real test of the no-updater decision. It is only tolerable because dragging a new app into /Applications cannot touch any of those.
The other half is that first run and hundredth run go down the same code path:
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 makes the three mkdirSync calls no-ops when the directories exist. The seed is guarded by existsSync, so the bundled config.json that ships inside every installer is only ever copied out on a genuinely fresh machine. A reinstall runs all of this and changes nothing, which is the behaviour an installer needs when it is also the updater.
The string that must never change
Here is the part that is easy to miss. app.getPath('userData') is not configured anywhere in this app. Electron derives it, and on both platforms it derives it from the application name, which electron-builder takes from productName:
{
"name": "notifio",
"productName": "Notifio",
"version": "1.1.0"
}
So ~/Library/Application Support/Notifio and %APPDATA%\Notifio are not constants in the codebase. They are a consequence of the word Notifio on that one line. Renaming the product to "Notifio Desktop" in a release where the marketing copy changed would move the userData directory, and the failure mode is the worst available:
-
initDataDirs()creates the new, empty directory successfully. - No
config.jsonexists there, so a fresh one gets seeded. - The app opens, reports no licence, and asks for an activation code.
- Every saved search, every logged-in session and every snapshot is still on disk, in a directory the new build will never look at.
- Nothing throws. Nothing logs an error. There is no bug to see.
A user whose app forgot their licence after an update does not file a report saying "your userData path moved". They file a report saying the update broke it, or they do not file anything at all. And because there is no auto-updater, the people affected are the subset who did the reinstall, arriving over weeks, which is the hardest possible signal to read.
Which is why that constraint is written down next to the release steps rather than living in my head:
- Keep `productName` as **"Notifio"**, it anchors the userData path. Renaming it
would orphan every user's existing config and license.
I would rather it were enforced than documented. The honest version of this post includes that the enforcement does not exist: there is no test asserting productName === "Notifio", and a one-line check in the release script would close it. The reason it has not been written is the usual one, which is that the documented version has held so far.
The costs, stated plainly
A download page as an update mechanism has consequences I have accepted rather than solved.
Users stay on old versions indefinitely. There is no mechanism to tell them a new build exists, because the app never asks. I checked before writing this: nothing in the main process or the embedded server reads app.getVersion() for comparison against anything.
There is no way to push a fix. If a release has a bug, the remedy is the same sentence as a feature release, which means the time to repair a user's install is however long it takes them to visit the site again.
macOS reinstalls re-trigger Gatekeeper. Unsigned builds mean right-click then Open, every time, which is both friction and exactly the habit security advice tells people not to form. Another consequence of the same missing signature.
Nobody can pin a version. The R2 keys are fixed filenames, notifio-arm64.dmg and friends, overwritten in place on each release, and the download endpoint maps a platform to a key:
const FILES: Record<string, string> = {
"mac-arm64": "notifio-arm64.dmg",
"mac-x64": "notifio-x64.dmg",
win: "notifio-setup.exe",
};
There is no version in the URL, so "the latest build" is the only build obtainable, and rolling back is not a thing a user can do. The endpoint itself hands out a short-lived presigned URL, which I wrote about in Our download button is a 302 to a URL that stops working in 60 seconds, and the three buttons on the page exist because the browser cannot reliably tell you which Mac it is running on, which is Chrome on an M3 says it is an Intel Mac, so our download page has three buttons and no guessing.
The cheapest improvement is not an updater. It is a version endpoint on the website and one comparison in the app, so the window can say "1.2.0 is out" with a link to the download page. That keeps the install step manual, costs one request on startup, and removes the only cost in that list that genuinely hurts, which is users who do not know there is anything to download.
The general shape
If you are building a desktop app and considering shipping without an updater, the decision is not really about updates. It is about whether you can state, file by file, that nothing the user would miss lives inside the bundle, and whether the thing that locates the data outside the bundle is something you could change by accident.
In this app the answer to the first is nine exported paths, and the answer to the second is one word in package.json and a warning in a markdown file. The first one I am happy with. The second is a reminder that in a packaged app the riskiest strings are often the ones that look like branding.
The current build is at notifio.app/download, what it does once installed is on the per-site pages such as notifio.app/alerts/pararius, and if an install does go wrong the contact form at notifio.app/help is where it lands.
Top comments (0)