Steven's Knowledge

Native Integration

Menus, tray, global shortcuts, dialogs, notifications, deep links, native modules, and OS state APIs that make an Electron app feel native

Native Integration

A window that loads a web page is not yet a desktop app. What makes Electron feel native is everything around the renderer: a proper application menu, a tray icon, global shortcuts, file associations, deep links, and integration with OS power/theme state — all of which live in the main process.

Application Menu

The menu is built from a template and applied process-wide on Windows/Linux, or as the macOS menu bar. Use roles instead of hand-wiring accelerators: roles give you the correct platform label, shortcut, and behavior for free.

import { app, Menu, type MenuItemConstructorOptions } from 'electron';

const isMac = process.platform === 'darwin';

const template: MenuItemConstructorOptions[] = [
  // macOS owns the app menu (the bold app-name menu). It does not exist on
  // Windows/Linux, so only push it conditionally.
  ...(isMac
    ? [{
        label: app.name,
        submenu: [
          { role: 'about' as const },
          { type: 'separator' as const },
          { role: 'services' as const },
          { type: 'separator' as const },
          { role: 'hide' as const },
          { role: 'hideOthers' as const },
          { role: 'unhide' as const },
          { type: 'separator' as const },
          { role: 'quit' as const },
        ],
      }]
    : []),
  {
    label: 'File',
    submenu: [
      { label: 'Open…', accelerator: 'CmdOrCtrl+O', click: () => openFile() },
      // On Windows/Linux, Quit lives under File; on macOS it is in the app menu.
      isMac ? { role: 'close' } : { role: 'quit' },
    ],
  },
  { label: 'Edit', submenu: [{ role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }] },
  { label: 'View', submenu: [{ role: 'reload' }, { role: 'toggleDevTools' }, { type: 'separator' }, { role: 'togglefullscreen' }] },
  { role: 'windowMenu' },
];

Menu.setApplicationMenu(Menu.buildFromTemplate(template));

CmdOrCtrl resolves to Command on macOS and Control elsewhere — always prefer it over hard-coding modifiers.

Context menus

Context menus are per-window and shown on demand from the renderer's context-menu event, forwarded over IPC.

import { Menu, BrowserWindow } from 'electron';

win.webContents.on('context-menu', (_e, params) => {
  const menu = Menu.buildFromTemplate([
    { label: 'Copy', role: 'copy', enabled: params.editFlags.canCopy },
    { label: 'Paste', role: 'paste', enabled: params.editFlags.canPaste },
    { type: 'separator' },
    { label: 'Inspect Element', click: () => win.webContents.inspectElement(params.x, params.y) },
  ]);
  menu.popup({ window: BrowserWindow.fromWebContents(win.webContents)! });
});

Tray Icons

A Tray lives in the menu bar (macOS) or notification area (Windows/Linux). It must be kept in a long-lived reference or it gets garbage-collected and disappears.

import { app, Tray, Menu, nativeImage } from 'electron';
import path from 'node:path';

let tray: Tray; // module-level: keep it alive

app.whenReady().then(() => {
  // macOS expects a small monochrome "template image" that the OS tints for
  // light/dark menu bars. Name it *Template.png and mark it as such.
  const icon = nativeImage.createFromPath(path.join(__dirname, 'trayTemplate.png'));
  if (process.platform === 'darwin') icon.setTemplateImage(true);

  tray = new Tray(icon);
  tray.setToolTip('My App');
  tray.setContextMenu(Menu.buildFromTemplate([
    { label: 'Show', click: () => showMainWindow() },
    { type: 'separator' },
    { role: 'quit' },
  ]));
});

Tray icon sizing. macOS wants a 16×16 (32×32 @2x) template image. Windows scales a 16×16/32×32 ICO and ignores template tinting. Ship per-platform assets rather than one oversized PNG.

Global Shortcuts

globalShortcut registers system-wide hotkeys that fire even when the app is unfocused. They are a scarce resource — register on ready, always unregister before quit, and accept that another app may already own the combo.

