DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Closing our window does not quit our app, and the line that makes that true is an empty event handler

Notifio is a desktop app that checks your rental searches every 30 seconds and emails you when a new listing appears. The entire value of it is that it is running when you are not looking, so the most product-critical code in the app is not the monitor. It is the handful of lifecycle hooks that decide what "closed" means.

There are five of them and they do not agree with each other on purpose.

Hook one: the close button does not close

mainWindow.on('close', (event) => {
  if (!(app as any).isQuitting) {
    event.preventDefault();
    mainWindow?.hide();
  }
});
Enter fullscreen mode Exit fullscreen mode

Pressing the red button or Alt+F4 hides the window and leaves the process running. The flag is the only thing that distinguishes a real quit from a close, and it is set in exactly two places: the tray's Quit item, and before-quit.

That (app as any) cast is not something I am proud of. Electron's App type has no isQuitting, so the code hangs a property off the framework's own singleton and casts away the type error at all three sites. The alternative is four lines:

let isQuitting = false;
export function markQuitting() { isQuitting = true; }
Enter fullscreen mode Exit fullscreen mode

Module scope would be more honest, and the only reason the cast survives is that the two writers and one reader all live in the same file, so the blast radius of the hack is one 396-line file. It is still the pattern that teaches a future version of this file to do it again.

Hook two: the empty handler

This is my favourite four lines in the app, because three of them are a comment and the fourth does nothing:

app.on('window-all-closed', () => {
  // Keep app running in tray
});
Enter fullscreen mode Exit fullscreen mode

Electron's default behaviour is to quit the app when every window has closed, on every platform except macOS. Registering a handler that does nothing is how you switch that off. Delete these lines and nothing in the diff looks like a behaviour change, while the product quietly becomes an app that stops hunting for rooms the moment you tidy your desktop.

An empty function with a comment in it is doing real work here, and it is the sort of code that a cleanup pass deletes. If I were being rigorous the comment would say what breaks.

Hook three: the tray is the only way back

With no window and no application menu, the tray is the entire interface:

const contextMenu = Menu.buildFromTemplate([
  {
    label: 'Show Window',
    click: () => {
      if (mainWindow) {
        mainWindow.show();
        mainWindow.focus();
      } else {
        createWindow();
      }
    },
  },
  { type: 'separator' },
  {
    label: 'Quit',
    click: () => {
      (app as any).isQuitting = true;
      app.quit();
    },
  },
]);
Enter fullscreen mode Exit fullscreen mode

The else createWindow() branch matters more than it looks: the window can be destroyed rather than hidden (quitting from the dock on macOS, for instance), and the tray then has to be able to build a new one from nothing. Clicking the icon toggles instead of always showing, which is the behaviour people expect of a status item and the behaviour they complain about when it is missing.

And here is the failure mode that worries me, which is three lines above:

try {
  trayIcon = nativeImage.createFromPath(iconPath);
  if (trayIcon.isEmpty()) throw new Error('Icon is empty');
} catch {
  trayIcon = nativeImage.createEmpty();
}
Enter fullscreen mode Exit fullscreen mode

If the icon cannot be loaded, the fallback is an empty image. On Windows, an app with no window, no application menu and an invisible tray icon is a process you can only reach through Task Manager. The guard was written to stop a missing asset crashing startup, which is right, and it trades a crash for an app you cannot see. The better trade is to keep the window un-hideable when the tray has no icon: if isEmpty() is true, do not preventDefault on close.

The icon itself is a single 8,818-byte PNG that already has five other jobs in this product, tray duty being the sixth. It is 512x512, handed to new Tray() with no resize, where Electron's own guidance is a 16x16 image with an @2x variant.

Hook four: a second launch is not a second app

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();
    }
  });
  // ...the rest of the file
}
Enter fullscreen mode Exit fullscreen mode

Double-clicking the app while it is already in the tray has to mean "show me the window", not "start a second copy". Two instances would be genuinely bad rather than just untidy: two processes polling the same searches, two sets of browser contexts, and two emails per listing.

The structural cost is that the whole of the rest of the file lives inside an else block, which is why the indentation in the lifecycle section of main.ts is a little strange. Worth it.

Hook five: sleep is not a close either

powerMonitor.on('suspend', () => {
  // logs "System suspending, closing browser contexts", then:
  try {
    const scraper = require('./scraper');
    scraper.closeAll().catch(() => {});
  } catch { /* scraper not loaded yet */ }
});

powerMonitor.on('resume', () => {
  // logs "System resumed", and that is all
});
Enter fullscreen mode Exit fullscreen mode

Closing the lid does not stop the app. It closes the browser contexts, because a context that was suspended mid-navigation comes back as a pile of "target closed" errors, and the next poll creates fresh ones. resume does nothing but write a line, and that asymmetry is deliberate: there is nothing to restore, since the next cycle rebuilds what it needs.

The require inside a try/catch rather than an import at the top is the same shape as the one in before-quit, and it exists so that a suspend before anything has ever been scraped does not drag the whole module graph in to ask it to tidy up.

What this is all in service of

The reason to be this fussy about lifecycle is that the process being alive, or not, is the thing that decides whether you get an email.

The first check of each search after a fresh start deliberately alerts about nothing. It records what the page holds and sends no email, because listings posted while the app was shut have already been seen by everybody who was watching, and an email about a room that went last night is worse than no email. The rule is keyed to the process, not to a clock: minimising, closing to the tray, letting the machine sleep and stopping and starting the monitor from inside the app all keep the same process alive and keep alerting normally. A full quit and reopen starts over.

Which means every one of the five hooks above is load-bearing for a product promise. "I closed the window" has to keep alerting. "I quit it and opened it again" has to not pretend that this morning's listings are new.

There is one more consequence, and it is the one place where the design is visibly a little rough. The window reloads itself every time it is shown again:

// Reload the UI when the window is re-shown after being hidden to prevent
// black screen and stale state (e.g. searches disappearing)
mainWindow.on('show', () => {
  mainWindow?.webContents.reload();
});
Enter fullscreen mode Exit fullscreen mode

That is a blunt fix for a real symptom, and the price is that the event stream has to replay its whole picture every time you look at the app. It is tolerable because the state that matters lives in the main process rather than in the renderer, so a reload cannot lose anything. It is still a reload where a re-render would do.

The gap I would fix first, though, is the one that has nothing to do with code quality: nothing tells you the app is still running the first time the window disappears. On macOS the dock icon is the hint. On Windows there is a tray icon you were not watching, and the honest version of hook one is a one-time notification saying Notifio is still in the tray, with the quit instruction in it. A monitor nobody knows is running is only one support email away from being a monitor nobody trusts.

You can watch the whole lifecycle from the outside: the app is on the download page, the one-time licence is on the pricing page, and the questions about leaving it running are answered on the help page. Per-site setup guides are at notifio.app/alerts, including Kamernet and Rightmove.

Top comments (0)