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.
Deep Links and Custom Protocols
Two distinct concerns share the word "protocol": URL scheme launching (myapp://… opens your app) and serving app content from a custom scheme.
Launching the app from a link
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-sqlite3Prefer 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
| Surface | API | Platform notes |
|---|---|---|
| Menu | Menu.buildFromTemplate, roles | macOS app menu is separate; use CmdOrCtrl |
| Tray | Tray, nativeImage | macOS template images; keep a live reference |
| Hotkeys | globalShortcut | Unregister on will-quit; may already be taken |
| Dialogs | dialog.show* | Parent to a window for macOS sheets |
| Deep links | setAsDefaultProtocolClient | open-url (mac) vs second-instance argv |
| Content scheme | registerSchemesAsPrivileged + protocol.handle | Mark secure: true for CSP/origin |
| Native modules | N-API, @electron/rebuild | Prefer prebuilds; must be asarUnpack-ed |
| OS state | powerMonitor, nativeTheme, systemPreferences | Always stop power-save blockers |