import { app, globalShortcut } from 'electron';

app.whenReady().then(() => {
  const ok = globalShortcut.register('CommandOrControl+Shift+Space', () => toggleQuickWindow());
  if (!ok) console.warn('Hotkey already taken by another app');
});

app.on('will-quit', () => globalShortcut.unregisterAll());

Local accelerators (the ones attached to menu items) only fire when your app is focused and are the right choice for in-app commands. Reserve global shortcuts for launcher-style features.

Dialogs and Notifications

Native file and message dialogs come from dialog; they return promises and should be parented to a window so they appear as sheets on macOS.

import { dialog, BrowserWindow, Notification } from 'electron';

async function pickFile(win: BrowserWindow) {
  const { canceled, filePaths } = await dialog.showOpenDialog(win, {
    properties: ['openFile', 'multiSelections'],
    filters: [{ name: 'Images', extensions: ['png', 'jpg', 'webp'] }],
  });
  if (!canceled) return filePaths;
}

async function confirmDelete(win: BrowserWindow) {
  const { response } = await dialog.showMessageBox(win, {
    type: 'warning',
    buttons: ['Cancel', 'Delete'],
    defaultId: 0,
    cancelId: 0,
    message: 'Delete this file?',
  });
  return response === 1;
}

function notify() {
  if (Notification.isSupported()) {
    new Notification({ title: 'Export complete', body: 'Saved to Downloads' }).show();
  }
}

Use showSaveDialog for exports. Notifications are throttled and styled by the OS — keep them short and actionable.

