Notifio is a desktop app that watches rental listing search pages and emails you the moment a new listing shows up on one. The whole value is in the word "moment", which means the thing that must never happen is the app quietly stopping.
Electron's defaults are almost exactly wrong for that. Close the last window on Windows or Linux and the app exits. The window is the app, by default, and for us the window is the least important part of the app: it is a control panel for a poll loop that should still be running when nobody is looking at it.
So the lifecycle is written out by hand, and the shortest handler in the file is the one that does the most work.
app.on('window-all-closed', () => {
// Keep app running in tray
});
One boolean separates hide from quit
Closing the window hides it. The only way to tell a real quit from a hide is a flag, and that flag is set by the two things that are allowed to end the process.
mainWindow.on('close', (event) => {
if (!(app as any).isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
The tray menu's Quit item sets it before calling app.quit(), and before-quit sets it again for the paths that do not come through the tray, such as a system shutdown or a Cmd+Q. Without the flag, close would cancel the real quit too, and the app would be unkillable from its own window.
That makes the tray the only surface that can end the process, so it is also the only surface that explains the app is still running:
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show Window', click: () => { /* show or recreate */ } },
{ type: 'separator' },
{ label: 'Quit', click: () => { (app as any).isQuitting = true; app.quit(); } },
]);
A left click on the tray icon toggles the window rather than always showing it, because once hiding is the normal state, "show" and "hide" are the same gesture.
Two monitors on one machine is worse than none
The single instance lock is three lines and it prevents a class of bug that would be very hard to diagnose from a support email.
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (!mainWindow.isVisible()) mainWindow.show();
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
// ...
}
Because the window is usually hidden, launching from the dock or the Start menu feels like the app is not open, which is exactly the moment a user double clicks the icon again. Two copies of Notifio would poll the same saved searches twice, double the request rate we present to each site, and each process would hold its own idea of what the page looked like last time. The second one starting up would rebuild its baseline from scratch, which is a rule we chose deliberately and is correct exactly once per launch. A second instance turns "start fresh" into "start fresh while another process is mid cycle".
So a second launch is not a second app. It is a request to see the window.
Sleep is not a stop, but the browser does not know that
The poll loop drives a real browser, and a laptop lid changes what that browser is capable of. Contexts that were alive when the machine suspended come back as handles that throw on first use, with errors about a closed target that have nothing to do with the code that is running.
powerMonitor.on('suspend', () => {
logFile('[main] System suspending, closing browser contexts');
try {
const scraper = require('./scraper');
scraper.closeAll().catch(() => {});
} catch { /* scraper not loaded yet */ }
});
Throwing the contexts away before the machine sleeps means the next cycle opens new ones. Nothing above this layer needs to know the machine slept, and no state that matters lives in a browser context. The same closeAll() runs on before-quit, for a less interesting reason: an Electron app that exits without closing its Chromium contexts leaves orphaned processes behind, and a user who quits an app expects their activity monitor to agree.
The window itself handles being re-shown by reloading its own renderer, which is the other half of this design: if the window can vanish for a day and come back, it cannot be trusted to remember anything.
Every link leaves
The window loads the app's own UI from a local HTTP server, and absolutely everything else is somebody else's site. A listing link opened inside the app window would be opened in a browser profile the user has never signed into, which for a rental site means a login wall instead of a room.
const internalOrigin = `http://localhost:${serverPort}`;
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(internalOrigin)) {
shell.openExternal(url);
}
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith(internalOrigin)) {
event.preventDefault();
shell.openExternal(url);
}
});
Both handlers are needed because they catch different things. setWindowOpenHandler covers a target of _blank, will-navigate covers a plain link and a scripted navigation. The window open handler returns deny in every case, including the internal one, because nothing in this UI wants a second window.
The window has no preload script, contextIsolation is on and nodeIntegration is off, so the renderer has no bridge to Electron at all. That is a separate decision with its own consequences, and it is the subject of the next post.
The window that only exists on the worst day
Startup can fail in a packaged app for reasons that never occur in development: a data directory that cannot be created, a bundled browser binary that did not survive the installer, a permission the operating system decided to ask about. The failure mode of an Electron app in that situation is to show nothing at all, which from the user's side is indistinguishable from a broken download.
} catch (err) {
const msg = err instanceof Error ? err.stack || err.message : String(err);
logFile(`[main] STARTUP ERROR, showing fallback window. Error: ${msg}`);
mainWindow = new BrowserWindow({ width: 800, height: 400, show: true });
mainWindow.loadURL(
`data:text/html,<pre style="font:14px monospace;padding:24px;background:#0a0a0a;color:#fafafa">Notifio failed to start.<br><br>${msg.replace(/</g, '<')}<br><br>Check logs: ${LOG_PATH}</pre>`
);
}
It is deliberately the dumbest window in the app. No server, no renderer bundle, no React, nothing that could itself be the thing that failed. A data: URL and a stack trace, plus the path to the log file, because the first useful question in a support conversation is "what does the log say" and the answer should not require knowing where Electron puts userData on three operating systems.
That log file is written from the first line of the main process, including from the two handlers everybody means to add and forgets:
process.on('uncaughtException', (err: Error) => {
logFile(`UNCAUGHT: ${err.stack || err.message}`);
});
process.on('unhandledRejection', (reason: unknown) => {
logFile(`UNHANDLED REJECTION: ${String(reason)}`);
});
What this looks like from outside
Download the app at notifio.app/download, which serves separate builds for Apple Silicon, Intel Macs and Windows, for reasons that come down to a bundled Chromium.
What the lifecycle buys is visible on the first run and never again: you close the window, the app keeps checking, and the next thing you hear from it is an email about a room. notifio.app/help is the page that explains the tray behaviour to users who expect a close button to mean close, and the per site pages such as notifio.app/alerts/spareroom and notifio.app/alerts/kamernet describe what the loop is doing while the window is hidden.
Top comments (0)