TL;DR — The difference between a "web app in a window" and a real desktop app lives in the tray icon that stays put when the window closes, the hotkey that summons your app from anywhere, and the notification that lands even when the window is hidden. All three are easy to call, and all three are easy to get wrong in cross-platform ways. This guide covers the minimal main-process wiring, the platform-specific quirks that bite you in production, and the checklist we now use before shipping a build that touches any of them.
Table of contents
- Why this layer matters
- Tray icon: more than a picture
- Global shortcuts: hotkeys that work everywhere
- Native notifications: permission, actions, and clicks
- Badge, dock, and taskbar progress
- Cross-platform cheat sheet
- Production pitfalls we actually hit
- Ship checklist
Why this layer matters
Browser web apps run inside a tab that the user can throw away. Desktop apps are expected to stay present: a tray icon, a system-wide hotkey, and a notification center entry are how the user knows your app is still alive, how they get back to it, and how you reach them when the window isn't focused.
In Electron all three live in the main process. The renderer should never touch them directly — it sends a structured IPC message ("the user clicked 'Remind me in 5 min'") and main decides what tray menu, global shortcut, or notification needs to change. That separation matters because tray icons and global shortcuts are process-wide singletons: you can't have two of them, and you can't leak one when a window closes.
Tray icon: more than a picture
A Tray object is a reference to an icon in the operating system's notification area (Windows) or status bar (macOS / Linux). Create it once, keep a reference on app.whenReady(), and never let it get GC'd — if you assign it to a local variable and return, the icon vanishes silently.
// main/tray.ts import { app, Tray, Menu, BrowserWindow, nativeImage } from 'electron' let tray: Tray | null = null function buildTrayMenu(win: BrowserWindow) { return Menu.buildFromTemplate([ { label: 'Open WiseWorks', click: () => showMainWindow(win) }, { type: 'separator' }, { label: 'Pause sync', type: 'checkbox', checked: false, click: (item) => win.webContents.send('sync:set-paused', item.checked), }, { type: 'separator' }, { label: 'Quit', role: 'quit' }, ]) } export function initTray(win: BrowserWindow) { // 16x16 logical pixels; Electron scales on HiDPI. Use a template PNG on macOS. const icon = nativeImage.createFromPath( app.isPackaged ? process.resourcesPath + '/assets/tray-iconTemplate.png' : 'assets/tray-iconTemplate.png', ) tray = new Tray(icon) tray.setToolTip('WiseWorks') tray.setContextMenu(buildTrayMenu(win)) // Windows/Linux: a left-click should toggle the window. macOS: left-click // shows the context menu by default, and you must opt into click behavior. tray.on('click', () => showMainWindow(win)) } function showMainWindow(win: BrowserWindow) { if (win.isVisible()) { win.focus() } else { win.show() } } Two rules that aren't obvious from the docs:
- On macOS, name the icon
*Template.pngand design it as a black-and-white asset withtemplate: true; macOS will tint it to match the light/dark menu bar. A full-color icon looks out of place and doesn't invert with the system theme. - On Windows, the tray icon lives at 16×16 logical pixels. A 32×32 PNG gets downscaled by the shell and looks blurry. Ship a separate 16×16 asset (or let Electron pick the right scale via
nativeImage.createFromPathwith{ scaleFactors: [1, 2] }).
"Close to tray" behavior
The single most common tray requirement: user clicks the window's ✕, the app should hide instead of quitting.
win.on('close', (e) => { if (!app.isQuitting) { e.preventDefault() win.hide() } }) // Somewhere — tray menu "Quit" or auto-updater before-quit: app.on('before-quit', () => { app.isQuitting = true }) Without the app.isQuitting flag, the real Quit path (from the tray menu, from Cmd+Q, or from the auto-updater) also gets intercepted and the app refuses to die.
Global shortcuts: hotkeys that work everywhere
globalShortcut lets you bind a chord that fires even when your app is in the background — think Spotlight-style summon, screenshot region, or push-to-talk.
// main/shortcuts.ts import { app, globalShortcut, BrowserWindow } from 'electron' export function registerGlobalShortcuts(win: BrowserWindow) { const summon = 'CommandOrControl+Shift+Y' const ok = globalShortcut.register(summon, () => { if (win.isVisible()) { win.hide() } else { win.show() win.focus() } }) if (!ok) { // Another app already owns the chord. Don't crash — surface it in UI. win.webContents.send('shortcut:conflict', summon) } app.on('will-quit', () => globalShortcut.unregisterAll()) } Three things will bite you:
- Register failures are silent. If another app (Slack, Raycast, your own old build) already owns
Ctrl+Shift+Y,register()returnsfalseand nothing else happens. Always check the return value and tell the user. - On macOS, certain chords require Accessibility permissions. The first time you register a global shortcut that fires on a modifier-only chord, the OS may prompt. Don't pre-emptively request it in
Info.plistwithout explaining why; just handle the denial gracefully. - Unregister on quit. A hotkey that survives your app's process is the classic "why does this chord still trigger my app after I quit" bug.
will-quit→unregisterAll()is the safety net.
Native notifications: permission, actions, and clicks
Electron's Notification wraps the OS notification center. Unlike web notifications, you get real click events and action buttons.
// main/notify.ts import { Notification, BrowserWindow, app } from 'electron' export function notifySyncComplete(win: BrowserWindow, changed: number) { if (!Notification.isSupported()) return const n = new Notification({ title: "'WiseWorks sync finished'," body: `${changed} records updated.`, silent: false, // On Windows this shows in Action Center; on macOS in Notification Center. }) n.on('click', () => { showMainWindow(win) win.webContents.send('navigate', '/sync/history') }) // Action buttons are supported on macOS and Windows 10+. n.on('reply', (_evt, reply) => { win.webContents.send('sync:note', reply) }) n.show() } Do not build your own in-app toast for this. The OS notification center is where users actually look, and clicking a real notification can bring your app forward even when it's hidden in the tray — which is the whole point.
Badge, dock, and taskbar progress
The "unread count" number in the dock, and the progress bar on the taskbar icon, are how you communicate long-running state without demanding attention.
// macOS dock badge if (process.platform === 'darwin' && app.dock) { app.dock.setBadge(String(unreadCount)) // '0' clears it } // Windows taskbar progress (determinate, 0..1) if (process.platform === 'win32') { win.setProgressBar(progressRatio) // -1 removes it } // Linux: Unity dock badge via launcher properties — best effort, often no-op. Keep the badge number small. A dock badge that says "247" tells the user they've already lost; they'll ignore it. Cap it at 99 and clear it the moment the user looks at the surface.
Cross-platform cheat sheet
| Concern | macOS | Windows | Linux |
|---|---|---|---|
| Tray icon format | *Template.png, black+alpha | 16×16 PNG (or .ico) | 16×16 PNG, theme varies |
| Tray left-click | Shows context menu by default; you must opt in to click event | Fires click event | Fires click event |
| Global shortcut failure | Returns false on conflict | Returns false on conflict | Returns false on conflict |
| Notification actions | reply + buttons | Buttons, no inline reply | Basic body, often no buttons |
| Unread badge | app.dock.setBadge('N') | win.setOverlayIcon (a small icon, not a number) | Often unsupported |
| Taskbar progress | n/a | win.setProgressBar(0..1) | Best effort / no-op |
| "Close to tray" | Use dock, not tray | Tray is the expected pattern | Tray varies by DE |
Production pitfalls we actually hit
| Pitfall | What we saw | Fix |
|---|---|---|
| Tray icon GC'd | Icon vanished on win.reload() | Keep tray at module scope; never a local |
| Colorful tray icon on macOS | Icon looked wrong in dark mode | Rename to *Template.png, redesign as 2-color alpha |
| Close-to-tray loop | Cmd+Q didn't quit | app.isQuitting flag set in before-quit |
| Hotkey conflict silently ignored | User reported "Y key does nothing" | Check register() return, surface IPC warning |
| Badge grows forever | Dock showed "128" after a week | Cap at 99, clear on window focus |
| Notification click does nothing | Users thought app was broken | Wire n.on('click') → win.show() + route |
setProgressBar stuck at 1 | Long task crashed mid-run | Reset to -1 in both success and error paths |
Ship checklist
- [ ] Tray icon created once in
whenReady(), reference held at module scope - [ ] macOS tray icon uses
*Template.pngnaming and template image rules - [ ] Windows ships a 16×16 (and @2x) tray asset, not a downscaled 32×32
- [ ] Tray menu has Open / separator / useful action / Quit
- [ ] Window ✕ hides (not exits), gated by
app.isQuitting - [ ] Every
globalShortcut.registerreturn value is checked - [ ]
will-quitcallsglobalShortcut.unregisterAll() - [ ] Notifications are native (not in-app toasts), clickable, and route somewhere
- [ ] Badge is capped, and cleared when the user opens the relevant surface
- [ ] Taskbar progress is reset on both success and error
- [ ] Conflict / permission failure paths send IPC to renderer, never throw
Desktop integration is where Electron apps earn their keep. The web view inside your window is commodity — the tray icon that persists, the hotkey that works from anywhere, and the notification that reaches the user, are the things that make the app feel native. Wire them deliberately, test them on all three platforms, and treat them as first-class main-process concerns, not as an afterthought tacked on before launch.