DEV Community

Cover image for Electron Tray, Global Shortcuts, and Notifications: A Field Guide to Desktop Integration
mp3sss
mp3sss

Posted on AI-assisted

Electron Tray, Global Shortcuts, and Notifications: A Field Guide to Desktop Integration

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

  1. Why this layer matters
  2. Tray icon: more than a picture
  3. Global shortcuts: hotkeys that work everywhere
  4. Native notifications: permission, actions, and clicks
  5. Badge, dock, and taskbar progress
  6. Cross-platform cheat sheet
  7. Production pitfalls we actually hit
  8. 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()
  }
}
Enter fullscreen mode Exit fullscreen mode

Two rules that aren't obvious from the docs:

  • On macOS, name the icon *Template.png and design it as a black-and-white asset with template: 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.createFromPath with { 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 })
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

Three things will bite you:

  1. Register failures are silent. If another app (Slack, Raycast, your own old build) already owns Ctrl+Shift+Y, register() returns false and nothing else happens. Always check the return value and tell the user.
  2. 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.plist without explaining why; just handle the denial gracefully.
  3. 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()
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.png naming 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.register return value is checked
  • [ ] will-quit calls globalShortcut.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.

Top comments (0)