Two distinct concerns share the word "protocol": URL scheme launching (myapp://… opens your app) and serving app content from a custom scheme.

Register your app as the handler, then read the link from the platform-specific entry point. On macOS the OS delivers an open-url event; on Windows/Linux the URL arrives as an argv on a second launch, which you catch via the single-instance lock.

import { app } from 'electron';
import path from 'node:path';

if (process.defaultApp && process.argv.length >= 2) {
  app.setAsDefaultProtocolClient('myapp', process.execPath, [path.resolve(process.argv[1])]);
} else {
  app.setAsDefaultProtocolClient('myapp');
}

// Windows / Linux: the deep link comes as argv on the second instance.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) app.quit();
app.on('second-instance', (_e, argv) => {
  const url = argv.find((a) => a.startsWith('myapp://'));
  if (url) handleDeepLink(url);
  focusMainWindow();
});

// macOS: the OS fires open-url (may arrive before windows exist).
app.on('open-url', (event, url) => {
  event.preventDefault();
  handleDeepLink(url);
});

Validate every deep link. Treat the URL as untrusted input — it can come from any web page or another app. Parse with new URL(), allow-list the host/path, and never feed it straight into navigation, shell.openExternal, or a shell command.

Serving content from a privileged scheme

To load your bundled app from a custom scheme (instead of file://) with proper origin semantics, register the scheme as privileged before ready, then handle it with the modern protocol.handle.

import { app, protocol, net } from 'electron';
import { pathToFileURL } from 'node:url';

protocol.registerSchemesAsPrivileged([
  { scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);

app.whenReady().then(() => {
  protocol.handle('app', (request) => {
    const url = new URL(request.url);
    // Resolve under your app root only — reject path traversal.
    const filePath = safeResolve(APP_ROOT, url.pathname);
    return net.fetch(pathToFileURL(filePath).toString());
  });
});

A privileged, secure: true scheme gets a stable origin, satisfies a strict CSP, and avoids the quirks of file:// (no shared origin, broken fetch, relative-path surprises).

File Associations and Drag-and-Drop

File associations are declared in the packaging config (see the Packaging page) and surface the same way as deep links: an argv on Windows/Linux and an open-file event on macOS.

app.on('open-file', (event, filePath) => {
  event.preventDefault();
  openDocument(filePath);
});

For drag-and-drop out of your app (dragging a file to Finder/Explorer), use webContents.startDrag from a main-process handler. For drag-and-drop in, the renderer's standard HTML drag events work, but read File.path in the preload — the renderer should not assume Node access.

Native Modules

Native (C/C++) addons must match Electron's V8/Node ABI, which differs from your system Node.

  • N-API / node-addon-api addons target a stable ABI, so a single prebuilt binary works across Node and Electron versions. Prefer these.
  • Nan / raw V8 addons are tied to a specific ABI and must be rebuilt for each Electron version.

When a module ships only source (or a non-matching prebuild), rebuild it against Electron's headers:

npm i -D @electron/rebuild
npx electron-rebuild -f -w better-sqlite3

Prefer N-API prebuilds. A module distributing N-API prebuilt binaries (via prebuild-install / node-gyp-build) needs no compiler on the user's machine and no rebuild when you bump Electron. This is the single biggest reduction in native-module pain.

Native modules cannot run from inside the asar archive — they must be unpacked (asarUnpack); see the Packaging page.

Power, Theme, and OS State

The main process can observe and influence OS state.

import { powerMonitor, powerSaveBlocker, nativeTheme, systemPreferences } from 'electron';

powerMonitor.on('suspend', () => pauseSync());
powerMonitor.on('resume', () => resumeSync());
powerMonitor.on('on-battery', () => throttleBackgroundWork());

// Keep the machine awake during a long export.
const blockerId = powerSaveBlocker.start('prevent-app-suspension');
// …later: powerSaveBlocker.stop(blockerId);

// Follow the OS theme; flip with nativeTheme.themeSource = 'dark' | 'light' | 'system'.
nativeTheme.on('updated', () => applyTheme(nativeTheme.shouldUseDarkColors));

// Accent color (Windows / macOS).
const accent = systemPreferences.getAccentColor();

Always pair a powerSaveBlocker.start with a guaranteed stop, or the user's machine will never sleep.

Auto-Launch at Login

app.setLoginItemSettings({
  openAtLogin: true,
  openAsHidden: true, // macOS: start in the background
  args: ['--hidden'], // your app reads this to skip showing a window
});

Make this a user-facing setting; silently adding a login item is a common malware behavior and annoys users.

Badges, Dock, and Taskbar

// Unread badge — dock on macOS, taskbar overlay on Windows/Linux (Unity).
app.setBadgeCount(3);

// macOS dock menu.
if (process.platform === 'darwin') {
  app.dock?.setMenu(Menu.buildFromTemplate([{ label: 'New Window', click: newWindow }]));
}

// Taskbar progress (0..1), or -1 to clear; <0 hides, >1 shows indeterminate.
win.setProgressBar(0.6);

// Windows jump list.
app.setJumpList([
  { type: 'custom', name: 'Recent', items: [{ type: 'task', title: 'New Doc', program: process.execPath, args: '--new' }] },
]);

Native APIs may need entitlements. Using the camera, microphone, location, or other protected resources requires usage-description keys in the macOS Info.plist (NSCameraUsageDescription, NSMicrophoneUsageDescription) and the matching hardened-runtime entitlements. Without them the app crashes or is silently denied access, and notarization can fail. Declare these in your packaging config.

Summary

SurfaceAPIPlatform notes
MenuMenu.buildFromTemplate, rolesmacOS app menu is separate; use CmdOrCtrl
TrayTray, nativeImagemacOS template images; keep a live reference
HotkeysglobalShortcutUnregister on will-quit; may already be taken
Dialogsdialog.show*Parent to a window for macOS sheets
Deep linkssetAsDefaultProtocolClientopen-url (mac) vs second-instance argv
Content schemeregisterSchemesAsPrivileged + protocol.handleMark secure: true for CSP/origin
Native modulesN-API, @electron/rebuildPrefer prebuilds; must be asarUnpack-ed
OS statepowerMonitor, nativeTheme, systemPreferencesAlways stop power-save blockers

On this page